本文直接从实战开始分享相关首屏优化的玩法,因此默认大家都有一定的 i18n 开发经验。因此本文不会展开 i18n 相关用法的介绍,有需要先了解 i18n 的同学可自行找相关资料查阅。
对于有出海业务的项目来说,项目开发中,多语言国际化是一个必不可少的环节。说到“多语言”、“国际化”,大家会毫不犹豫想到“i18n”及其一堆的工具链,那接下来,我们就一起探索在大型项目中 i18n 应该怎么玩,以及在重型语言包的情况下,如何做到更好的首屏优化。
1. 性能卡点
简要背景:大型的国际化项目中,一般会有管线化的翻译工具 + 专业的翻译同学来做语言翻译的工作,因此大部分情况下程序员同学是不用关注语言翻译和语言包生成的。也正因此,我们项目中 dev 环境和最终正常环境所接入的语言包并不是同一个(dev 环境便于我们开发调试,只有一个语种内容)。
erDiagram
"web应用" }|..|{ "本地语言包" : dev
"web应用" }|..|{ "线上语言包" : prod
大型的国际化项目中,往往存在以下一些问题对首屏性能是有负担的:
- 词条数目众多。词汇量、短语、短句等翻译场景偏多。
- 语种众多。可能有 10+ 种及以上的多语言场景。
- 语言包文件体积臃肿。一个语言包的体积可能随随便就有个 5-10m 了
其实总结起来,无非就一个字:“大”。词条数目+语种众多就会产生一个体积较大的多语言包文件,这无疑对我们的 web 首屏性能来说是一个灾难性的业务场景。因此,我们就需要有一定的优化手段,来将这个庞然大物进行分解,在保证我们的业务可正常开展的情况下,也能稳住前端的首屏性能指标。
2. 产物剥离
基于上述背景介绍中:“我们 dev 环境和正式环境用的不是同种语言包文件”的这么一个场景下,我觉得有必要提醒下本地语言包产物剥离这个点。
什么是产物剥离?其实就是把多语言本地文件的内容剥离出打包产物。基于我们线上读取另一份语言包的场景,dev 环境下的语言包肯定会和线上的有冗余。其实也就是当我们 build 项目的时候,本地的语言包是不需要 build 进去的。
为啥我会提这个点?首先我们参照下 i18n 的 demo 用法,如 i18next官网的基础用法:
import i18next from 'i18next';
i18next.init({
lng: 'en', // if you're using a language detector, do not define the lng option
debug: true,
resources: {
en: {
translation: {
"key": "hello world"
}
}
}
});
相信很多大型的项目都是从小开始做大的,因此当我们第一次引入 i18n 时可能会直接按照最官方的用法来用。那么这样写会有一个什么问题呢?
大家其实一眼就能看出,resources
中的数据是直接写的,最后丢到 init
函数中执行。毫无疑问,当我们的打包工具打包这段代码的时候,就会把当前这个语言包(en) 的数据也打包进去最终产物中。这就是问题所在。然而很多大型项目的初始形态就是从这里演变过来的,所以保不准项目发展壮大以后,大家忽视了这么一个问题,所以我觉得有必要提及下。
秉承着 web 首屏优化的核心理念:”按需加载“,这个我们”不需要“的内容其实是不必打包进产物里的,所以针对大型国际化项目首屏优化的第一点就是得把这个冗余的代码剥离开我们的打包产物中。
具体怎么剥离方案有很多,可以借助打包工具进行剥离;亦或者把整个本地语言包放到 public 目录下(如使用 vite),然后通过 http 的方式加载本地语言包进行开发调试。
上述放 public 的方案会导致语言包的修改后的 hmr 失效,具体原因我相信你也清楚。因为采用 http 请求加载的语言包文件不会被 vite 监控到其文件变化,所以 hmr 失效也是情理之中的事情。
简单总结一下:其实这个动作一优化下来,少则几十k,多则 1m+ 的优化效果立马就出来了~
3. 按语种拆分
按语种拆分无非就是另一种按需加载的理念,语言包文件通过语种拆分完后,web 应用初始化时仅仅 load 当前语言环境的语言包文件,以此达到瘦身的效果。相信这个思路相信很多同学都玩过,业内也有很多相关的技术文档介绍,所以这里就简单带过了。
文件最小化
都做到按语种拆分了,那其实我们只需要把所需要的字段保留,其余都摒弃即可。怎么理解,我举个案例:
HELLO: {
zh: '哈喽',
en: 'hello',
ja: 'xxx',
ko: 'xxx'
}
比如上述一个总语言包配置下,key 为 HELLO 的词条分别有 4 个语种的翻译内容。年轻的我第一次拆分的时候还是冗余了,比如:
HELLO: {
zh: '哈喽',
}
HELLO: {
en: 'hello',
}
...
大家可以看到,其实我们都按语种拆分后,每一个文件中的 "zh"、"en" 这种代表语种环境的字段是冗余的,因为这份文件他就是单一语种环境的,我们只需要在文件名中标识出来即可,而文件内根本不需要再出现有语种相关的数据。因此,更进一步压榨完语言包的体积后,他们应该只有 key vlaue 的值了:
HELLO: '哈喽'
按语种加载
在准备好语种拆分的文件后,我们仅需要将其进行一个的规则性命名并上传到服务器上即可。在 web 应用这一段呢,我们再准备一个 i18n 的插件:i18next-http-backend 即可。
这个插件就可以帮我们实现远程加载对应语种的语言文件了。因为业内也有很多相关的用法介绍了,详细了解的话大家自行搜索下吧,我这里就用个伪代码介绍下用法:
i18n
.use(HttpBackend)
.init({
backend: {
loadPath([lng], [ns]) {
return getSysTranslation(lng, ns);
},
}
})
简单来说就是注册这个插件之后,在 init 函数的配置中根据当前的语种、ns 来进行请求路径的拼接并返回,该插件就会在当前语种环境下去 load 对应地址的语言包文件回来了。
简单总结:按语种拆分会是效果最明显也是最常用的首屏优化方案,毕竟他可以简单地达到 1/n 的优化效果。也就是说,只要你的项目语种越多,他的优化就越明显!!就我个人经历过的项目而言,一个共有 7 个语种共 7m+ 大小的语言文件拆分成 7份语言包后,首屏搜索加载的文件瞬间达到了 1/7 的效果。
4. 按模块拆分
相较于上述多语言的首屏优化,这一个相对小众很多,也需要看业务场景,并不是所有地方都需要。那什么是按模块拆分呢?其实就是更细粒度的“按需加载”理念的落实而已。
前一小节按语种拆分,只是比较大粒度的从语种进行拆分来达到“按需加载”的效果。但是当我们的词条数异常多的时候,按语种拆分的颗粒度还是相对更大了点。那么此时我们就需要按模块进行拆分了。
如下图所示:
比如说我们的一些中台系统,每一个菜单下面就是一个模块,每个模块下都仅需要自己模块下的翻译内容。当我们打开 A 菜单的页面时,我们只需要当前语种的 A 模块的翻译内容,并不需要其他,因此更压榨性能的做法是:只加载当前语种的 A 模块翻译文件。
这么说可能大家不能 get 到他的优化效果,我可以举个更极端的例子。比如我们做一个撸啊撸的周边web应用,撸啊撸的英雄、技能的数据量够多了吧?但我们整个应用正常运行中都不需要用到其的翻译内容,只有当用户触发到一些查看英雄、技能等交互时,我们这才需要相应的翻译数据。那大家可以想下,如果把英雄的相关翻译数据跟应用的翻译数据剥离,我的首屏优化是不是可以得到一个较大的提升?
其实按模块拆分说起来简单,做起来也并不困难。我们需要一个可以识别出模块的前缀标识,再利用 i18next 提供的 missingKeyHandler
钩子即可实现。
比如我把 A、B、C 等模块的翻译 key 都加上一个区别的前缀:#a_hello、#b_go ...有了这些前缀,我们就可以很明确的知道我们需要什么“分类”的翻译内容了。当我们通过 t
函数传入 key 时,如果当前不存在的翻译映射结果就会触发 missingKeyHandler
的执行,此时我们再通过远程加载对应模块的翻译文件回来就可以实现按模块的拆分了。
当然,在处理 missingKeyHandler
的回调时我们要合理处理文件的加载逻辑。我大概讲下我遇到的坑点:
- 更新界面渲染。需要处理完
addResources
逻辑后手动调用i18n.changeLanguage(lng)
触发页面更新。(我用的 react+i18n,其他框架可能处理方式有所区别) - 并发触发,需避免重复请求。因为很可能出现并发的
missingKeyHandler
调用(渲染多个t
函数都没有的 key),因此我们需要控制对同一模块文件重复请求的问题 - 错误的翻译内容导致死循环。存在错误的 key 传入
t
函数,造成 load 完语言包触发changeLanguage
重渲染后又再次触发missingKeyHandler
,从而导致死循环。需要从代码逻辑上控制死循环的发生
接下来给大家贴上一段伪代码:
missingKeyHandler: async ([lng]: [LANGUAGES_TYPE], ns, key) => {
// 1. 控制语言包 http 请求状态避免重复请求
// 2. missKeysMap 判断避免错误 key 导致死循环
if (
langMap?.[lng] === 'loading' ||
langMap?.[lng] ||
missKeysMap[key]
)
return;
langMap?.[lng] = 'loading';
// 按需加载语言包
const runTimeResources = await axios(url)
// 没有 load 到资源或者使用了错误的 key 记录 missKeysMap,不然会 changeLanguage 后会无限循环这个过程
if (isNil(runTimeResources[key])) missKeysMap[key] = key;
// 标识当前模块的当前语种已加载完成
langMap?.[lng] = true;
i18n.addResources(lng, ns, runTimeResources);
await i18n.changeLanguage(lng);
},
总结
其实首屏优化就离不开:按需加载四个字,所以即使是在国际化场景需要针对语言包进行 web 的首屏优化依然适用。本文简单介绍了三种优化方式:
- 剥离打包产物
- 按语种拆分
- 按模块拆分
最终我在项目实战中取得的效果就是把首屏的 7.5m+ 的多语言优化到了 100+k(原文件体积非传输压缩体积)。最终的收益效果不仅仅是 web 应用的首屏加载变快了,也能反向的优化了不少的 CDN 带宽流量,也算是对 CDN 的流量成本进行了一定量的优化。
相信大家合理运用一定的优化手段,把一个大型国际化项目首屏进行压榨式优化一定能取得不错的效果。也许一个原 10+m 的首屏多语言包被优化到百来 k 也并不是什么难事!毕竟首屏能看到的东西是有限的,合理运用按需加载的理念进行优化,一定可以取得一个让人满意的效果~