多语言的Tree Shaking 之殇

我们早就习惯了这样一个原则:没被用到的代码,就不该送到用户浏览器里。

Tree Shaking 管 JavaScript,路由分割管页面,动态 import 管组件,Tailwind 管 CSS,响应式图片管媒体,字体有 subset。每一项都挺自然,以至于你根本不会觉得它们是什么高级特性——它们只是现代前端的默认操作。

可一旦你把目光移到国际化(i18n)上,画风就突然变得原始了。

直到今天,很多应用还在做这种事:

ts
const messages = {
  home: { ... },
  dashboard: { ... },
  settings: { ... },
  editor: { ... },
  checkout: { ... },
}

然后在页面里写:

ts
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 用到:

text
dashboard.title
dashboard.create
common.logout

Settings 用到:

text
settings.title
settings.save
common.logout

Editor 用到:

text
editor.save
editor.publish
editor.preview

那么访问 /dashboard 时,理论上只应该下载前三个 key,而不是把 settings 和 editor 的翻译也打包进去。这逻辑跟 JS Tree Shaking 完全一致:没进入当前依赖图的资源,就不该进最终 bundle。

第二,用户只应该下载当前语言。

一个用中文的用户,没有任何理由下载英文、日文、法文的翻译。所以“当前页面 × 当前语言”应该共同决定最终要加载哪些翻译。更进一步,如果页面里还有 lazy component,比如:

ts
const Editor = dynamic(() => import('./Editor'))

那 Editor 内部的翻译也不该混进首屏加载。

最终合理的模型是:

text
当前 Route × 当前 Locale × 当前 Dependency Graph

三者的交集,才是真正需要发送的翻译。

第三,namespace 不是银弹,它只是把问题切小了。

为了缓解整包下发的浪费,很多 i18n 方案引入了 namespace。比如:

text
locales/zh-CN/dashboard.json
locales/zh-CN/settings.json
locales/zh-CN/editor.json

访问 Dashboard 时只加载 dashboard.jsoncommon.json,这已经很不错了。

但一个 dashboard.json 里可能有九个 key,而当前页面只用了两个。于是你仍然会把另外七个没用的 key 下发给用户。

这就像在 JS 里写:

ts
import * as utils from './utils'

然后 Bundler 告诉你:

“我知道你只用两个函数,但整个文件我都给你打进去。”

这显然已经不符合现代前端的方向。


让翻译变成模块,而不是字典

Tailwind 有个很值得借鉴的思路:开发者只写:

html
class="flex items-center p-4"

构建工具负责扫描源码,只生成真正出现过的 CSS。

开发者不需要手动维护“这个页面用了哪些 utility”。

国际化其实完全可以这样。

开发者继续写:

ts
$t('dashboard.title')
$t('dashboard.create')
$t('common.logout')

编译器扫描整个依赖图,知道哪些 key 被使用了,哪些没被使用,然后只生成真正引用过的翻译。

开发体验不变,但产物不再是一个巨大字典。

这个转变的核心在于:

把 message 从“字典条目”变成“ES Module”。

传统模型是:

text
message → JSON property

比如:

json
{
  "dashboard.title": "控制台"
}

更现代的模型应该是:

text
message → ES Module

概念上类似:

ts
// dashboard_title.zh-CN.ts
export default function () {
  return '控制台'
}

以及:

ts
// dashboard_title.en-US.ts
export default function () {
  return 'Dashboard'
}

页面里写:

ts
$t('dashboard.title')

编译器把它转换成:

ts
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 到依赖图

假设源代码是:

tsx
export function Dashboard() {
  return (
    <>
      <h1>{$t('dashboard.title')}</h1>
      <button>{$t('dashboard.create')}</button>
    </>
  )
}

编译器先做一次 AST 转换,概念上变成:

ts
import {
  dashboardTitle,
  dashboardCreate
} from '@/generated/messages'

export function Dashboard() {
  return (
    <>
      <h1>{dashboardTitle()}</h1>
      <button>{dashboardCreate()}</button>
    </>
  )
}

于是依赖图就变成了:

text
Dashboard
├── dashboard.title
└── dashboard.create

而 Settings 和 Editor 的依赖是独立的。

剩下的事情全部交给 Bundler。

如果应用本身已经做了路由级代码分割,那么翻译也会自动跟着路由走。

用户访问 /dashboard 时,不会下载 settings 或 editor 的翻译,因为那些翻译已经成了对应 chunk 的依赖,而对应 chunk 根本没被加载。

