
又开了个新坑,来讲讲前端国际化。
开篇之前,读者需要区分好国际化
(i18n - internationalization)和本地化
(l10n - localization) , 它们是相互关联但又不同的概念:
- 国际化(i18n):这是一个设计和开发过程,确保产品(如软件、网站或应用)能够在不做任何修改的情况下适应不同的语言和地区。这涉及到从一开始就预留空间用于文本扩展,确保日期和时间格式可以根据地区变化,以及确保代码可以处理不同的字符集和写作系统等。
- 本地化(L10n):这是将产品或内容适应到特定市场的过程。这可能包括将文本翻译成本地语言,调整图像和色彩以适应本地文化,以及修改日期、电话号码和地址格式等。本地化可能还需要考虑本地法规和商业习惯。
简单来说,国际化是创建一个可以轻易本地化的产品的过程,而本地化是将产品调整以适应特定地区的过程。两者在实际产品中的边界可能比没有那么清晰,而是相辅相成,通常在大的国际化基座上进一步进行本地化。
国际化的涉及面非常广,比如语言、文字编码、时区、书写习惯、单复数、标点符号、时间格式、货币格式、计量单位…
强烈推荐读者读一下 基础设计专栏 - From.RED 这个专栏,这里面一系列的国际化/本地化的文章都非常赞:
实际上笔者也不是特别专业,这系列文章仅是我的一些技术实践总结。作为开篇,我们先聊一聊一些比较基础的话题:前端语言包的管理。
对于语言包的管理,我们大概率会遇到以下问题:
- 语言包应该放在哪个目录?
- 全局使用一个语言包,还是分模块?
- 如果是分模块的话?粒度怎么把握?
- 怎么实现按需加载?Web 端?小程序端?
- 如果分模块组织,碎片化的语言包会不会导致多个请求?
- 如何管理和分析语言包的使用?
- 还有哪些建议?
如果进一步归纳,这些问题又可以分为三大类:
组织语言包
- 语言包应该放在哪个目录?
- 全局使用一个语言包,还是分模块?
- 如果是分模块的话?粒度怎么把握?
语言包加载
- 怎么实现按需加载?Web 端?小程序端?
- 如果分模块组织,碎片化的语言包会不会导致多个请求?
语言包管理
1. 组织语言包
1.1 放在哪个目录下?
通常放在 locales
或者 i18n
目录下。比如:
/src /locales zh.json zh-Hant.json en.json th.json
|
我们团队的规范是使用 *.tr
来作为语言包,例如:
/src /locales zh.tr zh-Hant.tr en.tr th.tr
|
tr
即 translate
的缩写, 这么做的目的主要为了和 json
文件区分开,方便后面的构建工具识别。
当然还有其他手段可以实现,但在本篇文章中我们统一约定使用 .tr
作为语言包文件。
💡 VSCode 中加上以下配置,可以将 tr 文件识别为 JSON
:
> // .vscode/settings.json > { > "files.associations": { > "*.tr": "json" > } > } >
|
1.2 全局使用一个语言包,还是分模块?
我们推荐按照业务来聚合'实现'
,大部分情况不应该将所有的语言包一股脑放在一起,除非你的项目比较简单。换句话说,应该遵循就近原则
,Global is Evil。
比如 MonoRepo 项目:
packages ├── pkgA | └── i18n | ├── en.tr | ├── zh.tr | └── ... ├── pkgB | └── i18n | ├── en.tr | ├── zh.tr | └── ... └── ...
|
分模块的好处是维护起来相对容易,尤其是后期迁移和重构时。另外一个好处是可以根据模块按需加载。
1.3 如果是分模块的话?粒度怎么把握?
为了平衡加载速度、可维护性,翻译文件不能过小、也不能过大。通常按照业务模块
的粒度来划分。业务模块是由一个或多个页面组成的完整的功能。

