項目復盤:通過動態腳本,實現按需加載語言包
大家好,我是前端西瓜哥,是一名前端開發。
最近做了一個將按需加載語言包的需求,有不少收獲,這里記錄一下。
改造前的項目
原來項目是將所有的語言包合并在一起,放到一個 JSON 文件里然后被引入。
打包后的腳本里,有完整的語言包的代碼,導致打包文件非常大。理論上用戶只會使用一種語言,其他的語言沒有加載的必要。
目前來說項目只支持兩種語言,每個語言有文案 4000 多條。如果還是使用全量加載的話,以后支持的語言每多一個,打包后的文件就要膨脹一圈。
做語言包的拆分還是很有必要的。它可以減少加載資源的大小,減少首次頁面加載時間,提高用戶體驗。
實現方案的選擇
實現按需加載語言包的方式很多,我了解到的有三種:
- 后端渲染:在請求時將單個語言包嵌入到 HTML 里。
- 動態 import:使用 ES6的 動態 import 語法。
- 動態腳本:在腳本里創建一個 script,添加到 DOM 樹上。
后端渲染的方案,其實是最快捷的
// 下面這一個 script 是后端渲染的
<script>window.i18n = { 'apple': '蘋果' /* ... */ }</script>
<script src="app.js"></script>
請求 HTML 時,后端做渲染工作,給 HTML 加上語言包的內容。
前端沒有什么改造的工作量,但問題是不能利用緩存。但這個問題其實也可以解決,就是后端生成好語言包 js 文件,將嵌入語言包內容的方式改為 cdn 引入的方式,可以利用好緩存。
但這讓模板引擎的邏輯變得很重,cdn 上傳到哪里,如何維護也是個問題。
動態 import 方案
import('lang/zh-CN.js').then(() => {
ReactDOM.render();
});
使用 React 等框架打包出來單頁面應用的文件通常很大,下載需要不少時間。
動態 import 必須在腳本整個下載完后,再執行,所以這是一個串行下載的邏輯。
如果可以的話,我們希望語言包可以和業務代碼同時下載。此外,更重要的一點是,在動態 import 前,我們不能調用獲取文案的方法 getText。
我在改造項目代碼時,發現在我動態 import 語言包并 ReactDOM.render() 之前,有些模塊文件調用了getText 方法,因為它們作為枚舉指直接暴露出來,沒有用函數封裝,被 import 時就直接執行了。
語言包都沒加載,你執行 getText 是拿不到文案的,這個方案我果斷放棄。
動態腳本方案
<script>
(function(){
// 語言包 js 文件內容為:window.i18n = { key1: value1 };
const i18nLangCDNs = {
"zh-CN": "/lang/zh-CN.1268ec6019c7a7bb7b27d1ecdadc3948.js",
"en-US": "/lang/en-US.e6c246ecf2b64be936a116706cdd6611.js",
};
let lang = getLang();
const script = document.createElement('script');
script.async = false;
script.src = i18nLangCDNs[lang];
document.querySelector('head').appendChild(script);
});
</script>
這種方案利用了腳本里創建腳本的方式。能在更前面的位置加載語言包腳本。
優點是我們可以不需要做后端渲染的工作,讓選擇語言包的邏輯交給前端。但涉及到前端工程化,需要寫插件改變原來的加載腳本形式。
我們的項目使用了 webpack,如果用這個方案,就需要寫一個 webpack 插件去改造 HtmlWebpackPlugin 的構建流程。
目前來說,方案 1 和 方案 3 都是不錯的。
但考慮到我們公司的前后端是分離的,后端的代碼實現對我來說其實是黑盒,我沒有權限也沒有能力去寫后端代碼。而項目是前端項目,最好還是讓前端來掌控維護。所以我最終選擇了方案 3。
方案1 和方案 2 的更具體介紹,可以看我的這篇文章:前端國際化,該如何實現按需加載語言包?
改造過程
原來項目打包后的 html 文件大致如下。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<!-- 業務代碼,語言包也在里面 -->
<script src="app.js"></script>
</body>
</html>
app.js 里有全量語言包的內容。
改造后的 html 文件如下:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script>
(function(){
// 語言包 js 文件內容為:window.i18n = { key1: value1 };
const i18nLangCDNs = {
"zh-CN": "/lang/zh-CN.1268ec6019c7a7bb7b27d1ecdadc3948.js",
"en-US": "/lang/en-US.e6c246ecf2b64be936a116706cdd6611.js",
};
let lang = getLang();
const script = document.createElement('script');
script.async = false;
script.src = i18nLangCDNs[lang];
document.querySelector('head').appendChild(script);
});
</script>
</head>
<body>
<!-- 為保持執行順序,業務代碼也需要改為動態加載形式 -->
<script>
<!-- app.js 文件已移除語言包 -->
['app.js'].forEach(function(src) {
const script = document.createElement('script');
script.async = false;
script.src = src;
document.body.appendChild(script);
});
</script>
</body>
我們語言包將 app.js 從中提取出來,并且分為多個語言包放到 js 文件,如 zh-CN.js、en-US.js,在 app.js 之前執行。
let lang = getLang();
const script = document.createElement('script');
script.async = false;
script.src = i18nLangCDNs[lang];
document.querySelector('head').appendChild(script);
我們先確認用戶使用的語言是什么。
如果我們不支持持久化設置,可以通過 navigator.language 或前端的其他地方獲取。
但通常用戶可以設置語言,這個語言標識就要后端給,再請求一次用戶信息可太離譜了,所以這里還是需要后端給我們往 html 里嵌入用戶選擇的語言。然后我們從語言 cdn 列表里選我們需要的語言。
script 元素默認會將 async 設置為 true,效果是腳本下載完立即執行。需要將其改為 false,保證多個動態腳本順序執行。
文件名使用了哈希,是為了解決瀏覽器緩存問題。
執行后,就會將語言包文案暴露在全局變量中。
業務代碼 app.js 也得改成動態加載形式,如果原來的非動態寫法,執行時機就會早于語言包腳本。
這里涉及到了 script 的執行時機,具體規則可以看我的這篇文章:script 的三種加載模式:默認加載、defer、async。
原來的寫法
<script src="app.js"></script>
改造后
<script>
<!-- app.js 文件已移除語言包 -->
['app.js'].forEach(function(src) {
const script = document.createElement('script');
script.async = false;
script.src = src;
document.body.appendChild(script);
});
</script>
這樣我們就能保證先執行語言包腳本,再執行 app.js。
app.js 中的業務代碼執行時,使用 getText 方法就能正常通過 key 獲取到對應的文案。
這里 app.js 改為動態的寫法后,需要腳本解析執行后才下載腳本,可以考慮加個 link preload 提前下載腳本。
link 的 preload 作用可以看我的這篇文章。
期間遇到的問題
思路并不復雜,但改造過程中做了很多工作,遇到了不少問題。這里簡單列舉一下,不展開講了,到時候會考慮另寫文章討論。
- 我們項目的語言包是維護在在線表格上的,每次會通過腳本拉取數據,然后處理成 JSON 文件。我需要再寫一個腳本來處理這個 JSON 文件,將其分成多個語言包,并生成功 TS 類型文件。
- 使用了 monorepo,我專門分了一個 i18n 的包。
- 最難的是開發一個 Webpack 插件,需要做到拷貝特定文件夾下的語言包,加上內容哈希,放到構建目錄下。這些帶有哈希的名字要保存下來,通過 HtmlWebpack的鉤子轉換為內嵌 script 形式添加到 html 上。此外,還要將原來的打包文件 app.js 轉換為動態加載的形式。
- 開發環境還是要全量加載語言包,方便測試。一個原因是 devServer 無法讀取到使用 copy 的文件,需要額外用 write-file-webpack-plugin,但項目用的 create-react-app 不太好改造。
- 改造 getText 獲取文案的方法,需要考慮開發和生產環境的不同。
- 我們還有個中間層的 nextjs 項目,我們的語言包要兼容該項目,所以里面還寫了判斷環境的邏輯,在 global 或 window 上掛載全局變量。
- 測試用例和 CI 補上一行引入語言包的邏輯。