Lazy component 也一样:

text
AdvancedEditor
├── editor.publish
├── editor.preview
└── editor.autosave

只要 AdvancedEditor 没加载,这几个 message 也不应该进入首屏。

所以不需要专门设计 page namespace、route namespace 这类东西。

JS 依赖图本身就是最准确的 namespace。


连语言本身也可以被编译掉

解决 key 级 Tree Shaking 之后,还剩一个问题:

语言。

假设 dashboard.title 有四种翻译:

text
zh-CN
en-US
ja-JP
fr-FR

即使只保留这一个 key,如果最终 bundle 里仍然有四种 locale 的分支:

ts
switch (locale) {
  case 'zh-CN':
    return '控制台'
  case 'en-US':
    return 'Dashboard'
  case 'ja-JP':
    return 'ダッシュボード'
  case 'fr-FR':
    return 'Tableau de bord'
}

那依然不是最优。

真正理想的架构,应该是按语言构建

比如构建中文版时,编译器直接把:

ts
dashboardTitle()

特化成:

ts
'控制台'

构建英文版时特化成:

ts
'Dashboard'

这样 zh-CN 的 build 里根本不存在:

text
en-US
ja-JP
fr-FR

最终会把运行时负担降到非常低:

text
translation runtime      0
translation dictionary   0
translation JSON         0
translation lookup       0
translation fetch        0
message provider         0
locale branching         0

最后剩下的,只是用户真正需要看到的字符串。

不过这里有个权衡。

如果你希望用户在运行时无刷新切换语言,那么浏览器就必须有能力获取其他语言的资源。

而如果每个 build 只包含一种语言,那么切换语言就变成了:

text
/en/dashboard
        ↓
/zh/dashboard

而不是:

ts
setLocale('zh-CN')

瞬间替换文本。

两种模式各有代价,不存在完全免费的方案。


生态里已经有人在这么干了

如果你观察现有生态,会发现几条不同的路线正在逼近同一个终点。

Angular 的 AOT Localization 很早就走了编译期国际化的路。

它把翻译直接合并进应用,最终产物可能是:

text
dist/en/main.js
dist/zh/main.js

中文 chunk 里直接包含:

text
用户管理
创建用户

英文版则直接包含:

text
User Management
Create User

运行时甚至不需要一个完整的翻译字典。

但代价就是它牺牲了 runtime locale switching——换语言通常意味着重新导航到另一个语言的 URL。

Paraglide 更值得关注。

它试图把每一条 message 都变成普通的 JS 模块,比如:

text
messages/
  dashboard_title/
    index.js
    en.js
    zh.js

业务代码引用:

ts
m.dashboard_title()

对 Bundler 来说,这就跟 import 一个普通函数没什么区别。

于是 unused message 就有机会像 unused function 一样被 Tree Shake 掉。

这是第一次真正让:

text
i18n dependency graph

和:

text
JavaScript dependency graph

开始重合。

Lingui 则从另一个方向逼近。

它很早就支持动态加载当前语言的消息文件,例如:

ts
await import(`./locales/${locale}/messages`)

同时它也在做 dependency-tree extraction:从页面入口出发,只提取这棵依赖树真正用到的 message。

如果这两个能力最终合并,也会非常接近理想状态。

所以今天的情况是:

每条路线都摸到了大象的一部分,但还没有一个方案把所有优秀的想法完整组合到一起。


一个更容易被忽略的坑:RSC 时代的翻译序列化

Next.js 的特殊性在于,它不再是传统意义上的纯客户端 Bundler。

在 App Router / RSC 模型下,一份代码可能同时涉及:

text
Server Dependency Graph
Client Dependency Graph
RSC Payload
SSR HTML
Hydration Boundary

如果 Server Component 把翻译对象作为 props 传给 Client Component:

tsx
<Page messages={messages} />

那么即使这个 messages 没有进入 JS bundle,它仍然可能被序列化进 RSC Payload 和 HTML response。

于是会出现一个很隐蔽的问题:

Bundle 变小了,但 Initial Payload 并没有变小。

比如一个页面有 50KB 的翻译 JSON。

虽然它没进:

text
main.js

但只要它作为 props 传给了客户端组件,这 50KB 依然会出现在首屏网络响应里。

有人会说:

“HTML 和 RSC Payload 也有 gzip / Brotli,问题不大。”

这只说对了一半。

