持續進步的同窗都關注了「1024譯站」vue
這是1024譯站的第 100 篇文章 node
Web 項目多語言(i18n,即國際化)是比較常見的需求,常規的作法大概有如下幾種:web
每種語言單獨開發頁面,適用於 CMS 之類的網站express
多語言文本和頁面結構分離,運行時動態替換。適用於單頁應用(SPA)瀏覽器
直接用網頁翻譯插件,機器翻譯。這種效果不太理想,同時有一些侷限性(後面會講到)緩存
問題
每一種方案都有各自的優勢和侷限性,具體項目應該根據實際狀況選擇。最近在工做中碰到的需求是要在現有的項目基礎上快速推出多語言版本。微信
項目是基於 Vue.js 開發的,已經迭代過不少版本了。其實一開始是有規劃多語言的,也引進了 vue-i18n
插件。這個插件就是上面第二種方案,用 JSON 文件管理多語言的文本資源,在 Vue 組件模板裏經過鍵名引用文本。app
可是要管理這些英文鍵名比較麻煩,命名就很頭疼。並且閱讀代碼的時候也很難從鍵名快速識別出對應的中文。後面發現 VS Code 有相關的插件,能夠顯示出對應的中文,可是代碼找起來仍是有點麻煩。再加上產品的多語言版本一直沒有提上日程,時間久了就嫌麻煩,慢慢地就直接在模板裏寫中文了。函數
結果,該來的仍是來了。產品忽然說最近要快速推出英文版,後續還有其餘語言。一開始的想法是直接用 Chrome 瀏覽器自帶的 Google 翻譯功能,怎麼快怎麼來。但通過一番測試,發現了很多問題。工具
首先機翻的效果確定是要打折扣的,但這還在接受範圍內。最關鍵的是會影響到功能使用。什麼問題呢?因爲項目是用 Vue.js 開發的單頁應用,頁面內容徹底是用 JS 動態渲染的。有些對話框內的文字 Google 翻譯就忽略了。
另外,Google 翻譯只處理了 DOM 文本節點,input
輸入框內的文字(包括placeholder
)被忽略了。最嚴重的問題是,通過 Google 翻譯處理後的 DOM 元素,居然失去了 Vue 響應式特性,數據變化後 DOM 內的文字不會更新了!
若是要繼續採用瀏覽器 Google 翻譯的方案,就要解決這幾個問題。經過調試發現 Google 翻譯用的 JS 腳本是嵌入到瀏覽器 VM 裏的,經過 HTTP 調用翻譯服務,而後修改 DOM 元素。JS 腳本是壓縮混淆過的,格式化後也很難看。想要找到更新 DOM 的代碼,而後用本身的邏輯去覆蓋?眼睛都看瞎了,仍是算了。

鑑於以上緣由,瀏覽器自帶的 Google 翻譯方案基本不考慮了。
如今只剩下第二種方案了,語言配置文件和頁面結構分離。前面提過,vue-i18n
用得不完全,若是把全部組件從新規範化,工做量太大了。有沒有辦法不修改現有代碼,也能實現文本翻譯呢?很天然地就想到了 Google 翻譯的思路,直接對頁面渲染結果進行翻譯。本身翻譯的優點就是,能夠精細地控制 DOM 操做,好比能夠把輸入框裏的文本和placeholder
也翻譯出來。
同時,通過研究發現,Vue 組件經過數據綁定渲染出來的 DOM 元素,包含的文本內容不能直接經過 innerHTML
或者innerText
修改,這樣會致使響應式失效。解決辦法是操做它的子元素,也就是文本節點(nodeType
爲3的節點),修改它的 textContent
屬性。
多語言配置映射表
跟 Google 翻譯不一樣之處在於,咱們採用靜態翻譯,也就是經過多語言配置文件映射。 vue-i18n
是每種語言準備一個 JSON 文件,屬性名用英文,用命名空間(多層級對象)的方式避免命名衝突。我直接簡化了,用一個 JS 對象存儲全部語言版本,鍵名就是頁面用到的中文。隨着日積月累的開發迭代,這些中文散落在幾百個文件裏……個人作法是用 VS Code 全局正則搜索,把查找結果複製出來,寫一個 JS 方法把這些字符串處理成 JS 對象。

