壓縮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。