現(xiàn)有 Vue.js 項目快速實現(xiàn)多語言切換的一種思路
Web 項目多語言(i18n,即國際化)是比較常見的需求,常規(guī)的做法大概有以下幾種:
- 每種語言單獨開發(fā)頁面,適用于 CMS 之類的網(wǎng)站
- 多語言文本和頁面結(jié)構(gòu)分離,運行時動態(tài)替換。適用于單頁應(yīng)用(SPA)
- 直接用網(wǎng)頁翻譯插件,機器翻譯。這種效果不太理想,同時有一些局限性(后面會講到)
問題
每一種方案都有各自的優(yōu)點和局限性,具體項目應(yīng)該根據(jù)實際情況選擇。最近在工作中碰到的需求是要在現(xiàn)有的項目基礎(chǔ)上快速推出多語言版本。
項目是基于 Vue.js 開發(fā)的,已經(jīng)迭代過很多版本了。其實一開始是有規(guī)劃多語言的,也引進了 vue-i18n 插件。這個插件就是上面第二種方案,用 JSON 文件管理多語言的文本資源,在 Vue 組件模板里通過鍵名引用文本。
但是要管理這些英文鍵名比較麻煩,命名就很頭疼。而且閱讀代碼的時候也很難從鍵名快速識別出對應(yīng)的中文。后面發(fā)現(xiàn) VS Code 有相關(guān)的插件,可以顯示出對應(yīng)的中文,但是代碼找起來還是有點麻煩。再加上產(chǎn)品的多語言版本一直沒有提上日程,時間久了就嫌麻煩,慢慢地就直接在模板里寫中文了。
結(jié)果,該來的還是來了。產(chǎn)品突然說最近要快速推出英文版,后續(xù)還有其他語言。一開始的想法是直接用 Chrome 瀏覽器自帶的 Google 翻譯功能,怎么快怎么來。但經(jīng)過一番測試,發(fā)現(xiàn)了不少問題。
首先機翻的效果肯定是要打折扣的,但這還在接受范圍內(nèi)。最關(guān)鍵的是會影響到功能使用。什么問題呢?由于項目是用 Vue.js 開發(fā)的單頁應(yīng)用,頁面內(nèi)容完全是用 JS 動態(tài)渲染的。有些對話框內(nèi)的文字 Google 翻譯就忽略了。
另外,Google 翻譯只處理了 DOM 文本節(jié)點,input輸入框內(nèi)的文字(包括placeholder)被忽略了。最嚴(yán)重的問題是,經(jīng)過 Google 翻譯處理后的 DOM 元素,竟然失去了 Vue 響應(yīng)式特性,數(shù)據(jù)變化后 DOM 內(nèi)的文字不會更新了!
如果要繼續(xù)采用瀏覽器 Google 翻譯的方案,就要解決這幾個問題。通過調(diào)試發(fā)現(xiàn) Google 翻譯用的 JS 腳本是嵌入到瀏覽器 VM 里的,通過 HTTP 調(diào)用翻譯服務(wù),然后修改 DOM 元素。JS 腳本是壓縮混淆過的,格式化后也很難看。想要找到更新 DOM 的代碼,然后用自己的邏輯去覆蓋?眼睛都看瞎了,還是算了。
Google 翻譯JS代碼
鑒于以上原因,瀏覽器自帶的 Google 翻譯方案基本不考慮了。
現(xiàn)在只剩下第二種方案了,語言配置文件和頁面結(jié)構(gòu)分離。前面提過,vue-i18n用得不徹底,如果把所有組件重新規(guī)范化,工作量太大了。有沒有辦法不修改現(xiàn)有代碼,也能實現(xiàn)文本翻譯呢?很自然地就想到了 Google 翻譯的思路,直接對頁面渲染結(jié)果進行翻譯。自己翻譯的優(yōu)勢就是,可以精細(xì)地控制 DOM 操作,比如可以把輸入框里的文本和placeholder也翻譯出來。
同時,經(jīng)過研究發(fā)現(xiàn),Vue 組件通過數(shù)據(jù)綁定渲染出來的 DOM 元素,包含的文本內(nèi)容不能直接通過 innerHTML或者innerText修改,這樣會導(dǎo)致響應(yīng)式失效。解決辦法是操作它的子元素,也就是文本節(jié)點(nodeType為3的節(jié)點),修改它的 textContent屬性。
多語言配置映射表
跟 Google 翻譯不同之處在于,我們采用靜態(tài)翻譯,也就是通過多語言配置文件映射。 vue-i18n 是每種語言準(zhǔn)備一個 JSON 文件,屬性名用英文,用命名空間(多層級對象)的方式避免命名沖突。我直接簡化了,用一個 JS 對象存儲所有語言版本,鍵名就是頁面用到的中文。隨著日積月累的開發(fā)迭代,這些中文散落在幾百個文件里……我的做法是用 VS Code 全局正則搜索,把查找結(jié)果復(fù)制出來,寫一個 JS 方法把這些字符串處理成 JS 對象。
搜索中文
匹配中文的正則(不夠全面,有些還夾雜了其他符號):
- [A-Z]*[\u4e00-\u9fa5][,,!! 0-9a-zA-Z\u4e00-\u9fa5]*
將結(jié)果復(fù)制到翻譯工具翻譯,再寫一個函數(shù)把這些文本合并成對象,并保存到labels.js文件中備用。
- var kv = dist.reduce((acc,cur, index) => {
- acc[cur]=en[index] || cur;return acc;
- },{})
對象的結(jié)構(gòu)大致如下:
- // labels.js
- export default {
- 客戶性名: {
- en: 'Customer Name',
- },
- // 動態(tài)文本,后面會講到
- '剩余{0}臺礦機未登記': {
- en: '{0} unregistered',
- },
- xxxx: {
- en: 'XXX',
- }
- }
操作 DOM
跟 Google 翻譯類似,我們也采取事后更新 DOM 的方式來進行翻譯。由于是單頁應(yīng)用,隨著用戶的操作,會不停地更新 DOM。一開始的想法是監(jiān)聽整個 body的變化,在回調(diào)里再更新 DOM。監(jiān)聽 DOM 變化有一個原生的 API 可用,就是 MutationObserver。
- mounted() {
- this.observeDOM(document.body);
- },
- methods: {
- observeDOM(el) {
- let mutationTimer;
- const vm = this;
- const observer = new MutationObserver(() => {
- // 類似于 debounce 的效果,多次調(diào)用合并為一次
- clearTimeout(mutationTimer);
- mutationTimer = setTimeout(() => {
- if (!vm.mutationFromTrans) {
- translate();
- vm.mutationFromTrans = true;
- setTimeout(() => {
- vm.mutationFromTrans = false;
- }, 300);
- }
- }, 100);
- });
- const options = {
- childList: true, // 監(jiān)視node直接子節(jié)點的變動
- subtree: true, // 監(jiān)視node所有后代的變動
- attributes: true, // 監(jiān)視node屬性的變動
- characterData: true, // 監(jiān)視指定目標(biāo)節(jié)點或子節(jié)點樹中節(jié)點所包含的字符數(shù)據(jù)的變化。
- };
- if (this.language === 'en') {
- observer.observe(el, options);
- }
- },
- },
但是試過之后發(fā)現(xiàn)這會導(dǎo)致無線循環(huán),因為沒有判斷 DOM 的變化來自用戶操作還是翻譯本身。所以代碼里后面加了判斷,但是結(jié)果依然不理想。這種操作代價太大了,頁面性能受了很大影響。而且還有個很明顯的問題,就是進入到新的界面會閃一下,從中文變成英文。這個體驗太糟糕了。后面有改進辦法。
翻譯
先來來看下翻譯的過程。翻譯就是從多語言配置對象里查找匹配的屬性名,獲取對應(yīng)語言的屬性值。這對于靜態(tài)文本來說比較簡單,直接用屬性名就好了。但是對于動態(tài)的文本怎么處理呢?由于中英文表達方式不一樣,這種文本不能簡單地拆分成多個部分單獨處理,而是要在英文的表達方式里替換動態(tài)數(shù)據(jù)。
我的做法是使用帶格式的鍵名,比如{0}這樣的占位符。在查找的時候,優(yōu)先匹配固定文本。因為大部分情況是固定文本,而且這種匹配是O(1)時間復(fù)雜度的,優(yōu)先判斷會提高性能。匹配失敗的時候才去提前構(gòu)造好的正則列表里遍歷匹配,成功則提取正則匹配的group用于替換動態(tài)數(shù)據(jù)。如果失敗,說明沒有對應(yīng)的翻譯,直接返回原始字符串就行了。
- const keys = Object.keys(words);
- // 提前緩存正則,避免重復(fù)執(zhí)行消耗性能
- 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,會導(dǎo)致Vue響應(yīng)式失效
- // 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 渲染后再執(zhí)行翻譯,頁面性能非常差。于是想到了 Vue 本身的渲染過程,能不能攔截 Vue 組件渲染過程,插入一些額外的邏輯呢?通過扒源碼發(fā)現(xiàn),Vue 原型上有個__patch__方法,每次更新 DOM 的時候都會執(zhí)行。就從這里入手, 重寫這個方法,對還沒掛載到文檔樹的 DOM 元素執(zhí)行翻譯操作。
- 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;
- };
至此,基本完成了多語言翻譯。經(jīng)過權(quán)衡對比,這個方案算是比較省時省力又能完成需求的了。當(dāng)然,這種方案或多或少對頁面性能有一定影響,畢竟增加了 DOM 更新的時間。尤其是動態(tài)文本較多的情況,涉及到遍歷正則匹配,比較耗時。如果大家有更好的方案,歡迎留言!
本文轉(zhuǎn)載自微信公眾號「1024譯站」,可以通過以下二維碼關(guān)注。轉(zhuǎn)載本文請聯(lián)系1024譯站公眾號。