多语言的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 有个很值得借鉴的思路:开发者只写:

html
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 已经具备的所有能力:

换句话说:

最好的 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,问题不大。”

这只说对了一半。

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

翻译字典这种非常适合长期缓存的静态资源,一旦被塞进 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,那么浏览器最终可能只收到:

html
<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 方案,通常看:

这些依然重要。

但进入现代 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 决定资源边界,让用户只为自己真正看到的文字付出成本。