匹配中文的正則(不夠全面,有些還夾雜了其餘符號):
[A-Z]*[\u4e00-\u9fa5][,,!! 0-9a-zA-Z\u4e00-\u9fa5]*
將結果複製到翻譯工具翻譯,再寫一個函數把這些文本合併成對象,並保存到labels.js
文件中備用。
var kv = dist.reduce((acc,cur, index) => {
acc[cur]=en[index] || cur;return acc;
},{})
對象的結構大體以下:
// labels.js
export default {
客戶性名: {
en: 'Customer Name',
},
// 動態文本,後面會講到
'剩餘{0}臺礦機未登記': {
en: '{0} unregistered',
},
xxxx: {
en: 'XXX',
}
}
操做 DOM
跟 Google 翻譯相似,咱們也採起過後更新 DOM 的方式來進行翻譯。因爲是單頁應用,隨着用戶的操做,會不停地更新 DOM。一開始的想法是監聽整個 body
的變化,在回調裏再更新 DOM。監聽 DOM 變化有一個原生的 API 可用,就是 MutationObserver
。
mounted() {
this.observeDOM(document.body);
},
methods: {
observeDOM(el) {
let mutationTimer;
const vm = this;
const observer = new MutationObserver(() => {
// 相似於 debounce 的效果,屢次調用合併爲一次
clearTimeout(mutationTimer);
mutationTimer = setTimeout(() => {
if (!vm.mutationFromTrans) {
translate();
vm.mutationFromTrans = true;
setTimeout(() => {
vm.mutationFromTrans = false;
}, 300);
}
}, 100);
});
const options = {
childList: true, // 監視node直接子節點的變更
subtree: true, // 監視node全部後代的變更
attributes: true, // 監視node屬性的變更
characterData: true, // 監視指定目標節點或子節點樹中節點所包含的字符數據的變化。
};
if (this.language === 'en') {
observer.observe(el, options);
}
},
},
可是試過以後發現這會致使無線循環,由於沒有判斷 DOM 的變化來自用戶操做仍是翻譯自己。因此代碼裏後面加了判斷,可是結果依然不理想。這種操做代價太大了,頁面性能受了很大影響。並且還有個很明顯的問題,就是進入到新的界面會閃一下,從中文變成英文。這個體驗太糟糕了。後面有改進辦法。
翻譯
先來來看下翻譯的過程。翻譯就是從多語言配置對象裏查找匹配的屬性名,獲取對應語言的屬性值。這對於靜態文原本說比較簡單,直接用屬性名就行了。可是對於動態的文本怎麼處理呢?因爲中英文表達方式不同,這種文本不能簡單地拆分紅多個部分單獨處理,而是要在英文的表達方式裏替換動態數據。
個人作法是使用帶格式的鍵名,好比{0}
這樣的佔位符。在查找的時候,優先匹配固定文本。由於大部分狀況是固定文本,並且這種匹配是O(1)時間複雜度的,優先判斷會提升性能。匹配失敗的時候纔去提早構造好的正則列表裏遍歷匹配,成功則提取正則匹配的group
用於替換動態數據。若是失敗,說明沒有對應的翻譯,直接返回原始字符串就好了。
const keys = Object.keys(words);
// 提早緩存正則,避免重複執行消耗性能
const regExps = keys.reduce((acc, key) => {
// 模板型鍵名
if (key.indexOf('{0}') > -1) {
const reg = new RegExp(key.replace('{0}', '(.+)'));
acc.push({
expression: reg,
key,
});
}
return acc;
}, []);
export function translate(el = document.body, lang = 'en') {
const kv = words;
if (!el.querySelectorAll) {
return;
}
const _trans = label => {
const text = label?.trim?.();
if (!text) {
return label;
}
if (kv[text]?.[lang]) {
return kv[text]?.[lang];
}
for (let index = 0; index < regExps.length; index++) {
const regItem = regExps[index];
const m = text.match(regItem.expression);
if (m) {
return kv[regItem.key][lang].replace('{0}', m[1]);
}
}
return text;
};
[...el.querySelectorAll('*')].forEach(node => {
// 不能直接修改node.innerText,會致使Vue響應式失效
// node.innerText = kv[node.innerText?.trim?.()] || node.innerText;
if (node.nodeName === 'INPUT' && node.type === 'text') {
node.value = _trans(node.value);
node.placeholder = _trans(node.placeholder);
}
const textNodes = [...node.childNodes].filter(n => n.nodeType === 3);
textNodes.forEach(textNode => {
textNode.textContent = _trans(textNode.textContent);
});
});
}
改進後的 DOM 操做
前面提過,若是在 DOM 渲染後再執行翻譯,頁面性能很是差。因而想到了 Vue 自己的渲染過程,能不能攔截 Vue 組件渲染過程,插入一些額外的邏輯呢?經過扒源碼發現,Vue 原型上有個__patch__
方法,每次更新 DOM 的時候都會執行。就從這裏入手, 重寫這個方法,對還沒掛載到文檔樹的 DOM 元素執行翻譯操做。
const __patch__ = Vue.prototype.__patch__;
Vue.prototype.__patch__ = function() {
const elm = __patch__.apply(this, arguments);
if (this.$store?.getters?.language) {
translate(elm, this.$store?.getters?.language);
}
return elm;
};
至此,基本完成了多語言翻譯。通過權衡對比,這個方案算是比較省時省力又能完成需求的了。固然,這種方案或多或少對頁面性能有必定影響,畢竟增長了 DOM 更新的時間。尤爲是動態文本較多的狀況,涉及到遍歷正則匹配,比較耗時。若是你們有更好的方案,歡迎留言!
相似的思路,以前也用到過。參考這篇(幾乎)完美實現 el-table 列寬自適應,也是一種歪門邪道,當心走火入魔哦。
順手點「在看」,天天早下班;轉發加關注,共奔小康路~
本文分享自微信公衆號 - 1024譯站(trans1024)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。