現有 Vue.js 項目快速實現多語言切換的一種思路

Web 項目多語言(i18n,即國際化)是比較常見的需求,常規的做法大概有以下幾種:

  1. 每種語言單獨開發頁面,適用於 CMS 之類的網站
  2. 多語言文本和頁面結構分離,運行時動態替換。適用於單頁應用(SPA)
  3. 直接用網頁翻譯插件,機器翻譯。這種效果不太理想,同時有一些侷限性(後面會講到)

問題

每一種方案都有各自的優點和侷限性,具體項目應該根據實際情況選擇。最近在工作中碰到的需求是要在現有的項目基礎上快速推出多語言版本。項目是基於 Vue.js 開發的,已經迭代過很多版本了。其實一開始是有規劃多語言的,也引進了 vue-i18n 插件。這個插件就是上面第二種方案,用 JSON 文件管理多語言的文本資源,在 Vue 組件模板裏通過鍵名引用文本。但是要管理這些英文鍵名比較麻煩,命名就很頭疼。而且閱讀代碼的時候也很難從鍵名快速識別出對應的中文。後面發現 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 翻譯JS代碼

鑑於以上原因,瀏覽器自帶的 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 更新的時間。尤其是動態文本較多的情況,涉及到遍歷正則匹配,比較耗時。如果大家有更好的方案,歡迎留言!

這個圖的信息量太大了,你們猜猜是什麼

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章