压缩当然有帮助,但问题不只是网络字节数,还包括:

  • 序列化

  • 反序列化

  • 流解析

  • hydration data

  • 无法独立缓存

  • 每次导航重新传输

翻译字典这种非常适合长期缓存的静态资源,一旦被塞进 HTML / RSC,就失去了:

text
content hash
CDN cache
immutable cache
跨页面复用

这些能力。

所以真正理想的架构想避免的,不只是“没有 gzip”。

而是:

不要把静态国际化资源当成页面数据反复序列化。


Next.js 已经优化了一切,为什么唯独翻译还停留在 JSON 时代?

说到这里,一个很难绕开的对象就是 Next.js。

这件事之所以特别值得讨论,是因为 Next.js 并不是一个“管不了这么多”的轻量 SSR 框架。

恰恰相反。

它几乎已经把现代 React 应用里最重要的资源边界全部接管了:

text
Routing
Server Components
Client Components
Dynamic Import
Code Splitting
Streaming
Prefetching
Image Optimization
Font Optimization
CSS
Bundling
Turbopack

它知道一个页面依赖哪些组件。

知道哪些组件属于 Client Boundary。

知道哪些模块应该跟随 dynamic import 进入另一个 chunk。

知道哪些资源应该 preload。

甚至连字体都可以:

ts
import { Inter } from 'next/font/google'

然后由框架在构建阶段完成下载、self-host、预加载和优化。

图片也是一样。

开发者写:

tsx
<Image />

框架理解:

这不是一个普通的 <img>,而是一种值得参与构建和运行时优化的资源。

JavaScript 有 Dependency Graph。

CSS 有构建期处理。

字体有 subset。

图片有尺寸、格式和响应式优化。

但一到翻译,常见模型突然退回到:

ts
const dictionaries = {
  en: () => import('./dictionaries/en.json'),
  zh: () => import('./dictionaries/zh.json')
}

也就是:

text
locale
  ↓
dictionary
  ↓
lookup

而不是:

text
component
  ↓
message dependency
  ↓
bundle graph

这其实是一个很奇怪的断层。

Next.js 已经把其他资源带进了编译器时代,国际化却依然大量停留在“怎么加载 JSON”这个问题上。


问题不是 Next.js 没有内置 t()

这里很容易把批评带偏。

真正的问题并不是:

为什么 Next.js 不自己做一个翻译库?

事实上,我甚至不觉得框架应该再内置一个功能越来越大的 i18n Runtime。

Next.js 真正缺少的,是另一种东西:

让翻译成为一等编译期依赖的能力。

因为 Next.js 实际上已经掌握了解决这件事情所需要的大部分信息。

假设页面依赖图是:

text
Dashboard
├── Header
├── Chart
└── AdvancedEditor

其中 AdvancedEditor 是 lazy component。

Next.js 已经知道:

text
/dashboard
   │
   ├── Header
   ├── Chart
   │
   └── dynamic(AdvancedEditor)

所以它可以决定:

text
哪些 JS 进首屏
哪些 JS 进入 lazy chunk
哪些 Client Component 需要 hydration

但如果 Dashboard 里面写:

ts
$t('dashboard.title')
$t('dashboard.create')

而 Editor 写:

ts
$t('editor.publish')

框架却不知道:

text
Dashboard
├── dashboard.title
└── dashboard.create

AdvancedEditor
└── editor.publish

于是程序员重新开始手工维护:

text
namespace
pick(messages)
provider
catalog

这就很值得反思。

因为:

程序依赖了什么,本来就是编译器应该知道的事情。

如果一个 Client Component 使用了三条翻译,开发者还需要手工告诉框架“请只把这三个 message 发过去”,那其实说明 Dependency Graph 还缺了一条边。


RSC 在一定程度上掩盖了这个问题

这里还有个很有意思的地方。

React Server Components 出现之后,Next.js 对 i18n 得到了一个非常漂亮的答案:

翻译尽量放服务器上执行,不就不用发给客户端了吗?

比如:

tsx
export default function Page() {
  const t = useTranslations('Dashboard')

  return <h1>{t('title')}</h1>
}

如果它是纯 Server Component,那么浏览器最终可能只收到:

html
<h1>控制台</h1>

这当然非常漂亮。

因为客户端确实不需要:

text
translation runtime
translation dictionary
locale lookup

从这个角度看,Server Component 甚至比 Tree Shaking 更彻底。

问题是:

这并没有真正解决 Client Component 的国际化。

现实中的复杂应用一定会存在:

