压缩11000条 key 减少 7.2M,飞书如何实现 i18n 前端体积优化

语言: CN / TW / HK

动手点关注 干货不迷路  :point_up_2:

背景

在推进国际化的进程中,涌现出很多方案可以帮大家实现 国际化文案 定义以及使用。在飞书前端架构中,国际化文案已经做到了按需引入及按需加载,只不过随着业务的发展,国际化文案数量逐渐增多。再来看代码中的文案部分,key 长度越来越长,这部分都属于无用代码,如果能够缩短,可以 节省部分代码体积,加快 js 在浏览器中运行的速度

如何做?

通过 压缩 i18nkey 的方式, 将 i18n 的 key 从字母压缩为短字符串 。目前业界中为了提升 webpack 打包速度,发展出很多利用多进程进行 js 编译的方案。飞书前端为了提高 webpack 编译速度,大量使用了 thread-loader 进行并发编译,i18n 扫描则采用了 babel 插件进行扫描和统计,那如何在 babel 扫描的过程中将扫描结果收集起来,如何将运行时的 key 更换为更短的 key,并且能够按照文件归类,实现按需加载呢?

思路

  1. 在 webpack 编译之前,先拿到当前业务下载的文案列表,将列表中所有的 key 进行编码,编码后的长度应该越短越好;

  2. 在 babel loader 扫描的过程中,将用到的文案上报,并将引入文案时使用的 key,替换为短编码;

  3. 在扫描完成后,生成文案的部分,使用编码后的短字符串,作为文案的 key,打包进文案文件中。

具体代码

编码方式

将下载的所有 i18n 的 key 进行一次编码映射,通过 key 在数组中的 index,做一个 26 进制转换,再把转换后的字符串中的数字填充为剩余的未用到的字母,保证 key 中无数字,可获得一个不超过 5 位的短 key。

  const NUMBER_MAP = {
0: 'q',
1: 'r',
2: 's',
3: 't',
4: 'u',
5: 'v',
6: 'w',
7: 'x',
8: 'y',
9: 'z',
};
const i18nKeys = Object.keys(resources['zh-CN']).reduce((all: object, key: string, index: number) => {
// 将i18n的key重新编码,编码成26进制,然后用字母替换掉所有数字。
// 因为变量名称不能用数字开头,所以需要替换掉所有数字
all[key] = index.toString(26).replace(/\d/g, (s) => NUMBER_MAP[s]);
return all;
}, {});

最初的设想中如果有从某个 enum 中引入 key 的行为,可以将 enum 的成员名字一起缩短,所以采用了替换所有数字的方式,保证短 key 不会以数字开头,后来在开发过程中发现没有这种用法,但是编码方式还是保留下来了。

扫描方式

借助 babel plugin 强大的 ast api,可以轻松完成 i18n key 的扫描和替换。

export default function babelI18nPlugin(options, args: {i18nKeys: {[key: string]: string}}) {
const i18nKeys = args.i18nKeys;

return {
visitor: {
StringLiteral: (tree, module) => {
const { node, parentPath: {
node: parent, scope, type
} } = tree;
const { filename } = module;
if (!shouldAnalyse(filename)) {
return;
}
const stringValue = node.value;
if (stringValue && i18nKeys.hasOwnProperty(stringValue)) {
if (
/**
* 飞书前端中使用了 __Text 和 _t 的全局方法来获得对应的文案内容,所以在这里限定了只有在全局方法
* __Text 和 _t 中传递的第一个参数为字符串时,才将字符串修改为短key
*/
type === 'CallExpression' &&
['__t', '__Text', '__T'].includes(parent.callee.name) &&
!scope.hasBinding(parent.callee.name)
) {
node.value = i18nKeys[stringValue];
/**
* 通过在source中写入一个特殊注释的方式将key标记在代码中,
* 交给下一步的webpack来收集
*/
tree.addComment('leading', `${COMMENT_PREFIX} ${i18nKeys[stringValue]}`);
} else {
/**
* 当匹配到的字符串并不是通过 _t 和 __Text 使用的场景,依然上报长key,保证代码稳定性
*/
tree.addComment('leading', `${COMMENT_PREFIX} ${stringValue}`);
}
}
},
MemberExpression: (tree, { filename }) => {
if (!shouldAnalyse(filename)) {
return;
}
const { node } = tree;
const memberName = node.property.name;
if (memberName && i18nKeys.hasOwnProperty(memberName)) {
tree.addComment('leading', `${COMMENT_PREFIX} ${memberName}`);
}
},
}
};
}

如果扫描到了 i18n 相关的字符串字段,将在原地添加一个注释,用来标记当前模块使用到的 key,这种方式可以让扫描结果落在代码中,使得扫描的操作可以被 cache-loader 缓存,进一步提升构建速度。

收集过程

通过 babel-loader 的模块都会被标记上使用到的 i18n 的 key 和替换后的短 key,在 webpack 的 parse 阶段只需要遍历文件的所有注释即可拿到模块内用到的所有 i18n 的 key。

export default class ChunkI18nPlugin implements Plugin {
static fileCache = new Map<string, Set<string>>();

constructor(private i18nConfig: I18nBundleConfig) {
}

public apply(compiler: Compiler) {
compiler.hooks.compilation.tap('ChunkI18nPlugin', (compilation, { normalModuleFactory }) => {

const handler = (parser) => {
// 在 parser 中 hook program 钩子
parser.hooks.program.tap('ChunkI18nPlugin', (ast, comments) => {
const file = parser.state.module.resource;

if (!ChunkI18nPlugin.fileCache.has(file)) {
ChunkI18nPlugin.fileCache.set(file, new Set<string>());
}
const keySet = ChunkI18nPlugin.fileCache.get(file);

// 拿到module的所有注释,扫描其中包含的i18n信息,缓存到一个map中
comments.forEach(({ value }: {value: string}) => {
const matcher = value.match(/\s*@i18n\s*(?<keys>.*)/);
if (matcher?.groups?.keys) {
const keys = matcher.groups?.keys?.split(' ');
(keys || []).forEach(keySet.add.bind(keySet));
}
});
});
};

// 监听 normalModuleFactory 的 parser 的 hooks
normalModuleFactory.hooks.parser
.for('javascript/auto')
.tap('DefinePlugin', handler);
normalModuleFactory.hooks.parser
.for('javascript/dynamic')
.tap('DefinePlugin', handler);
normalModuleFactory.hooks.parser
.for('javascript/esm')
.tap('DefinePlugin', handler);
});
}

...

}

有什么不足?

按照模块收集到的 key 是基于源文件扫描到的所有的 key。实际上我们可能存在一些较大的工具方法模块,或者组件模块,并不会用到全部的代码(部分代码会被 treeshaking 机制删掉),后续优化方向可以探索如何只扫描用到的代码中的 key,进一步压缩打包后的总体积。

最终收益

在一段时间的灰度测试后,最终方案上线运行,飞书前端大约 11000 条 key 的情况下,所有单页前端代码体积总计下降 7.2MB。