图片来源: https://time.geekbang.org/column/intro/100037301
如果按照 DDD 的说法,业务模块可以是一个子域
、甚至更小粒度的聚合
。总之这个业务模块有以下特征:
- 自包含。自给自足实现一个完整的功能闭环
- 高聚合。对外部依赖较少。
读者也不用过于纠结,实际在业务开发时,随着对需求了解的深入,你会摸索到它们的边界,或者你也可以从其他地方借鉴,比如后端服务的划分、产品需求结构的划分等等。
从代码的实现层面来看,你也可以认为业务模块
等同于 MonoRepo 的一个子项目
。尽管子项目内部可能会继续拆分。
2. 语言包加载
2.1 怎么实现按需加载?Web 端?小程序端?
在 Web 端,通常通过动态导入
(Dynamic Import) 实现, 例如:
registerBundles({ zh: () => import('./zh.tr'), en: () => import('./en.tr'), 'zh-Hant': () => import('./zh-Hant.tr'), th: () => import('./th.tr'), })
|
在 Webpack 中无法识别 tr 扩展名,我们扩展一下:
chain.module.rule('translate').test(/\.tr$/).use('json').loader('json-loader').end()
|
使用 json-loader
来处理 tr 文件。
小程序端呢?
小程序端不支持动态执行代码
, 所以无法使用动态导入
, 解决办法就是作为静态资源提取出去,托管到静态资源服务器
或 CDN
中,远程加载:

以 Taro
配置为例
const generator = { filename: fileLoaderOptions.name, publicPath: fileLoaderOptions.publicPath, outputPath: fileLoaderOptions.outputPath, }
ctx.modifyWebpackChain(({ chain }) => { const translation = chain.module.rule('translation').test(/\.tr$/)
if (process.env.NODE_ENV === 'development') { translation.type('json').end() } else { translation.type('asset/resource').set('generator', generator).end()
chain.module .rule('extra') .resourceQuery(/extra/) .type('asset/resource') .set('generator', generator) .end() } })
|
对于开发环境,沿用 json-loader 的方式处理,生产环境则进行资源提取(等价 Webpack 4 的 url-loader、file-loader)。
小程序语言包声明:
registerBundles({ zh: require('@wakeapp/login-sdk/i18n/zh.tr'), 'zh-Hant': require('@wakeapp/login-sdk/i18n/zh-Hant.tr'), en: require('@wakeapp/login-sdk/i18n/en.tr'), th: require('@wakeapp/login-sdk/i18n/th.tr'), })
|
同样的思路也可以用于小程序的其他静态资源、比如图片、视频、字体等。
2.2 如果分模块组织,碎片化的语言包会不会导致多个请求?

一个屎山项目可能会有很多语言包。如果不干预,就会有很多碎片化的请求, 在不支持 HTTP 2.0 的环境,这些请求会对页面性能造成较大的影响,怎么优化加载呢?
在 Web 端,可以利用 splitChunks
对语言包进行合并:
const TRANSLATE_FILE_REG = /([^./]*)\.tr$/
function getLocale(request: string) { return request.match(TRANSLATE_FILE_REG)?.[1] }
// ... 省略部分代码
// 翻译文件资源合并, 避免碎片化, 导致并发请求数量过多 if (process.env.NODE_ENV === 'production') { const splitChunks = chain.optimization.get('splitChunks') if (splitChunks == null) { // 已禁用 return }
const translateMerge = { // 只针对异步模块 chunks: 'async', test: /\.tr$/, // 🔴 最大尺寸 maxSize: 200 * 1024, name: (module: { rawRequest: string }) => { const request = module.rawRequest if (request == null) { throw new Error(`[vue-cli-plugin-i18n]: failed to get locale from ${request}`) } // 🔴 按 locale 作为 key 进行合并 return `${getLocale(request)}-tr` }, // 强制执行 enforce: true, }
chain.optimization.splitChunks({ ...splitChunks, cacheGroups: { ...splitChunks.cacheGroups, translateMerge, }, }) }
|
上面的代码就是使用 splitChunks 对相同 Locale 的语言包进行合并,最大体积不超过 200kb。
小程序端暂时不支持这种方式。可以通过其他手段来弥补,比如人工避免碎片化、缓存到本地存储等等。
2.3 registerBundles 怎么实现?
registerBundles
负责对语言包进行注册、加载、合并、激活等操作:

