多语言的Tree Shaking 之殇
我们早就习惯了这样一个原则:没被用到的代码,就不该送到用户浏览器里。
Tree Shaking 管 JavaScript,路由分割管页面,动态 import 管组件,Tailwind 管 CSS,响应式图片管媒体,字体有 subset。每一项都挺自然,以至于你根本不会觉得它们是什么高级特性——它们只是现代前端的默认操作。
可一旦你把目光移到国际化(i18n)上,画风就突然变得原始了。
直到今天,很多应用还在做这种事:
const messages = {
home: { ... },
dashboard: { ... },
settings: { ... },
editor: { ... },
checkout: { ... },
}
然后在页面里写:
t('dashboard.title')
哪怕用户只是打开首页,Dashboard、Settings、Editor、Checkout 的翻译也可能整包一起下发到浏览器里。如果系统有 10 种语言、10000 个 key、100 个页面,这个浪费就更夸张了。
于是问题就来了:
为什么国际化资源不能像 JS、CSS 一样,真正进入编译器和 Bundler 的依赖分析?
或者说:
能不能让 i18n 也拥有 Tree Shaking?
这篇文章想聊的,就是我认为现代 Web 应用最终应该走向的那种国际化架构。
我们缺的不是“多语言”,而是“按需”
传统 i18n 库解决的核心问题,是让 t('user.profile.title') 能根据当前语言返回“用户资料”或 “User Profile”。从功能上说,这当然够用了。
但从工程角度看,这里至少还有三个问题没被认真对待。
第一,页面只应该加载自己真正用到的翻译。
假设有三个页面:/dashboard、/settings、/editor。
Dashboard 用到:
dashboard.title
dashboard.create
common.logout
Settings 用到:
settings.title
settings.save
common.logout
Editor 用到:
editor.save
editor.publish
editor.preview
那么访问 /dashboard 时,理论上只应该下载前三个 key,而不是把 settings 和 editor 的翻译也打包进去。这逻辑跟 JS Tree Shaking 完全一致:没进入当前依赖图的资源,就不该进最终 bundle。
第二,用户只应该下载当前语言。
一个用中文的用户,没有任何理由下载英文、日文、法文的翻译。所以“当前页面 × 当前语言”应该共同决定最终要加载哪些翻译。更进一步,如果页面里还有 lazy component,比如:
const Editor = dynamic(() => import('./Editor'))
那 Editor 内部的翻译也不该混进首屏加载。
最终合理的模型是:
当前 Route × 当前 Locale × 当前 Dependency Graph
三者的交集,才是真正需要发送的翻译。
第三,namespace 不是银弹,它只是把问题切小了。
为了缓解整包下发的浪费,很多 i18n 方案引入了 namespace。比如:
locales/zh-CN/dashboard.json
locales/zh-CN/settings.json
locales/zh-CN/editor.json
访问 Dashboard 时只加载 dashboard.json 和 common.json,这已经很不错了。
但一个 dashboard.json 里可能有九个 key,而当前页面只用了两个。于是你仍然会把另外七个没用的 key 下发给用户。
这就像在 JS 里写:
import * as utils from './utils'
然后 Bundler 告诉你:
“我知道你只用两个函数,但整个文件我都给你打进去。”
这显然已经不符合现代前端的方向。
让翻译变成模块,而不是字典
Tailwind 有个很值得借鉴的思路:开发者只写:
class="flex items-center p-4"
构建工具负责扫描源码,只生成真正出现过的 CSS。
开发者不需要手动维护“这个页面用了哪些 utility”。
国际化其实完全可以这样。
开发者继续写:
$t('dashboard.title')
$t('dashboard.create')
$t('common.logout')
编译器扫描整个依赖图,知道哪些 key 被使用了,哪些没被使用,然后只生成真正引用过的翻译。
开发体验不变,但产物不再是一个巨大字典。
这个转变的核心在于:
把 message 从“字典条目”变成“ES Module”。
传统模型是:
message → JSON property
比如:
{
"dashboard.title": "控制台"
}
更现代的模型应该是:
message → ES Module
概念上类似:
// dashboard_title.zh-CN.ts
export default function () {
return '控制台'
}
以及:
// dashboard_title.en-US.ts
export default function () {
return 'Dashboard'
}
页面里写:
$t('dashboard.title')
编译器把它转换成:
import dashboardTitle from './messages/dashboard_title'
dashboardTitle()
这一步变化很关键。
Bundler 不再看到一个巨大的 translation dictionary,而是看到一个普通的 ESM 依赖。
从这一刻起,i18n 天然获得了现代 Bundler 已经具备的所有能力:
Tree Shaking
Code Splitting
Dynamic Import
依赖图优化
内容哈希
长期缓存
压缩
换句话说:
最好的 i18n Bundler,可能根本不该存在。
i18n 应该直接复用 JS Bundler。
编译过程:从 $t 到依赖图
假设源代码是:
export function Dashboard() {
return (
<>
<h1>{$t('dashboard.title')}</h1>
<button>{$t('dashboard.create')}</button>
</>
)
}
编译器先做一次 AST 转换,概念上变成:
import {
dashboardTitle,
dashboardCreate
} from '@/generated/messages'
export function Dashboard() {
return (
<>
<h1>{dashboardTitle()}</h1>
<button>{dashboardCreate()}</button>
</>
)
}
于是依赖图就变成了:
Dashboard
├── dashboard.title
└── dashboard.create
而 Settings 和 Editor 的依赖是独立的。
剩下的事情全部交给 Bundler。
如果应用本身已经做了路由级代码分割,那么翻译也会自动跟着路由走。
用户访问 /dashboard 时,不会下载 settings 或 editor 的翻译,因为那些翻译已经成了对应 chunk 的依赖,而对应 chunk 根本没被加载。
Lazy component 也一样:
AdvancedEditor
├── editor.publish
├── editor.preview
└── editor.autosave
只要 AdvancedEditor 没加载,这几个 message 也不应该进入首屏。
所以不需要专门设计 page namespace、route namespace 这类东西。
JS 依赖图本身就是最准确的 namespace。
连语言本身也可以被编译掉
解决 key 级 Tree Shaking 之后,还剩一个问题:
语言。
假设 dashboard.title 有四种翻译:
zh-CN
en-US
ja-JP
fr-FR
即使只保留这一个 key,如果最终 bundle 里仍然有四种 locale 的分支:
switch (locale) {
case 'zh-CN':
return '控制台'
case 'en-US':
return 'Dashboard'
case 'ja-JP':
return 'ダッシュボード'
case 'fr-FR':
return 'Tableau de bord'
}
那依然不是最优。
真正理想的架构,应该是按语言构建。
比如构建中文版时,编译器直接把:
dashboardTitle()
特化成:
'控制台'
构建英文版时特化成:
'Dashboard'
这样 zh-CN 的 build 里根本不存在:
en-US
ja-JP
fr-FR
最终会把运行时负担降到非常低:
translation runtime 0
translation dictionary 0
translation JSON 0
translation lookup 0
translation fetch 0
message provider 0
locale branching 0
最后剩下的,只是用户真正需要看到的字符串。
不过这里有个权衡。
如果你希望用户在运行时无刷新切换语言,那么浏览器就必须有能力获取其他语言的资源。
而如果每个 build 只包含一种语言,那么切换语言就变成了:
/en/dashboard
↓
/zh/dashboard
而不是:
setLocale('zh-CN')
瞬间替换文本。
两种模式各有代价,不存在完全免费的方案。
生态里已经有人在这么干了
如果你观察现有生态,会发现几条不同的路线正在逼近同一个终点。
Angular 的 AOT Localization 很早就走了编译期国际化的路。
它把翻译直接合并进应用,最终产物可能是:
dist/en/main.js
dist/zh/main.js
中文 chunk 里直接包含:
用户管理
创建用户
英文版则直接包含:
User Management
Create User
运行时甚至不需要一个完整的翻译字典。
但代价就是它牺牲了 runtime locale switching——换语言通常意味着重新导航到另一个语言的 URL。
Paraglide 更值得关注。
它试图把每一条 message 都变成普通的 JS 模块,比如:
messages/
dashboard_title/
index.js
en.js
zh.js
业务代码引用:
m.dashboard_title()
对 Bundler 来说,这就跟 import 一个普通函数没什么区别。
于是 unused message 就有机会像 unused function 一样被 Tree Shake 掉。
这是第一次真正让:
i18n dependency graph
和:
JavaScript dependency graph
开始重合。
Lingui 则从另一个方向逼近。
它很早就支持动态加载当前语言的消息文件,例如:
await import(`./locales/${locale}/messages`)
同时它也在做 dependency-tree extraction:从页面入口出发,只提取这棵依赖树真正用到的 message。
如果这两个能力最终合并,也会非常接近理想状态。
所以今天的情况是:
每条路线都摸到了大象的一部分,但还没有一个方案把所有优秀的想法完整组合到一起。
一个更容易被忽略的坑:RSC 时代的翻译序列化
Next.js 的特殊性在于,它不再是传统意义上的纯客户端 Bundler。
在 App Router / RSC 模型下,一份代码可能同时涉及:
Server Dependency Graph
Client Dependency Graph
RSC Payload
SSR HTML
Hydration Boundary
如果 Server Component 把翻译对象作为 props 传给 Client Component:
<Page messages={messages} />
那么即使这个 messages 没有进入 JS bundle,它仍然可能被序列化进 RSC Payload 和 HTML response。
于是会出现一个很隐蔽的问题:
Bundle 变小了,但 Initial Payload 并没有变小。
比如一个页面有 50KB 的翻译 JSON。
虽然它没进:
main.js
但只要它作为 props 传给了客户端组件,这 50KB 依然会出现在首屏网络响应里。
有人会说:
“HTML 和 RSC Payload 也有 gzip / Brotli,问题不大。”
这只说对了一半。
压缩当然有帮助,但问题不只是网络字节数,还包括:
序列化
反序列化
流解析
hydration data
无法独立缓存
每次导航重新传输
翻译字典这种非常适合长期缓存的静态资源,一旦被塞进 HTML / RSC,就失去了:
content hash
CDN cache
immutable cache
跨页面复用
这些能力。
所以真正理想的架构想避免的,不只是“没有 gzip”。
而是:
不要把静态国际化资源当成页面数据反复序列化。
Next.js 已经优化了一切,为什么唯独翻译还停留在 JSON 时代?
说到这里,一个很难绕开的对象就是 Next.js。
这件事之所以特别值得讨论,是因为 Next.js 并不是一个“管不了这么多”的轻量 SSR 框架。
恰恰相反。
它几乎已经把现代 React 应用里最重要的资源边界全部接管了:
Routing
Server Components
Client Components
Dynamic Import
Code Splitting
Streaming
Prefetching
Image Optimization
Font Optimization
CSS
Bundling
Turbopack
它知道一个页面依赖哪些组件。
知道哪些组件属于 Client Boundary。
知道哪些模块应该跟随 dynamic import 进入另一个 chunk。
知道哪些资源应该 preload。
甚至连字体都可以:
import { Inter } from 'next/font/google'
然后由框架在构建阶段完成下载、self-host、预加载和优化。
图片也是一样。
开发者写:
<Image />
框架理解:
这不是一个普通的
<img>,而是一种值得参与构建和运行时优化的资源。
JavaScript 有 Dependency Graph。
CSS 有构建期处理。
字体有 subset。
图片有尺寸、格式和响应式优化。
但一到翻译,常见模型突然退回到:
const dictionaries = {
en: () => import('./dictionaries/en.json'),
zh: () => import('./dictionaries/zh.json')
}
也就是:
locale
↓
dictionary
↓
lookup
而不是:
component
↓
message dependency
↓
bundle graph
这其实是一个很奇怪的断层。
Next.js 已经把其他资源带进了编译器时代,国际化却依然大量停留在“怎么加载 JSON”这个问题上。
问题不是 Next.js 没有内置 t()
这里很容易把批评带偏。
真正的问题并不是:
为什么 Next.js 不自己做一个翻译库?
事实上,我甚至不觉得框架应该再内置一个功能越来越大的 i18n Runtime。
Next.js 真正缺少的,是另一种东西:
让翻译成为一等编译期依赖的能力。
因为 Next.js 实际上已经掌握了解决这件事情所需要的大部分信息。
假设页面依赖图是:
Dashboard
├── Header
├── Chart
└── AdvancedEditor
其中 AdvancedEditor 是 lazy component。
Next.js 已经知道:
/dashboard
│
├── Header
├── Chart
│
└── dynamic(AdvancedEditor)
所以它可以决定:
哪些 JS 进首屏
哪些 JS 进入 lazy chunk
哪些 Client Component 需要 hydration
但如果 Dashboard 里面写:
$t('dashboard.title')
$t('dashboard.create')
而 Editor 写:
$t('editor.publish')
框架却不知道:
Dashboard
├── dashboard.title
└── dashboard.create
AdvancedEditor
└── editor.publish
于是程序员重新开始手工维护:
namespace
pick(messages)
provider
catalog
这就很值得反思。
因为:
程序依赖了什么,本来就是编译器应该知道的事情。
如果一个 Client Component 使用了三条翻译,开发者还需要手工告诉框架“请只把这三个 message 发过去”,那其实说明 Dependency Graph 还缺了一条边。
RSC 在一定程度上掩盖了这个问题
这里还有个很有意思的地方。
React Server Components 出现之后,Next.js 对 i18n 得到了一个非常漂亮的答案:
翻译尽量放服务器上执行,不就不用发给客户端了吗?
比如:
export default function Page() {
const t = useTranslations('Dashboard')
return <h1>{t('title')}</h1>
}
如果它是纯 Server Component,那么浏览器最终可能只收到:
<h1>控制台</h1>
这当然非常漂亮。
因为客户端确实不需要:
translation runtime
translation dictionary
locale lookup
从这个角度看,Server Component 甚至比 Tree Shaking 更彻底。
问题是:
这并没有真正解决 Client Component 的国际化。
现实中的复杂应用一定会存在:
表单
validation
optimistic UI
editor
drag & drop
charts
实时协作
offline UI
客户端状态
这些东西不可能全部停留在 Server Component。
一旦进入 Client Component:
'use client'
const t = useTranslations()
问题又回来了:
messages
provider
serialization
runtime
于是很容易出现一种架构:
<Editor
saveLabel={t('save')}
publishLabel={t('publish')}
cancelLabel={t('cancel')}
errorMessage={t('error')}
/>
表面上你成功避免了 Client i18n Runtime。
但本质上只是在用 props 手工实现:
translation dependency injection
而不是让编译器真正理解:
Editor
├── editor.save
├── editor.publish
├── editor.cancel
└── editor.error
这不是同一件事。
“尽量放 Server”不是 i18n 编译模型
Server Components 给了 Next.js 一个非常强大的优化工具,但它也容易制造一个思维盲区:
Client translation 很难优化
↓
那就尽量不要有 Client translation
这当然是一种有效的工程策略。
但它没有回答更根本的问题:
如果 Client Component 确实需要翻译,这些翻译为什么不能像它依赖的 JavaScript 一样进入 Dependency Graph?
换句话说:
Server Component 可以减少问题发生的次数。
但它不能替代一个真正的:
Message Dependency Graph
两者不是竞争关系。
真正理想的模型应该是:
Server Component
↓
直接 SSR 成字符串
Client Component
↓
编译出精确 message dependency
↓
跟随自己的 chunk
而不是:
Server Component
↓
很好
Client Component
↓
请自己 pick messages
更尴尬的是,Next.js 明明最有条件做好这件事
如果这是 Express,没有什么好批评的。
Express 不知道:
React AST
Client Boundary
Route Graph
Dynamic Import
Chunk Graph
你当然不能要求它自动分析 message dependency。
但 Next.js 不一样。
它已经知道:
Route
×
Server Graph
×
Client Graph
×
Dynamic Boundary
×
Runtime Target
理论上只差两维:
Locale
×
Message
假如编译器能把:
$t('dashboard.title')
识别成一种:
LocalizedResourceDependency
那么整个事情就会突然自然很多。
最终构建图可能直接变成:
/dashboard
│
├── Dashboard
│ ├── dashboard.title
│ └── dashboard.create
│
└── AdvancedEditor
└── editor.publish
中文版:
/dashboard × zh-CN
只留下:
dashboard.title.zh-CN
dashboard.create.zh-CN
AdvancedEditor 没加载:
editor.publish ❌
英文:
en-US ❌
日文:
ja-JP ❌
这其实不是一个离谱的幻想。
它恰恰跟 Next.js 已经在 JavaScript 上做的事情完全一致。
这也是 Turbopack 应该解决的问题之一
如果框架自己不做 i18n,没有问题。
完全可以交给:
next-intl
Paraglide
Lingui
这样的生态库。
但这样一来,Bundler 至少应该提供足够好的 compiler extension 和 dependency primitive,让第三方可以把:
message
真正注册成:
module dependency
而不是永远停留在:
loader
JSON
runtime lookup
这里的关键并不是“支持某个 i18n Plugin”。
而是:
Bundler 有没有能力表达一种新的静态资源依赖?
Vite 生态里之所以比较容易出现 Paraglide 这种方案,一个很重要的原因就是:
message
→ ESM module
→ module graph
这条路径非常自然。
而在 Next.js 里,还必须同时穿过:
Turbopack
RSC
Server Graph
Client Graph
Serialization Boundary
结果就是:
本来应该由框架层解决的问题,往往被第三方 i18n 库一点一点补。
当然,这件事没有看起来那么简单
这里也不能把问题简化成:
Next.js 团队为什么连 Tree Shaking 都不会做?
真正的难点是,i18n 和普通函数并不完全一样。
假设:
m.hello()
在编译期变成:
function hello() {
return 'Hello'
}
这当然非常适合 Tree Shaking。
但如果它要跨 RSC Boundary:
<Client message={hello} />
函数本身又不能直接序列化。
如果改成让 Client Component 自己 import:
import { hello } from './messages'
又会遇到另一个问题:
hello.en
hello.zh
hello.ja
到底应该把哪个 locale 放进 client graph?
于是事情从:
Message Tree Shaking
继续升级成:
Message Tree Shaking
×
Locale Specialization
×
Server / Client Boundary
难度是真实存在的。
但问题是:
Next.js 已经投入了巨大的工程成本去解决:
RSC
Streaming
Server Actions
Partial Prerendering
Turbopack
Image Optimization
Font Optimization
这些事情没有一个简单。
所以“很难”当然可以解释为什么它还没完成,却很难解释为什么 i18n 长期没有被提升到同一个抽象层级。
这才是 Next.js 的 i18n 真正值得批评的地方
所以我并不觉得应该简单说:
Next.js 的 i18n 做得很差。
更准确的说法是:
Next.js 对国际化的理解长期停留在 routing 和 translation data loading,而它对其他前端资源早已进入 dependency graph 和 compiler optimization 阶段。
它当然支持:
/[lang]/dashboard
当然可以根据:
Accept-Language
决定 locale。
也当然可以:
import(`./dictionaries/${locale}.json`)
但这些解决的本质是:
用户是哪种语言?
而不是:
这一行代码依赖哪一条翻译?
前者是 Routing 问题。
后者才是真正的 Compilation 问题。
如果把这两件事混在一起,国际化就永远会围绕:
dictionary
namespace
loader
provider
catalog
打转。
而一旦把后一个问题单独拿出来:
module → message dependency
整个架构才会突然和现代前端的其他部分对齐。
所以真正值得追问的不是:
为什么 Next.js 没有一个更好的
useTranslations()?
而是:
为什么一个拥有自己的路由、SSR Runtime、Server/Client Dependency Graph 和 Bundler 的框架,到今天仍然没有把 translation message 当成一等编译期资源?
这才是问题的核心。
好的方案应该怎么打分?
过去我们比较 i18n 方案,通常看:
支持多少语言
支不支持 ICU
有没有 React hook
翻译平台多不多
这些依然重要。
但进入现代 Bundler 时代之后,还应该加入另一组指标。
第一,Tree Shaking 粒度。
最小优化单位到底是:
整个 locale
namespace
页面
单条 message
?
如果一个组件只用了两个 message,最后还能带进去一个 100KB catalog,那么它离真正的 Tree Shaking 还很远。
第二,Locale 隔离。
中文用户有没有下载:
English
日本語
Français
?
如果有,那说明 locale 还没有真正进入构建图。
第三,路由隔离。
Dashboard 有没有混进:
Editor
Settings
Checkout
的翻译?
第四,Lazy 边界意识。
Lazy Component 的翻译是否跟着自己的 chunk 走?
dynamic(Editor)
↓
editor messages
还是首屏就被一起打进去?
第五,Initial Payload 污染。
翻译有没有进入:
HTML
RSC Payload
PageProps
hydration data
?
Bundle Analyzer 很漂亮,不代表用户真的没有下载这些数据。
第六,独立可缓存性。
翻译资源或者 localized chunk 有没有:
content hash
immutable cache
CDN reuse
跨页面复用
?
还是每次 SSR 都重新序列化一遍?
第七,运行时成本。
是否还需要:
dictionary lookup
locale branching
ICU parsing
provider
context
runtime formatter
?
这七条指标,比单纯的“支持多少语言”更能反映一个现代 i18n 方案的真实水平。
最终,i18n 应该从“数据加载问题”变成“编译问题”
传统架构总在问:
怎么加载 translation JSON?
现代架构应该问:
怎么让编译器知道这条代码依赖哪条翻译?
前一个问题会催生:
loader
namespace
provider
catalog
fetch
cache
这一大堆运行时机制。
后一个问题则会引出:
static dependency
tree shaking
code splitting
locale specialization
这些编译期能力。
这其实是一次很重要的思维转换。
甚至可以说:
最佳 i18n 的终局,可能不是出现一个功能越来越庞大的 i18n runtime。
恰恰相反。
应该是:
runtime ↓
compiler ↑
最终 i18n runtime 趋近于零。
国际化会逐渐退化成一种“编译期关注点”,就像:
TypeScript
JSX
CSS Modules
Tailwind
一样。
源码拥有丰富的表达能力,生产环境只保留真正需要执行的东西。
开发者仍然可以写:
$t('dashboard.title')
但生产环境真正得到的只是:
"控制台"
一个中文用户访问 Dashboard 时,不应该知道项目里还有:
英文
日文
Editor
Checkout
Settings
甚至不应该知道世界上存在:
translation catalog
这种东西。
他只需要收到:
当前页面真正运行的代码
当前页面真正展示的中文
没有其他语言。
没有其他页面。
没有未使用的 key。
没有巨大 JSON。
没有为了国际化而额外存在的 Runtime Dictionary。
这才是我理解中,现代 Web 国际化真正值得追求的架构。
让翻译进入 Dependency Graph,让 Bundler 决定资源边界,让用户只为自己真正看到的文字付出成本。