背景
国际化多语言(i18n)支持是一个古老的需求,当下企业出海潮的大背景下是一项重要的基础功能。本文将结合自己产品的特点,探讨传统国际化方案的优缺点,并给出适合自己的方案。
原理
国际化的基本原理是通过映射
,将同一个文案,映射成不同国家语言的过程,大部分是直接映射,当然少数情况会伴随着格式或顺序的变化
我们可以从最简单的解决方案讨论起,即我们设计一个 json 存储这个映射信息,然后在html里加载这个json,根据浏览器语言去动态映射
讨论
需要响应式吗?
响应式就是指,在页面上切换语言,不刷新页面就可以看到效果
大部分spa
应用(react/vue
等等)如果要做到响应式
,在组件内部的文本,比较好实现,因为我们可以控制组件/应用的更新,但是还有少部分不在组件内的文本,例如一些枚举值, 一些函数内的文本,则比较麻烦,需要把这些文本改成可重复执行的响应式的
import { useTranslation } from 'react-i18next'
function TestComponent() {
// 利用现成的库可以比较容易的实现响应式
const { t } = useTranslation(['home'])
return (
<h2>{t('home')}</h2>
)
}
// 某些函数内部的文本,需要特殊改造
function transform() {
// something
return 'error msg'
}
// 这种静态的映射也需要特殊改造
const customEnum = {
code1: '编码1',
code2: '编码2'
}
问题是,我们真的需要响应式吗?
切换语言这个行为本身还是比较低频,一般应用初始化的时候,根据浏览器设置或者应用设置,直接渲染对应语言即可,即使切换了语言,页面reload一下也无大碍,平衡工作量和体验来看,不做响应式也许对我们是更好的选择
谁来翻译?
大型的应用或者企业,都有专门的翻译团队来做文本的翻译。不过我们是初创公司,而且需要支持的语言非常有限,考虑到成本,还是选择让开发同学自己来做翻译,而且现在AI
大行其道,针对一些场景化的文案也能翻译的很好。
谁来翻译文本其实决定了翻译的架构。
试想,如果是专业的翻译团队,肯定搭建一个中心化的翻译平台会更好,将各个业务,移动端,h5,pc都统一到一个平台来翻译会更舒服。
那么如果是开发同学自己来翻译呢?肯定更希望直接在代码仓库中填写翻译文件。
普通的json文件用作翻译存在几个体验问题:
- 没有自动提示
- 不能从组件内直接ctrl跳转到翻译
- 不能从翻译ctrl看到有哪些地方引用了该翻译
当然可以通用插件提升翻译体验,i18n-ally是我看到的非常好的vscode插件。
前端存储or服务端存储映射?
将 “语言包” 放在服务端有几个好处
- 修改方便,数据库里面修改完直接可以生效
坏处是
- 文件名带hash值的语言包是直接可以强缓存的,或者当文件名固定的时候使用协商缓存,而在服务端(数据库里面)存储,缓存做起来不太容易
- 一般静态资源(json映射文件)放到nginx上是有gzip压缩的,服务端返回翻译信息则不太好压缩
语言包拆分/按需加载
一个语言包可能需要几百k甚至上M,一次性加载可能会影响首屏性能。如果要拆分,可能有几种办法
- 手动拆分,约定好每个页面创建一个语言包文件,然后通过路由与语言包组件的绑定,动态加载
- 通过打包工具的某些插件,分析每个页面里用到哪些语言包的哪些块,将其打成不同的子语言包,然后动态加载
打包时需要把语言包独立出来吗?
两种方案
- 将语言包打成单独的文件,而且如果做了拆分/按需加载,那么每种语言可能会出现多个(子)语言包
- 直接将不同的语言打进js文件中,即有多少语言,就有多少js的不同版本
我觉得两种差别不大吧,我还是倾向于将语言包打成单独的文件,多了一些请求,但是看起来清楚一些
持续维护问题
语言包中的内容可以预见的会逐步增长,持续维护面临两个问题:
- 修改,例如产品经理指出某个组件内的文本有误,开发同学顺利找到组件,并找到了这个文本在语言包中的key,修改的时候一定要小心,因为这个key可能在其他地方也用到了,需要全局搜索一下
- 移除,组件可能会删除或者废弃,但遗留在语言包中的翻译一般不会跟着删除,除非我们定期清理,每个key都全局搜索一下,有没有引用,当然我们也可能上一些技术手段,例如treeshakeing
国际化方案
权衡之下,我们
- 放弃了
响应式
- 选择了将 “语言包” 放到前端
- 希望实现语言包自动
拆分
/按需加载
- 通过语言包文件带hash值实现语言包http
强缓存
- 语言包自动
treeshaking
,即使写到语言包里,如果没有地方引用,也不会打包进最终的产物中 - 希望有良好的开发体验,按着ctrl可以来回跳转
具体实现
先来看一下用法:
import { t } from '../../utils/getI18nText' // t 是工具函数,自动根据当前语言替换
import {
text1,
text2
} from './i18n'
// i18n.ts 文件是约定的语言包文件,只要是以 i18n结尾的ts文件都会被视作语言包
// 场景1:固定值,直接使用,无需往里传
export const dictMap = {
'1': t(text1),
'2': t(text2)
}
export const text = t(text1)
// 场景2:函数内,直接使用,无需hook
export const pureFunction = () => {
const flag = true
if(flag) {
return t(text1)
} else {
return t(text2)
}
}
// 场景3:组件内,直接使用,无需hook
export default function Page1 () {
return <div>page1 {t(text1)}</div>
}
/ i18n.ts 文件内容,可以看出就是普通的对象导出
export const text2 = {
zh: '测试文本2',
en: 'testText2'
}
export const text3 = {
zh: '测试文本3',
en: 'testText3'
}
export const text1 = {
zh: '测试文本1',
en: 'testText1'
}
这里 t 的内容:
```javascript
const userLanguage = navigator.language || navigator.userLanguage;
const supportLan = ['zh', 'en']
// 初始化的时候确定当前浏览器语言,反正切换语言的时候会重新reload一下,不怕
let lan = supportLan.find(e => userLanguage.startsWith(e)) ?? 'zh'
// 运行时直接简单映射一下就行
export const t = (x: { [key: string]: string }) => {
return x[lan]
}
可以想象,如果不加处理,那么语言都将打进产物包中,无法根据当前浏览器语言去加载不同的语言包
我们使用的是vite
,项目是react
,因此我们通过vite插件实现了这个功能
相关vite插件实现
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { nanoid } from 'nanoid'
import crypto from 'crypto'
import { parse } from '@babel/parser';
import generate from '@babel/generator';
import { traverse } from '@babel/core';
function I18nPlugin() {
return {
name: 'i18nPlugin',
enforce: 'pre',
generateBundle(outputOptions, bundle) {
const lans = ['zh', 'en']
// 遍历生成的所有 chunks
for (const [fileName, chunkInfo] of Object.entries(bundle)) {
if (chunkInfo.name.startsWith('i18n-')) {
// 配合 manualChunks 中的设置,manualChunks 中将语言包的chunk命名为 i18n-xxx
// 对 AST 进行操作
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: nanoid(5),
source: code
});
})
}
}
},
transformIndexHtml(html, option) {
const {
bundle,
} = option
const fileList = []
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']
let lan = supportLan.find(e => userLanguage.startsWith(e)) ?? 'zh'
const map = {}
fileList.forEach(fileName => {
map[\`/\${fileName}\`] = \`/\${fileName.replace(/(.*i18n-)([a-z0-9]+-)/, \`$1\${lan}-$2\`)}\`;
})
const importMap = {
imports: map,
};
const imp = document.createElement('script');
imp.type = 'importmap';
imp.textContent = JSON.stringify(importMap);
document.currentScript.after(imp);
</script>`)
return newHtml
},
}
}
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), I18nPlugin()],
build: {
modulePreload: false,
rollupOptions: {
// external: (id, importer: string | undefined, isResolved: boolean) => {
// const match = /.*i18n\.ts*/.test(id)
// return !!match
// },
output: {
manualChunks: (id: string, { getModuleInfo }: any) => {
const match = /.*i18n\.ts*/.test(id)
if (match) {
const dependentEntryPoints = []
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);
return `i18n-${key}`
}
}
}
}
},
})
标签:翻译,缓存,const,语言包,i18n,return,zh,加载
From: https://blog.csdn.net/qq_34435913/article/details/144063183