- 调用
registerBundles
会将相关语言包注册到资源表
(Resouces)中。它可以接收对象、HTTP 链接、Promise 等
- 具体要加载哪个语言包由 i18n 库通知。i18n 库传入一个
Locale chain
, 这是一个字符串数组。表示的是 i18n 库的语言回退链条
, 或者说 i18n 库就是按照这个顺序到语言包中查找 key,比如当前 locale 是 ‘zh-Hant-HK
’, 那么 Locale chain 就是 ['zh-Hant-HK', 'zh-Hant', 'zh']
- 接着根据
Locale chain
计算出需要加载的语言包。
- 根据资源的类型选择不同的
Loader
(加载器)进行处理。比如 HTTP Loader
、Promise Loader
- 当所有语言包加载就绪后,将所有结果合并成一棵树,返回给 i18n。合并时可以有优先级,比如某些语言包从后端服务中获取,我们希望它能覆盖其他语言包,优先展示。
来看一下具体代码:
export class BundleRegister { private executing = false
private resources: { [locale: string]: Set<I18nBundle> } = {}
private layerLinks: { [locale: string]: LayerLink } = {}
/** * 缓存资源的层级 */ private resourceLayer: Map<I18nBundle, number> = new Map()
private pendingQueue = new PromiseQueue<void>()
constructor( private registerBundle: (locale: string, bundle: Record<string, any>) => void, private getLocaleChain: () => string[], private onBundleChange: () => void ) {}
/** * 判断是否存在正在加载中的语言包 */ hasPendingBundle() {}
/** * 调度语言包加载和合并 */ async schedulerMerge(): Promise<void> {}
/** * 注册语言包 */ registerBundles = async ( bundles: { [locale: string]: I18nBundle }, layer: number = 10 ): Promise<void> => {} }
|
整个类的结构如上,构造函数需要传入三个钩子:
- registerBundle。 BundleRegister 通过它向 i18n 库提交语言包(message)
- getLocaleChain。向 i18n 获取 local chain
- onBundleChange。语言包变动事件通知
看下在 vue-i18n(9+) 下怎么对接:
// 🔴 初始化 const bundleRegister = new BundleRegister( (loc, bundle) => { // 🔴 提交语言包 const initialMessages = messages?.[loc] let cloneBundle = bundle
// 拷贝 if (initialMessages) { cloneBundle = merge({}, initialMessages, cloneBundle) }
vueI18nInstance.setLocaleMessage(loc, cloneBundle) }, // 🔴 获取 Local chain getFallbackLocaleChain, () => { eventBus.emit(EVENT_MESSAGE_CHANGE) } )
// 🔴 监听语言变动并触发 BundlerRegister 加载 watch( () => unref(vueI18nInstance.locale), (loc) => { // 检查是否通过 setLocale 调用 if (!SET_LOCALE_CONTEXT) { console.error(`[i18n] 禁止直接设置 .locale 来设置当前语言, 必须使用 setLocale()`) }
eventBus.emit(EVENT_LOCALE_CHANGE, loc) bundleRegister.schedulerMerge() }, { flush: 'sync' } )
|
返回来看注册细节。registerBundles
就是注册语言包,过程很简单:
/** * 注册语言包 */ registerBundles = async ( bundles: { [locale: string]: I18nBundle }, layer: number = 10 ): Promise<void> => { let dirty = false Object.keys(bundles).forEach((k) => { const normalizedKey = k.toLowerCase() // 登记到资源表 const list = (this.resources[normalizedKey] ??= new Set()) const bundle = bundles[k]
const add = (b: I18nBundle) => { if (!list.has(b)) { list.add(b) this.resourceLayer.set(b, layer) dirty = true } }
if (Array.isArray(bundle)) { for (const child of bundle) { add(child) } } else { add(bundle) } })
if (dirty) { // 🔴 立即调度加载 return await this.schedulerMerge() } }
|
相对比较复杂的是 scheduleMerge
,但也不难理解:
async schedulerMerge(): Promise<void> { // 🔴 执行中,不需要重新发起 if (this.executing) { return await this.pendingQueue.push(); }
let queue = this.pendingQueue;
try { this.executing = true;
// 🔴 等待更多 bundle 插入,批量执行 await Promise.resolve();
// 🔴 下一批执行 this.pendingQueue = new PromiseQueue();
// 🔴 加载当前语言 const localeChain = this.getLocaleChain();
// 🔴 已经加载的语言 let messages: { [locale: string]: Record<string, any>[] } = {}; let task: Promise<void>[] = [];
// 🔴 遍历 localeChain for (const locale of localeChain) { const resource = this.resources[locale.toLowerCase()];
if (resource == null) { continue; }
for (const bundle of resource.values()) { // 🔴 跳过已经加载 if (isLoaded(bundle)) { continue; } // 🔴 layer 表示语言包的分层,或者说合并的优先级, 层数越低优先级越高 const layer = this.resourceLayer.get(bundle) ?? DEFAULT_LAYER;
if (typeof bundle === 'function') { // 🔴 异步加载函数 task.push( (async () => { const loadedBundle = await asyncModuleLoader(bundle as I18nAsyncBundle); if (loadedBundle) { this.setLayer(loadedBundle, layer); console.debug(`[i18n] bundle loaded: `, bundle); (messages[locale] ??= []).push(loadedBundle); } })() ); } else if (typeof bundle === 'string') { // 🔴 http 链接 task.push( (async () => { const loadedBundle = await httpLoader(bundle);
if (loadedBundle) { this.setLayer(loadedBundle, layer); console.debug(`[i18n] bundle loaded: `, bundle); (messages[locale] ??= []).push(loadedBundle); } })() ); } else { // 🔴 直接就是语言包对象 this.setLayer(bundle, layer); (messages[locale] ??= []).push(bundle); }
setLoaded(bundle); } }
// 🔴 并发加载 if (task.length) { try { await Promise.all(task); } catch (err) { console.warn(`[i18n] 加载语言包失败:`, err); } }
const messageKeys = Object.keys(messages);
// 🔴 接下来就是将 messages 合并成一棵树 if (messageKeys.length) { const messageToUpdate: { [locale: string]: LayerLink } = {};
for (const locale of messageKeys) { // 🔴 LayerLink 存储了所有已经加载的语言包和他的分层信息 const layerLink = (this.layerLinks[locale] ??= new LayerLink());
for (const bundle of messages[locale]) { const layer = this.getLayer(bundle);
layerLink.assignLayer(layer, bundle); }
messageToUpdate[locale] = layerLink; }
// 🔴 触发更新 for (const locale in messageToUpdate) { this.registerBundle(locale, messageToUpdate[locale].flattenLayer()); }
this.onBundleChange(); } } catch (err) { console.error(`[i18n] 语言包加载失败`, err); } finally { this.executing = false; queue.flushResolve();
// 🔴 判断是否有新的 bundle 加进来,需要继续调度加载 if (this.hasUnloadedBundle()) { // 继续调度 this.schedulerMerge(); } else { // 没有了,清空队列不需要继续等待了 this.pendingQueue.flushResolve(); } } }
|
这就是一个典型的异步任务执行的调度过程。相关的源码可以看这里
3. 语言包管理
3.1 如何管理和分析语言包的使用?
那么如何提高前端国际化的开发体验呢?比如:
- 能够在编辑器回显 key 对应的中文
- 能够点击跳转到 key 定义的语言包
- 能够分析语言包是否被引用、有没有重复、缺译的情况
- 支持 key 重命名(重构)
- 能自动发现文本硬编码,并支持提取
- 支持机器翻译
- 提供协同翻译….

🎉 还真有这么一个神器可以满足上面所有需求,那就是 VSCode 的 i18n Ally 插件(还是 antfu 大神开发的, 顶礼膜拜)!

安装了 i18n Ally 后,大多数情况下是能开箱即用。以下是一些你可能需要调整的常见配置项:
使用的框架。默认情况下,i18n ally 会分析项目根目录下的 package.json, 确定你使用的 i18n 框架,它支持了很多常见的 i18n 库,比如 vue-i18n
, react-i18next
。
💡 如果无法你发现 i18n ally 插件没有启用,那大概率就是它检测失败了, 可以在 OUTPUT
Panel 下看的日志:

解决办法就是显式告诉它:
// .vscode/setting.json { "i18n-ally.enabledFrameworks": ["react-i18next"] }
|
自定义语言包检查目录。
// .vscode/setting.json { // 支持在所有嵌套的 locales、i18n 目录下发现语言包 "i18n-ally.localesPaths": ["**/locales", "**/i18n"] }
|
语言包配置
我们上文使用的是 .tr
扩展名, i18n ally 并不能识别它,我们通过下面的配置来告诉它如何处理 tr 文件:
// .vscode/setting.json { // 语言包的命名规则 "i18n-ally.pathMatcher": "{locale}.tr", // 语言包的 parser "i18n-ally.parsers.extendFileExtensions": { "tr": "json" } }
|
其他常见配置
{ // 源语言。主要会影响翻译,即以哪个语言为源语言翻译到其他语种。中文开发者通常设置为中文 "i18n-ally.sourceLanguage": "zh", // 在编辑器内联提示的语种 "i18n-ally.displayLanguage": "zh", // 语言包的组织形式,nested 表示嵌套对象模式 "i18n-ally.keystyle": "nested" }
|
更多的配置可以看它的文档。
3.2 还有哪些建议?
3.2.1 统一语言标签
多语言的语言标签通常遵循 BCP 47, 这是由互联网工程任务组(IETF)发布的一种语言标签规范,用于唯一标识各种语言。格式为 lng-(script)-(Region 区域)-(Variant 变体)
,例如 zh-Hans-CN、en-US、zh-Hant 等等。
因为语言标签形式多种多样,而且不同的环境给出的结果可能都不太一样,所以建议开发者在维护语言包时统一使用语言标签,并且前后端保持统一。
以我们团队为例:
en 默认英文 zh 默认简体中文 zh-Hant 默认繁体 th 默认泰文
|
同时维护一些语言标签的映射规则:
{ "zh-TW": "zh-Hant-TW", "zh-HK": "zh-Hant-HK", "zh-MO": "zh-Hant-MO" }
|
你会发现我们使用的 en、zh、zh-Hant、th 这些语言标签都是 lng-(script)
形式,这样兜底/命中效果会好点。
举个例子 zh-Hant-TW
的 Locale chain
是 ['zh-Hant-TW', 'zh-Hant', 'zh']
, 会回退加载 zh-Hant
和 zh
语言包。 如果有朝一日,需要对 TW 地区做特殊的适配,我们再创建一个更具体 zh-Hant-TW
语言包就行了。
3.2.2 使用嵌套命名空间来组织语言包
建议以业务模块
或者团队名
称来作为命名空间
, 避免直接将 key 暴露到全局。
{ "rule": { "deleteRuleTips": "删除规则后无法恢复,确定删除?", "newRule": "新建规则", "pointRule": "积分规则", "tiedRule": "等级规则" } }
|
下一篇,我们介绍多语言的翻译问题,敬请期待!!
扩展阅读