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

李中凱發表於2020-09-17

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 更新的時間。尤其是動態文字較多的情況,涉及到遍歷正則匹配,比較耗時。如果大家有更好的方案,歡迎留言!

這個圖的資訊量太大了,你們猜猜是什麼

相關文章