前端国际化,该如何实现按需加载语言包?

1,940 阅读5分钟

这是我参与11月更文挑战的第1天,活动详情查看:2021最后一次更文挑战

笔者最近接手了公司的国际化开发需求,花了不少时间调研了国际的一整套实施方案,有一点小心得,给大家分享分享。

本文仅针对国际化其中的一个小点 “按需加载语言包” 做分析。

前端国际化是什么?

所谓前端国际化,就是根据用户的语言设置以及浏览器的环境计算出网站能支持的一种语言,然后将网站的文案替换为对应语言的文案。

方案有:

  1. 针对不同语言,打包为不同的项目编译包,在 编译过程 国际化;

  2. 使用一个项目,通过 id 和语言来映射最终文案,在 运行过程 国际化;

第一种方案,在用户请求页面时就返回编译好的对应语言的 html。具体开发操作,其实就是将项目再拷贝一份,将里面的语言改为另一种语言,维护起来比较麻烦,只适合一些相对简单的静态页面。

或者可以利用打包工具,打包多份的构建文件包,但我没调研出什么可行具体方案,直觉告诉这可能不好测试、不好定位 BUG、不好维护,没有做太多的深入研究。

第二种方案更主流,在脚本运行时确定语言,再配合 id 获取文案,渲染到页面上。

需要保存 id 到文案的映射,形式就像下面这样:

const locale = {
  'zh-CN': {
    hello: '你好',
    thank: '谢谢',
    steamedBuns: '小笼包',
    goodbye: '再见'
  }
}

不过更常见的是使用 json 文件来保存,并将不同的语言的 json 放到 locale 目录下:

projectRoot
|-- lang
|   |-- en-US.json
|   |-- fr.json
|   |-- zh-CN.json

然后我们在业务代码中使用对应的语言包,然后在用到文案的地方,通过 id 拿到文案。比如 React 中,可以这样使用:

<div>{ intl.formatMessage({ id: 'hello' }) }</div>
// 渲染为
<div>你好</div>

为什么需要按需加载语言包?

所谓按需加载语言包,就是只加载用到的语言包,而不是把所有的语言包一股脑全部加载。

单一一种语言的语言包其实已经很大了,而且随网站的页面数不断增加,文案增多,语言包的大小也会日益增大。比如我参与的项目中,单独一个语言包在不压缩的情况下,行数约为 500 行,文件大小约为 300K,以后还会更多。

如果不做按需加载,做全量加载的话,每多一个语言,文件就要增加 300K。每多一个文案,就要增加 n 行内容。其实没有必要。

按需加载的方案

方案 1:后端返回的 HTML 直接带上语言包脚本

比如 notion 网站的语言设置如果为韩语,会通过在 HTML 里嵌入的方式,带上一个韩语对应的 JSON 语言包。

<script id="messages" type="application/json" data-locale="ko-KR">
  {"FrontPricingPage.individualSection.header":"개인용", ... }
</script>

然后后续的业务代码可以通过通过 JSON.parse(document.getElementById('messages').innerText) 拿到这个语言包对象。

或者可以直接嵌入一个 window.TTi18n = xxx 的 script 脚本,效果都一样,但去掉了 JSON 转换为对象过程。

这里的重点是 请求页面时后端要一次性计算出用户语言。这点可以配合 HTTP 请求头字段(Accept-Language, User-Agent)和用户信息来判断,然后将对应的语言包拼到 HTML 上返回。具体逻辑为:

  • 用户没有设置过语言 ==> 取 Accept-Language 中的第一权重语言;User-Agent 用于处理一些比较特别的客户端(比如钉钉浏览器)==> 如果该语言没有对应语言包,使用兜底语言包,通常为英文 ==> 计算结束
  • 用户设置了语言 ==> 结束

缺点:

  • 不能利用缓存,HTML 一般是不做缓存的;
  • 前后端配合有对接成本和沟通成本;
  • 语言包内容较多,拼到 html 消耗服务器资源;

方案2:前端通过动态 import 导入语言包

后端返回的模板填充好用户设置的语言。需要拼到 html 里面,比如放到全局的 useInfo 对象的 locale 属性中。不拼到 html 里的话,前端就需要多请求一个用户语言设置的接口,没太大必要。

然后和前端计算出客户端语言(navigator.language)进行组合,得到最后的语言。当然这一步也可以让后端去处理,就是方案 1 提到后端利用 HTTP 请求头字段去计算。

接着使用动态 import 加载语言包,加载的语言包可以暴露到全局环境中,再进行 React 的初始化渲染,或者是通过 context 注入到 React 组件中(React-Intl 的做法)。

例子:

locale 目录下的 zh-CN.json 文件

{
  "hello": "你好",
  "thank": "谢谢",
  "steamedBuns": "小笼包",
  "goodbye": "再见"
}

React 的入口文件

import(
  /* webpackChunkName: "lang" */
  `./locale/${lang}.json`
).then(m => {
  window.TTi18n = m.default;
  ReactDOM.render(/* ... */);
})

如果使用的是 webpack,webpack 会解析代码,将 locale 目录下的每个 json 文件打包为单独的一个模块文件。我在代码中使用了 魔法注释,指定了模块文件的名字为 lang,所以最后打包的文件名格式会像这个样子: lang0.90dfe781.chunk.jslang1.fe9ff131.chunk.js

webpack 给这些文件添加了哈希值,文件如果内容没有变化,就能很好地使用缓存。当内容发生了更新,文件名就会改变,就不会走缓存,浏览器顺利拿到最新的语言包。

webpack 动态导入官方文档

总的来说,我觉得第二种方式,即动态 import 的方式来实现按需加载语言包要更合适。缺点是需要多请求一次语言包,不过可以有效利用缓存,其实也不算是缺点。

相关阅读