壓縮11000條 key 減少 7.2M,飛書如何實現 i18n 前端體積優化
動手點關注 乾貨不迷路 :point_up_2:
背景
在推進國際化的程序中,湧現出很多方案可以幫大家實現 國際化文案 定義以及使用。在飛書前端架構中,國際化文案已經做到了按需引入及按需載入,只不過隨著業務的發展,國際化文案數量逐漸增多。再來看程式碼中的文案部分,key 長度越來越長,這部分都屬於無用程式碼,如果能夠縮短,可以 節省部分程式碼體積,加快 js 在瀏覽器中執行的速度 。
如何做?
通過 壓縮 i18nkey
的方式, 將 i18n 的 key 從字母壓縮為短字串
。目前業界中為了提升 webpack 打包速度,發展出很多利用多程序進行 js 編譯的方案。飛書前端為了提高 webpack 編譯速度,大量使用了 thread-loader
進行併發編譯,i18n 掃描則採用了 babel 外掛進行掃描和統計,那如何在 babel 掃描的過程中將掃描結果收集起來,如何將執行時的 key 更換為更短的 key,並且能夠按照檔案歸類,實現按需載入呢?
思路
-
在 webpack 編譯之前,先拿到當前業務下載的文案列表,將列表中所有的 key 進行編碼,編碼後的長度應該越短越好;
-
在 babel loader 掃描的過程中,將用到的文案上報,並將引入文案時使用的 key,替換為短編碼;
-
在掃描完成後,生成文案的部分,使用編碼後的短字串,作為文案的 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。
- 智慧問答:基於 BERT 的語義模型
- 位元組雲資料庫未來方向的探索與實踐
- 電影兌換券的推薦策略——二分圖最優匹配演算法
- 深入淺出依賴注入及其在抖音直播中的應用
- 位元組跳動使用 Flink State 的經驗分享
- A/B 測試助力遊戲業務增長
- 西瓜影片 iOS 播放器技術重構
- 2022 春節抖音影片紅包系統設計與實現
- 通過 Goyacc 構建 Elasticsearch Querystring 解析器 - 領域特定語言語法分析實踐
- 以一次 Data Catalog 架構升級為例聊業務系統的效能優化
- 從響應式程式設計到 Combine 實踐
- 從單機到分散式資料庫儲存系統的演進
- 以一次 Data Catalog 架構升級為例聊業務系統的效能優化
- 智慧問答:基於 BERT 的語義模型
- 位元組跳動使用 Flink State 的經驗分享
- 基於 SPICE 協議的硬編推流整合方案在雲遊戲中的應用
- 抖音 Android 效能優化系列:抖音功耗優化實踐
- 抖音 Android 包體積優化探索:基於 ReDex 的 DEX 優化落地實踐
- 抖音功耗優化實踐
- 位元組跳動智創音影片團隊拿下 NTIRE2022 ESR 挑戰賽主賽道冠軍