text
表单
validation
optimistic UI
editor
drag & drop
charts
实时协作
offline UI
客户端状态

这些东西不可能全部停留在 Server Component。

一旦进入 Client Component:

tsx
'use client'

const t = useTranslations()

问题又回来了:

text
messages
provider
serialization
runtime

于是很容易出现一种架构:

tsx
<Editor
  saveLabel={t('save')}
  publishLabel={t('publish')}
  cancelLabel={t('cancel')}
  errorMessage={t('error')}
/>

表面上你成功避免了 Client i18n Runtime。

但本质上只是在用 props 手工实现:

text
translation dependency injection

而不是让编译器真正理解:

text
Editor
├── editor.save
├── editor.publish
├── editor.cancel
└── editor.error

这不是同一件事。


“尽量放 Server”不是 i18n 编译模型

Server Components 给了 Next.js 一个非常强大的优化工具,但它也容易制造一个思维盲区:

text
Client translation 很难优化
        ↓
那就尽量不要有 Client translation

这当然是一种有效的工程策略。

但它没有回答更根本的问题:

如果 Client Component 确实需要翻译,这些翻译为什么不能像它依赖的 JavaScript 一样进入 Dependency Graph?

换句话说:

Server Component 可以减少问题发生的次数。

但它不能替代一个真正的:

text
Message Dependency Graph

两者不是竞争关系。

真正理想的模型应该是:

text
Server Component
    ↓
直接 SSR 成字符串

Client Component
    ↓
编译出精确 message dependency
    ↓
跟随自己的 chunk

而不是:

text
Server Component
    ↓
很好

Client Component
    ↓
请自己 pick messages

更尴尬的是,Next.js 明明最有条件做好这件事

如果这是 Express,没有什么好批评的。

Express 不知道:

text
React AST
Client Boundary
Route Graph
Dynamic Import
Chunk Graph

你当然不能要求它自动分析 message dependency。

但 Next.js 不一样。

它已经知道:

text
Route
×
Server Graph
×
Client Graph
×
Dynamic Boundary
×
Runtime Target

理论上只差两维:

text
Locale
×
Message

假如编译器能把:

ts
$t('dashboard.title')

识别成一种:

text
LocalizedResourceDependency

那么整个事情就会突然自然很多。

最终构建图可能直接变成:

text
/dashboard
  │
  ├── Dashboard
  │     ├── dashboard.title
  │     └── dashboard.create
  │
  └── AdvancedEditor
        └── editor.publish

中文版:

text
/dashboard × zh-CN

只留下:

text
dashboard.title.zh-CN
dashboard.create.zh-CN

AdvancedEditor 没加载:

text
editor.publish ❌

英文:

text
en-US ❌

日文:

text
ja-JP ❌

这其实不是一个离谱的幻想。

它恰恰跟 Next.js 已经在 JavaScript 上做的事情完全一致。


这也是 Turbopack 应该解决的问题之一

如果框架自己不做 i18n,没有问题。

完全可以交给:

text
next-intl
Paraglide
Lingui

这样的生态库。

但这样一来,Bundler 至少应该提供足够好的 compiler extension 和 dependency primitive,让第三方可以把:

text
message

真正注册成:

text
module dependency

而不是永远停留在:

text
loader
JSON
runtime lookup

这里的关键并不是“支持某个 i18n Plugin”。

而是:

Bundler 有没有能力表达一种新的静态资源依赖?

Vite 生态里之所以比较容易出现 Paraglide 这种方案,一个很重要的原因就是:

text
message
→ ESM module
→ module graph

这条路径非常自然。

而在 Next.js 里,还必须同时穿过:

text
Turbopack
RSC
Server Graph
Client Graph
Serialization Boundary

结果就是:

本来应该由框架层解决的问题,往往被第三方 i18n 库一点一点补。


当然,这件事没有看起来那么简单

这里也不能把问题简化成:

Next.js 团队为什么连 Tree Shaking 都不会做?

真正的难点是,i18n 和普通函数并不完全一样。

假设:

ts
m.hello()

在编译期变成:

ts
function hello() {
  return 'Hello'
}

这当然非常适合 Tree Shaking。

但如果它要跨 RSC Boundary:

tsx
<Client message={hello} />

函数本身又不能直接序列化。

如果改成让 Client Component 自己 import:

ts
import { hello } from './messages'

又会遇到另一个问题:

text
hello.en
hello.zh
hello.ja

到底应该把哪个 locale 放进 client graph?

于是事情从:

