引言
国际化(i18n)在 Vite 项目里不只是「换个文案」。它牵扯三层:文案的组织与加载、数字日期货币的格式化、以及 URL 与路由上的语言前缀。做得好,多语言包按需加载、切换无闪烁;做得差,所有语言文案打进首屏 bundle,或者切换语言后整页刷新白屏。
本文从国际化的三层拆分讲起,深入文案的命名空间与键设计、按语言的动态导入与分包、回退链的构造、Intl 与 ICU 的格式化,最后给出构建期优化手段与常见陷阱的排查清单。
前置:代码分割与产物优化、运行期加载策略。SSR 场景见 Vite SSR 与服务端渲染实战:从模块图到全栈框架生态。
目录
- 1. 国际化的三层:文案、格式与路由
- 2. 文案组织:JSON 结构、命名空间与键设计
- 3. 运行时加载:按语言懒加载与回退
- 4. 按语言分包:构建产物拆分策略
- 5. 复数与格式化:Intl API 与 ICU 消息
- 6. 路由与语言前缀:URL 结构与切换
- 7. 动态导入与代码分割:locale chunk 的生成
- 8. 构建期优化:内联默认语言与预加载
- 9. 常见陷阱:回退缺失、闪烁与 SSR 不一致
- 10. 工程规范与协作流程
- 延伸阅读
1. 国际化的三层:文案、格式与路由
1.1 三层的职责
国际化拆开看是三个相对独立的子系统,混在一起做必然乱:
| 层 | 职责 | 关键决策 |
|---|---|---|
| 文案 | 键值翻译、命名空间 | 结构与加载时机 |
| 格式 | 数字、日期、货币、复数 | 用 Intl 还是库 |
| 路由 | 语言前缀、切换与持久化 | URL 结构 |
1.2 为什么不建议一把梭
最常见的反模式是把所有语言的 JSON 一次性 import 进入口,结果首屏 bundle 里躺着五六种语言。文案必须能按语言拆开、按需加载,而这正好是 Vite 动态导入的强项。
反模式:import zh from './zh.json'; import en from './en.json' ...
正解: 动态 import(`./locales/${lang}.json`)
记忆:国际化拆成文案、格式、路由三层——文案要按语言动态加载,别把五六种语言的 JSON 全打进首屏。
2. 文案组织:JSON 结构、命名空间与键设计
2.1 目录结构
按语言分目录、按命名空间分文件,是扩展性最好的组织方式:
src/locales/
zh-CN/
common.json → 通用按钮、提示
home.json → 首页专属
errors.json → 错误码文案
en-US/
common.json
home.json
errors.json
2.2 键设计原则
- 用「语义」而不是「原文」做键:common.save 而不是 common.保存
- 层级不超过三层,过深难维护
- 复数、插值等特殊结构用统一的约定(如 _one / _other)
- 键一旦发布就不要改名,改名等于所有语言都要改
2.3 示例
{
"save": "保存",
"greeting": "你好,{{name}}",
"cart_items_one": "购物车有 {{count}} 件商品",
"cart_items_other": "购物车有 {{count}} 件商品"
}
记忆:文案按语言分目录、按命名空间分文件——键用语义命名且发布后不改名,插值与复数用统一约定表达。
3. 运行时加载:按语言懒加载与回退
3.1 用 i18next 做懒加载
// src/i18n.ts
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
i18n.use(initReactI18next).init({
lng: localStorage.getItem('lang') ?? 'zh-CN',
fallbackLng: 'en-US',
ns: ['common'],
defaultNS: 'common',
interpolation: { escapeValue: false },
})
export default i18n
3.2 用 Vite 的动态导入喂资源
关键点是把语言文件交给 Vite 的 import.meta.glob 管理,这样每个语言会成为独立 chunk:
const modules = import.meta.glob('./locales/*/*.json')
async function loadNamespace(lng: string, ns: string) {
const loader = modules[`./locales/${lng}/${ns}.json`]
const mod = await loader()
return mod.default
}
3.3 回退链
回退要分两级:先回退到同语言的其他命名空间,再回退到默认语言。缺失的键应当在开发期就报错,而不是运行时静默显示键名。
zh-CN.home.title 缺失 → 回退 en-US.home.title
整份 zh-CN 未加载 → 回退 en-US
记忆:按语言懒加载的核心是把语言文件交给
import.meta.glob——回退链分「同语言其他命名空间」与「默认语言」两级,缺失键要在开发期暴露。
4. 按语言分包:构建产物拆分策略
4.1 分包的自然结果
因为每种语言是独立的动态导入,Vite 会自动为每种语言生成独立 chunk:
dist/assets/
zh-CN-common-a1b2c3.js
en-US-common-d4e5f6.js
首页 chunk(不包含任何语言文案)
4.2 默认语言的处理
默认语言有两种策略,各有利弊:
| 策略 | 做法 | 适用 |
|---|---|---|
| 内联默认语言 | 默认语言同步打进主包 | 首屏就要文案 |
| 全部懒加载 | 默认语言也动态导入 | 首屏可接受短暂加载 |
4.3 避免语言包互相污染
要确保语言文件之间不共享可变状态,否则切换语言时可能出现缓存串味。每次 loadNamespace 都应重新取模块,而不是把结果挂到全局可变对象上。
记忆:每种语言的动态导入天然生成独立 chunk——默认语言可选内联(首屏即用)或懒加载(更小首屏),但语言模块之间不能共享可变状态。
5. 复数与格式化:Intl API 与 ICU 消息
5.1 优先用原生 Intl
浏览器的 Intl 覆盖了数字、货币、日期、相对时间与复数规则,不需要额外依赖:
new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(1234.5)
// ¥1,234.50
new Intl.DateTimeFormat('en-US', { dateStyle: 'long' }).format(new Date())
// October 2, 2026
5.2 复数规则
不同语言的复数规则差异很大(英语 2 类、阿拉伯语 6 类),不要自己写 count === 1 ? ... : ...:
const pr = new Intl.PluralRules('en-US')
const form = pr.select(3) // 'other'
// 用 form 去选 cart_items_one / cart_items_other
5.3 ICU 消息格式
复杂插值(如「{count, plural, …}」)可以用 ICU 语法:
{
"items": "{count, plural, =0 {没有商品} one {# 件商品} other {# 件商品}}"
}
配合支持 ICU 的格式化器(如 intl-messageformat)解析。注意:ICU 会增加运行时体积,简单场景优先用 Intl.PluralRules 手写映射。
记忆:格式化优先用原生
Intl——复数别手写三元表达式,用Intl.PluralRules选形态;ICU 消息更表达力强但更重,简单场景不必上。
6. 路由与语言前缀:URL 结构与切换
6.1 URL 结构选择
| 结构 | 示例 | 特点 |
|---|---|---|
| 路径前缀 | example.com/en/home | SEO 友好、可分享 |
| 子域名 | en.example.com | 部署复杂 |
| 查询参数 | example.com?lang=en | 最简、SEO 差 |
推荐路径前缀,对搜索引擎与分享最友好。
6.2 切换语言
切换语言时要避免整页刷新:
async function switchLang(lng: string) {
await i18n.changeLanguage(lng)
localStorage.setItem('lang', lng)
// 若用路径前缀,则同步更新 URL 而不 reload
history.replaceState(null, '', `/${lng}${location.pathname.replace(/^\/[a-z-]+/, '')}`)
}
6.3 与 Vite 的 base 配合
如果站点部署在子路径(base: '/app/'),语言前缀要拼在 base 之后,否则路由会错位。
记忆:语言前缀用路径形式最利于 SEO——切换语言用
changeLanguage加history.replaceState,不要整页刷新;子路径部署要拼在 base 之后。
7. 动态导入与代码分割:locale chunk 的生成
7.1 命名与缓存
给语言 chunk 起可预测的名字,便于长期缓存:
export default defineConfig({
build: {
rollupOptions: {
output: {
chunkFileNames: (chunk) =>
chunk.name.includes('locale')
? 'assets/locales/[name]-[hash].js'
: 'assets/[name]-[hash].js',
},
},
},
})
7.2 预加载下一门语言
在用户很可能切换的语言上做预取:
// 用户悬停语言切换按钮时预取
function prefetchLang(lng: string) {
const link = document.createElement('link')
link.rel = 'prefetch'
link.href = `/assets/locales/${lng}-common.js`
document.head.appendChild(link)
}
7.3 控制 chunk 数量
语言多时 chunk 会爆炸。可以按「语言组」合并(如 zh-CN 与 zh-TW 共享一份基础文案),或者只对高频语言做独立 chunk。
记忆:locale chunk 用可预测命名便于长缓存——对高频语言做 prefetch 预取,语言很多时按语言组合并以避免 chunk 爆炸。
8. 构建期优化:内联默认语言与预加载
8.1 内联默认语言
如果首屏强依赖文案,把默认语言同步内联进主包,避免一次额外往返:
import zhCommon from './locales/zh-CN/common.json'
i18n.init({
lng: 'zh-CN',
resources: { 'zh-CN': { common: zhCommon } },
})
8.2 用 HTML 注入预加载
可以在 index.html 里预加载默认语言资源,让浏览器尽早发起请求:
<link rel="preload" href="/assets/locales/zh-CN-common.js" as="script" crossorigin />
8.3 体积预算
- 单语言单命名空间 ≤ 30KB(gzip 后 ≤ 10KB)
- 首屏内联文案 ≤ 15KB
- 语言 chunk 总数控制在 20 个以内
记忆:首屏强依赖文案就把默认语言内联、其余懒加载——单语言单命名空间控制在 gzip 后 10KB 以内,语言 chunk 总数别失控。
9. 常见陷阱:回退缺失、闪烁与 SSR 不一致
9.1 高频陷阱
| 现象 | 原因 | 处理 |
|---|---|---|
| 页面显示键名 | 该键缺失且无回退 | 开发期报错 + 回退链 |
| 切换语言白屏 | 切换时整页刷新 | 用 changeLanguage 无刷新 |
| 首屏文案闪烁 | 文案异步到达晚于渲染 | 内联默认语言 |
| SSR 与客户端不一致 | 服务端语言判定不同 | 用同一份语言判定逻辑 |
| 语言包串味 | 模块被缓存复用 | 不共享可变状态 |
| chunk 过多 | 每种语言都独立 | 按语言组合并 |
9.2 首屏闪烁的根因
闪烁的本质是「文案还没到,页面已经渲染」。解决办法只有两个:要么把默认语言内联进主包,要么在文案就绪前渲染骨架屏。
9.3 SSR 的一致性
SSR 下必须保证服务端与客户端用同一套语言判定逻辑(同一份 Cookie 或 Accept-Language 解析),否则水合时会因为文案不同而报不匹配。
记忆:i18n 翻车集中在「缺键、闪烁、SSR 不一致」——缺键在开发期报错、闪烁靠内联默认语言、SSR 靠服务端与客户端共用一套语言判定。
10. 工程规范与协作流程
10.1 协作流程
1. 开发只写默认语言的键与文案
2. 提交流程中抽取新增键,生成待翻译清单
3. 翻译平台回填其他语言
4. CI 校验:所有语言键集合是否一致、有无缺失
10.2 CI 校验脚本思路
// 校验各语言键集合一致性
const base = Object.keys(zhCN)
for (const lang of others) {
const diff = base.filter((k) => !(k in lang))
if (diff.length) throw new Error(`${lang} 缺失键: ${diff}`)
}
10.3 落地清单
□ 文案按语言 + 命名空间组织
□ 语言文件走 import.meta.glob 动态加载
□ 回退链:同语言其他命名空间 → 默认语言
□ 格式化统一用 Intl,复数用 PluralRules
□ URL 用路径前缀,切换不刷新
□ 默认语言内联,其余懒加载
□ CI 校验各语言键集合一致
□ 监控语言 chunk 体积
记忆:i18n 的工程化落点是「键集合一致 + 按语言分包 + 回退可控」——把键校验放进 CI、把默认语言内联、把其余语言懒加载,多语言就不再是维护负担。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。