最近公司平台需国际化改造,以适配不同语言环境。基于特定需求,展开了一系列技术探索与实践。
但是平台的国际化需满足以下前提:
- 不需要支持站内实时切换语言,需刷新页面完成切换。
- 平台包含大量业务页面,导致语言包体积庞大且各页面语言包独立性高;故尽可能的切割语言包,如按路由拆分,按需获取。
- 考虑到部分页面内容需要提前就需确定,无法采用异步加载语言包的方式。
调研
github.com/fnando/i18n
I18nextProvider | react-i18next documentation
在调研过程中,我们发现大多数 i18n 实现库都侧重于站内切换语言,这会导致同时加载多个语言包,随着页面增多,语言包体积膨胀,不符合我们的需求。
不过,我们注意到了 I18nextProvider,它通过提供不同的 i18n 实例来加载不同的语言包,能在一定程度上实现路由间的分片加载,这启发了我们在第二版方案中实现类似的功能。
此外,对于异步加载语言包的方案,如将语言包放在 public 目录下,根据语言动态加载,虽然可行,但因一些限制,我们无法采用这种方式。
颗粒化方案
方案核心
方案的核心是将国际化颗粒度细化到极致,通过以下步骤实现:
- 极致颗粒度国际化:将每个文本片段都独立处理。
- 利用 Tree Shaking:通过 TypeScript 的特性,移除未使用的 i18n 变量,减少体积。
- vite 插件提取 i18n 内容:将语言包按需拆分。
- 打包压缩:通过变量压缩等手段进一步减小语言包体积。
i18n 实现
以取浏览器语言为例
typescript
const userLanguage = navigator.language || navigator.userLanguage
const supportLan = ['zh', 'en']
export const lan = supportLan.find(e => userLanguage.startsWith(e)) ?? 'zh'
export const t = (x: { [key: string]: any }) => {
return x[lan]
}
使用
语言包定义
typescript
import { t } from './getI18nText
export const Button_Add_I18n = t({
zh: '新增',
en: 'Add',
})
export const Button_DeleteText_I18n = t({
zh: '删除',
en: 'Delete',
})
export const Button_EditText_I18n = t({
zh: '编辑',
en: 'Edit',
})
export const Button_ViewText_I18n = t({
zh: '查看',
en: 'View',
})
// ...
组件中使用
typescript
const Xxx = () => {
return (
<Button type='primary'>
{Button_Add_I18n}
</Button>
)
}
打包优化
-
Tree Shaking:利用 TypeScript 的特性,过滤掉未使用的 i18n 变量,减少打包体积。
-
变量压缩:通过压缩工具,将变量名进行压缩,进一步减小体积。例如:
typescript
export const Button_Add_I18n = t({
zh: '新增',
en: 'Add',
})
// 压缩后 -->
export const a = t({
zh: '新增',
en: 'Add',
})
语言拆分
利用 vite 配置和插件,将 i18n 语言包内容进行提炼和拆分。首先,通过 rollup 的 manualChunks 提取所有 i18n.ts
文件,并为其生成唯一的 chunk 名称。
vite.config.js
typescript
module.exports = options => {
return {
// ...
build: {
rollupOptions: {
output: {
manualChunks: (id, { getModuleInfo }) => {
// 找到 i18n.ts 文件
const match = /.*i18n\.ts*/.test(id)
if (match) {
// 存储所有依赖此i18n文件的入口点
const dependentEntryPoints = []
// 使用Set避免重复处理模块,初始为当前模块的直接引用者
const idsToHandle = new Set(getModuleInfo(id).importers)
// 广度优先遍历所有父级依赖
for (const moduleId of idsToHandle) {
const { isEntry, dynamicImporters, importers } = getModuleInfo(moduleId)
if (isEntry || dynamicImporters.length > 0) {
dependentEntryPoints.push(moduleId)
}
for (const importerId of importers) idsToHandle.add(importerId)
}
// 生成稳定哈希
const depStr = dependentEntryPoints.sort().join()
const key = crypto.createHash('sha256').update(depStr).digest('hex').slice(0, 8)
// 返回格式化的chunk名称,例如:i18n-3a7f5c2d
return `i18n-${key}`
}
},
},
},
},
}
}
打包之后,我们就能看到生成的 i18n 的文件:
然后,利用 vite 插件对这些 chunk 进行处理,将其中的语言包内容拆解成单独的语言文件,如 i18n-zh-*.js
和 i18n-en-*.js
,实现单语言加载。
i18nPlugin.js
typescript
const { traverse } = require('@babel/core')
const generate = require('@babel/generator')
const { parse } = require('@babel/parser')
function RandomString(length) {
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
const digits = '0123456789'
const allCharacters = letters + digits
let result = letters.charAt(Math.floor(Math.random() * letters.length))
for (let i = 1; i < length; i++) {
result += allCharacters.charAt(Math.floor(Math.random() * allCharacters.length))
}
return result
}
const lans = ['zh', 'en']
function I18nPlugin() {
return {
name: 'i18nPlugin',
enforce: 'pre',
generateBundle(outputOptions, bundle) {
// 遍历生成的所有 chunks
for (const [fileName, chunkInfo] of Object.entries(bundle)) {
// 配合 manualChunks 中的设置,manualChunks 中将语言包的chunk命名为 i18n-xxx
if (chunkInfo.name.startsWith('i18n-')) {
// 对所有语言进行提炼
lans.forEach(lan => {
// 对文件内容进行 AST 解析
const ast = parse(chunkInfo.code, {
sourceType: 'module',
plugins: ['jsx'],
})
traverse(ast, {
ObjectExpression(path) {
// 找到对象表达式(即 { zh: '测试文本1', en: 'testText1' } 形式的部分)
path.node.properties = path.node.properties.filter(prop => {
// 保留 key 为 zh 的属性
return prop.key.name === lan
})
},
})
// 生成修改后的代码
const { code } = generate.default(ast)
// 使用 emitFile 发出新的文件
const newFileName = fileName.replace(/(i18n-)([a-z0-9]+-)/, `$1${lan}-$2`)
this.emitFile({
type: 'asset',
fileName: newFileName,
name: RandomString(),
source: code,
})
})
}
}
},
}
}
module.exports = {
I18nPlugin,
}
利用之前 manualChunks
打包好的 i18n 文件标识,取找到所有的文件并通过 AST 进行语言提炼,生成单独语言的新文件。
单语言映射
在 index.html
中加入一个标识,然后在 vite 插件中,通过 transformIndexHtml
方法,将提炼后的语言包文件进行载入并替换该标识,从而实现单语言映射。
index.html
html
<!DOCTYPE html>
<html>
<!-- .... -->
<script createImportMap></script>
<!-- .... -->
</html>
i18nPlugin.js
javascript
function I18nPlugin() {
return {
name: 'i18nPlugin',
enforce: 'pre',
generateBundle(outputOptions, bundle) {
// ...
},
transformIndexHtml(html, option) {
// 忽略 dev
if (option.server?.config?.mode === 'development') {
return html
}
const { bundle } = option
const fileList = []
// 找到所有的 i18n 文件
for (const [fileName, chunkInfo] of Object.entries(bundle)) {
if (chunkInfo.name?.startsWith('i18n-')) {
fileList.push(fileName)
}
}
const newHtml = html.replace(
`<script createImportMap></script>`,
`<script>
const fileList = ${JSON.stringify(fileList)}
const userLanguage = navigator.language || navigator.userLanguage;
const supportLan = ['zh', 'en']
const lan = supportLan.find(e => userLanguage.startsWith(e)) ?? 'zh'
const map = {}
fileList.forEach(fileName => {
map[\`/\${fileName}\`] = \`/\${fileName.replace(/(.*i18n-)([a-z0-9]+-)/, \`$1\${lan}-$2\`)}\`;
})
// 更新 importmap 的函数
function updateImportMap(newMapping) {
const importMap = document.querySelector('script[type="importmap"]');
if(importMap) {
const importMapJson = JSON.parse(importMap.textContent);
importMapJson.imports = { importMapJson.imports, ...newMapping };
importMap.textContent = JSON.stringify(importMapJson);
} else {
const importMapObj = {
imports: map,
};
const imp = document.createElement('script');
imp.type = 'importmap';
imp.textContent = JSON.stringify(importMapObj);
const head = document.head;
header.insertBefore(imp, head.firstChild)
}
}
// 使用时调用 updateImportMap 并传入新的映射
updateImportMap(map)
</script>`,
)
return newHtml
},
}
}
拆除了单独看这个替换的函数部分:
javascript
// 语言包列表
const fileList = []
const userLanguage = navigator.language || navigator.userLanguage
const supportLan = ['zh', 'en']
const lan = supportLan.find(e => userLanguage.startsWith(e)) ?? 'zh'
const map = {}
// 根据当前语言,找到对应的语言包,生成对应的映射
fileList.forEach(fileName => {
map[`/${fileName}`] = `/${fileName.replace(/(.*i18n-)([a-z0-9]+-)/, `$1${lan}-$2`)}`;
})
// 更新 importmap 的函数
function updateImportMap(newMapping) {
const importMap = document.querySelector('script[type="importmap"]');
if(importMap) {
const importMapJson = JSON.parse(importMap.textContent);
importMapJson.imports = { importMapJson.imports, ...newMapping };
importMap.textContent = JSON.stringify(importMapJson);
} else {
const importMapObj = {
imports: map,
};
const imp = document.createElement('script');
imp.type = 'importmap';
imp.textContent = JSON.stringify(importMapObj);
const head = document.head;
header.insertBefore(imp, head.firstChild)
}
}
// 使用时调用 updateImportMap 并传入新的映射
updateImportMap(map)
这里的 map 是关系,建立映射关系,将原本的语言包映射到单一语言包上。
比如原本引入的是 i18n-1abc4c9b-d12e7f4b.js
文件,通过映射之后,假设现在加载的是中文,那么就会变成 i18n-1abc4c9b-d12e7f4b.js
→ i18n-zh-1abc4c9b-d12e7f4b.js
,从而节省了一半的体积。
该方案的主要优化逻辑是在打包阶段实现的,通过颗粒化处理和打包优化,显著减少了语言包的体积。然而,这种方式对开发者来说并不友好,开发体验较差。
在后续的方案中,我们将重点优化开发体验,通过单语言拆分和路由拆分等方向来改进。