前端国际化:语言包篇


Cover

又开了个新坑,来讲讲前端国际化。


开篇之前,读者需要区分好国际化(i18n - internationalization)和本地化(l10n - localization) , 它们是相互关联但又不同的概念:

  1. 国际化(i18n):这是一个设计和开发过程,确保产品(如软件、网站或应用)能够在不做任何修改的情况下适应不同的语言和地区。这涉及到从一开始就预留空间用于文本扩展,确保日期和时间格式可以根据地区变化,以及确保代码可以处理不同的字符集和写作系统等。
  2. 本地化(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


trtranslate 的缩写, 这么做的目的主要为了和 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 扩展名,我们扩展一下:

// webpack chain
chain.module.rule('translate').test(/\.tr$/).use('json').loader('json-loader').end()

使用 json-loader 来处理 tr 文件。






小程序端呢?

小程序端不支持动态执行代码, 所以无法使用动态导入, 解决办法就是作为静态资源提取出去,托管到静态资源服务器CDN中,远程加载:

小程序


Taro 配置为例

// Webpack 5
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') {
// 🔴 开发环境使用 JSON 引用
translation.type('json').end()
} else {
// 🔴 生产环境 使用 ’file-loader‘ 提取到 CDN 服务器
translation.type('asset/resource').set('generator', generator).end()

// 支持 import xx from './test.json?extra' 模式, 强制提取
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 LoaderPromise 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 重命名(重构)
  • 能自动发现文本硬编码,并支持提取
  • 支持机器翻译
  • 提供协同翻译….

i18n-ally

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

i18n-ally


安装了 i18n Ally 后,大多数情况下是能开箱即用。以下是一些你可能需要调整的常见配置项:


  1. 使用的框架。默认情况下,i18n ally 会分析项目根目录下的 package.json, 确定你使用的 i18n 框架,它支持了很多常见的 i18n 库,比如 vue-i18n, react-i18next

    💡 如果无法你发现 i18n ally 插件没有启用,那大概率就是它检测失败了, 可以在 OUTPUT Panel 下看的日志:

    OUTPUT

    解决办法就是显式告诉它:

    // .vscode/setting.json
    {
    "i18n-ally.enabledFrameworks": ["react-i18next"]
    }
  2. 自定义语言包检查目录。

    // .vscode/setting.json
    {
    // 支持在所有嵌套的 locales、i18n 目录下发现语言包
    "i18n-ally.localesPaths": ["**/locales", "**/i18n"]
    }
  3. 语言包配置

    我们上文使用的是 .tr 扩展名, i18n ally 并不能识别它,我们通过下面的配置来告诉它如何处理 tr 文件:

    // .vscode/setting.json
    {
    // 语言包的命名规则
    "i18n-ally.pathMatcher": "{locale}.tr",
    // 语言包的 parser
    "i18n-ally.parsers.extendFileExtensions": {
    "tr": "json"
    }
    }
  4. 其他常见配置

    {
    // 源语言。主要会影响翻译,即以哪个语言为源语言翻译到其他语种。中文开发者通常设置为中文
    "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-TWLocale chain['zh-Hant-TW', 'zh-Hant', 'zh'] , 会回退加载 zh-Hantzh 语言包。 如果有朝一日,需要对 TW 地区做特殊的适配,我们再创建一个更具体 zh-Hant-TW 语言包就行了。




3.2.2 使用嵌套命名空间来组织语言包

建议以业务模块或者团队名称来作为命名空间, 避免直接将 key 暴露到全局。

{
"rule": {
"deleteRuleTips": "删除规则后无法恢复,确定删除?",
"newRule": "新建规则",
"pointRule": "积分规则",
"tiedRule": "等级规则"
}
}

下一篇,我们介绍多语言的翻译问题,敬请期待!!






扩展阅读