text
Message Tree Shaking

继续升级成:

text
Message Tree Shaking
×
Locale Specialization
×
Server / Client Boundary

难度是真实存在的。

但问题是:

Next.js 已经投入了巨大的工程成本去解决:

text
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 阶段。

它当然支持:

text
/[lang]/dashboard

当然可以根据:

text
Accept-Language

决定 locale。

也当然可以:

ts
import(`./dictionaries/${locale}.json`)

但这些解决的本质是:

用户是哪种语言?

而不是:

这一行代码依赖哪一条翻译?

前者是 Routing 问题。

后者才是真正的 Compilation 问题。

如果把这两件事混在一起,国际化就永远会围绕:

text
dictionary
namespace
loader
provider
catalog

打转。

而一旦把后一个问题单独拿出来:

text
module → message dependency

整个架构才会突然和现代前端的其他部分对齐。

所以真正值得追问的不是:

为什么 Next.js 没有一个更好的 useTranslations()

而是:

为什么一个拥有自己的路由、SSR Runtime、Server/Client Dependency Graph 和 Bundler 的框架,到今天仍然没有把 translation message 当成一等编译期资源?

这才是问题的核心。


好的方案应该怎么打分?

过去我们比较 i18n 方案,通常看:

  • 支持多少语言

  • 支不支持 ICU

  • 有没有 React hook

  • 翻译平台多不多

这些依然重要。

但进入现代 Bundler 时代之后,还应该加入另一组指标。

第一,Tree Shaking 粒度。

最小优化单位到底是:

text
整个 locale
namespace
页面
单条 message

如果一个组件只用了两个 message,最后还能带进去一个 100KB catalog,那么它离真正的 Tree Shaking 还很远。

第二,Locale 隔离。

中文用户有没有下载:

text
English
日本語
Français

如果有,那说明 locale 还没有真正进入构建图。

第三,路由隔离。

Dashboard 有没有混进:

text
Editor
Settings
Checkout

的翻译?

第四,Lazy 边界意识。

Lazy Component 的翻译是否跟着自己的 chunk 走?

text
dynamic(Editor)
        ↓
editor messages

还是首屏就被一起打进去?

第五,Initial Payload 污染。

翻译有没有进入:

text
HTML
RSC Payload
PageProps
hydration data

Bundle Analyzer 很漂亮,不代表用户真的没有下载这些数据。

第六,独立可缓存性。

翻译资源或者 localized chunk 有没有:

text
content hash
immutable cache
CDN reuse
跨页面复用

还是每次 SSR 都重新序列化一遍?

第七,运行时成本。

是否还需要:

text
dictionary lookup
locale branching
ICU parsing
provider
context
runtime formatter

这七条指标,比单纯的“支持多少语言”更能反映一个现代 i18n 方案的真实水平。


最终,i18n 应该从“数据加载问题”变成“编译问题”

传统架构总在问:

怎么加载 translation JSON?

现代架构应该问:

怎么让编译器知道这条代码依赖哪条翻译?

前一个问题会催生:

text
loader
namespace
provider
catalog
fetch
cache

这一大堆运行时机制。

后一个问题则会引出:

text
static dependency
tree shaking
code splitting
locale specialization

这些编译期能力。

这其实是一次很重要的思维转换。

甚至可以说:

最佳 i18n 的终局,可能不是出现一个功能越来越庞大的 i18n runtime。

恰恰相反。

应该是:

text
runtime ↓
compiler ↑

最终 i18n runtime 趋近于零。

国际化会逐渐退化成一种“编译期关注点”,就像:

text
TypeScript
JSX
CSS Modules
Tailwind

一样。

源码拥有丰富的表达能力,生产环境只保留真正需要执行的东西。

开发者仍然可以写:

ts
$t('dashboard.title')

但生产环境真正得到的只是:

text
"控制台"

一个中文用户访问 Dashboard 时,不应该知道项目里还有:

text
英文
日文
Editor
Checkout
Settings

甚至不应该知道世界上存在:

text
translation catalog

这种东西。

他只需要收到:

  • 当前页面真正运行的代码

  • 当前页面真正展示的中文

没有其他语言。

没有其他页面。

没有未使用的 key。

没有巨大 JSON。

没有为了国际化而额外存在的 Runtime Dictionary。

这才是我理解中,现代 Web 国际化真正值得追求的架构。

让翻译进入 Dependency Graph,让 Bundler 决定资源边界,让用户只为自己真正看到的文字付出成本。