谷歌外掛: 頁面展示i18n的原始key, 營救pm於水火

lulu_up發表於2022-05-09

背景、

     國際化專案會用到一大堆的i18n_key來處理文案, 直接看下面的例子吧:
image.png

image.png

但是實際上我們的程式碼裡可能是這樣的:

  <p className="home_title">{t("page_home_title_welcome")}</p>
  <button> {t("page_home_nav_switch_language")}</button>
  <div>{t("page_home_main_content")}</div>

     我希望做一款谷歌外掛, 它可以讓網站隨意切換為下面這個樣子:
image.png

一、這外掛什麼場景使用?

     隨著專案的不斷壯大, 像是上圖的 page_home_nav_switch_language這種i18n_key, 已經 n千多條了, 並且每次功能的合併或者是改版, 可能都會涉及到i18n_key的改寫。

     如果一個網站同時相容多國語言, 比如提供8個國家的語言, 那麼翻譯後的文案展示相關問題會激增。

     我遇到多次的實際問題就是, 某個模組的某個按鈕的xx國家語言下文案出了問題, 此時產品同學就會at我, 讓我幫忙找這個文案對應的key是什麼, 尋找key的過程也不容易, 因為翻譯的文案重複的太多了, 比如一個按鈕文案是"ok", 那麼全域性這些key都對應著"ok",

page_home_title_model_ok:      "ok",
page_user_nav_create_model_ok: "ok",
page_user_title_error_ok:      "ok",
user_detail_model_ok:          "ok",
//...

     我一般需要通過業務來確定程式碼所在檔案, 然後再逐一排查, 這個過程經歷過才知道有多"墨跡", 所以一定要做一款外掛解救pm也解救自己。

     外掛做出來後收到了產品同學的強烈感謝?!

二、搭建簡易的i18n專案

     為了演示外掛的效果, 我這裡真實的搭建一個簡易的react_i18n專案:

npx react-react-app react_i18n

     進入建立好的專案內, 安裝 i18n 相關包:

yarn add i18next react-i18next

     在src下新建i18n資料夾,以存放國際化相關配置:

image.png

image.png

     對index.js檔案進行配置:

import i18n from "i18next";
import { initReactI18next } from "react-i18next";

import enTranslation from "./en.json";
import zhTranslation from "./zh.json";

const lng = "zh";

i18n.use(initReactI18next).init({
  resources: {
    en: { translation: enTranslation },
    zh: { translation: zhTranslation },
  },
  lng,
  fallbackLng: lng,
  interpolation: { escapeValue: false },
});

export default i18n;

     上述i18n程式碼在index.js入口檔案裡面初始化一次:

import i18n from "./i18n/index";

     在元件中就可以正常使用了, 這裡用的是reactfunction元件來演示:

import i18n from "./i18n/index";
import { useTranslation } from "react-i18next";

function App() {
  const { t } = useTranslation();
  return (
    <div className="App">
      <p className="home_title">{t("page_home_title_welcome")}</p>
      <button
       onClick={() => {
        i18n.changeLanguage(i18n.language === "zh" ? "en" : "zh");
       }}
       >
        {t("page_home_nav_switch_language")}
      </button>
      <div>{t("page_home_main_content")}</div>
    </div>
  );
}

export default App;

     可以看到useTranslationhook的形式。

三、對i18n函式的封裝

     對i18n函式進行封裝的好處是, 可以統一管理一些預設值, 或者是各種報錯的埋點, 並且可以配合我們的外掛, 在src下建立usei18nformat.js檔案:

import { useTranslation } from "react-i18next";

export default () => {
  const { t } = useTranslation();
  return (key, defaultVal) => {
    const value = t(key);
    return value === key ? defaultVal : value;
  };
};
  1. 上面我延續了使用hook這種模式。
  2. 增加接收defaultVal預設值, 這樣當i18n_key翻譯失敗的時候, 可以展示兜底文案。
  3. value === key ? defaultVal : value這裡的比較是因為, react-i18next預設是當無法翻譯的時候返回i18n_key,但這樣的處理很不友好, 因為失去了可讀性。
  4. 翻譯失敗的場景有, 前端寫錯了i18n_key, i18n_key更新了但是前端未更新, 以及隨著翻譯的增多, i18n資料夾內的檔案都是從server非同步獲取的, 所以網路出現問題會導致翻譯失敗。

四、建立谷歌外掛

     終於"主人公"出現了, 未開發過谷歌外掛的推薦先看看我的入門文章:
     谷歌外掛入門文章推薦(上)
     谷歌外掛入門文章推薦(下)

     先展示manifest.json檔案配置:

{
  "manifest_version": 2,
  "name": "隨便起個外掛名",
  "description": "展示i18n的key",
  "version": "0.1",
  "browser_action": {
    "default_icon": "images/logo.png"
  },
  "permissions": ["contextMenus"],
  "background": {
    "page": "background/background.html"
  },
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content/index.js"],
      "css": ["content/index.css"]
    }
  ]
}

image.png

     千萬別忘了開啟開發者模式, 然後就可以匯入manifest.json所在資料夾了:

image.png

五、content_scripts 靠你了

     content_scripts 是谷歌外掛提供的一種能力, 開發者可以向"任意網站"或"指定網站"的html程式碼裡插入一個script標籤, 也就是開發者寫的一段js程式碼可以執行在任何的web中, 可以獲取到當前網站的domwindow資訊。

     能夠把js程式碼注入到web中就可以實現侵入程式碼啦, 可以呼叫web專案內已有的方法。

     我想到的辦法是, 用的i18n專案裡面的 useTranslation方法增加一個判斷, 當window.xxx的值為true的時候, 則直接返回key的值, 這不就實現了頁面展示i18n_key嗎。

     這裡舉個例子吧, 在react_i18n專案中:

import { useTranslation } from "react-i18next";

export default () => {
  const { t } = useTranslation();
  return (key, defaultVal) => {
    const value = t(key);
    return value === key ? defaultVal : value;
  };
};

改寫成:

import { useTranslation } from "react-i18next";

export default () => {
  const { t } = useTranslation();
  return (key, defaultVal) => {
    // 新增的程式碼-----↓
    if (window.GlobalShowI18nKey === true) {
      return key;
    }
    // 新增的程式碼-----↑
    const value = t(key);
    return value === key ? defaultVal : value;
  };
};
如何讓react重新整理

     強制react重新整理這個事比較難辦, 首先react自身也屬於閉包操作, 內部的值都是不外露的, 那思路就剩下呼叫react內部自己的方法了, 這裡我採取的是將"切換語言"的方法同樣掛載到window物件上, 這樣每次我修改window.GlobalShowI18nKey的值都主動呼叫一次切換語言方法, 具體程式碼如下所示:

import i18n from "./i18n/index";

window.GlobalChangeLanguage = () => i18n.changeLanguage(i18n.language);

     上述程式碼裡不用擔心同語言切換問題, 比如當前是'英語'再次呼叫切換到'英語'依舊可以讓react重新整理。

六、從按鈕開始編寫

     既然content_scripts能力讓我們可以插入js程式碼, 那麼我們就用js建立一些"按鈕dom元素"並插入到body上。

     現在先建立一個容器兩個按鈕, 按鈕分別是"展示i18n_key按鈕"與"展示翻譯結果"。

image.png

點選可以展示i18n_key
image.png

image.png

先封裝一個建立按鈕的方法, 並附加上一些基本樣式:

function createBt(config) {
  const oBt = document.createElement("div");
  oBt.classList.add("am-i18n_key-bt");
  oBt.setAttribute("id", config.id);
  oBt.innerText = config.text;
  oBt.style.display = config.display || "none";
  return oBt;
}

建立兩個按鈕

const oShowI18nKeyBt = createBt({
  id: "am-i18n_key_show_key-bt",
  text: "展示:i18n_key",
  display: "block",
});

const oHiddenI18nKeyBt = createBt({
  id: "am-i18n_key_hidden_key-bt",
  text: "展示:翻譯結果",
});

     按鈕樣式的css不展示了畢竟太基礎了, 懂得了原理樣式你可以天馬行空。

可能存在的延遲

     使用者可能並不是第一時間就把GlobalChangeLanguage掛載到window上, 所以這邊要做好多次判斷是否有"更新翻譯"的方法存在。

     我這裡選擇的是, 監聽容器元件的滑鼠移入操作, 滑鼠移入後才決定按鈕的顯隱,

oTipWrap.addEventListener("mouseover", () => {
  // ... 移入後決定按鈕的顯隱
});

七、window竟然被'沙盒'了

     當時寫到這裡遇到了個坑大家一定也要小心啊, 就是通過content_scripts獲取到的網頁上的window物件被沙盒了, 也就是window物件的變化我監聽不到, 我對window物件身上的值進行修改也無法反饋到真正的window上, 也就是我獲得的window物件就是一個深複製過來的拷貝物件...

     非常理解谷歌外掛對widnow能力的限制, 畢竟安全無小事, 但是這種情況下進行開發就會比較費力。

     解決方法也呼之欲出, 我可以動態往body裡面插入script標籤啊, 這個插入的標籤是可以獲取到全域性真正的widnow物件的, 缺點就是好多邏輯都要寫在這個script標籤裡面, 一起看看下面這段控制按鈕"顯隱"的方法:

     第一步: 定義滑鼠進入外層容器:

oTipWrap.addEventListener("mouseover", () => {
  createScript();
  creatScript2updataBtStyle();
  bodyAppendChildScript();
});

建立指令碼

let script = null;

function createScript() {
  if (script) script.remove();
  script = document.createElement("script");
  script.type = "text/javascript";
  script.innerHTML = "";
}

插入指令碼

function bodyAppendChildScript() {
  document.body.appendChild(script);
}

     第二步: 為指令碼賦予js邏輯:

function creatScript2updataBtStyle() {
  script.innerHTML += `
  var GLOBAL_SHOW_I18N_KEY = 'GlobalShowI18nKey';
  var GLOBAL_CHANGE_LANGUAGE = 'GlobalChangeLanguage';
  var i18nKeyShowKeyBt = document.getElementById("am-i18n_key_show_key-bt");
  var i18nKeyHiddenKeyBt = document.getElementById("am-i18n_key_hidden_key-bt");

  if(window[GLOBAL_CHANGE_LANGUAGE]){
    i18nKeyHiddenKeyBt.onclick = () => {
        window[GLOBAL_SHOW_I18N_KEY] = false;
        window[GLOBAL_CHANGE_LANGUAGE]()
        changeBtStatus()
    };
    i18nKeyShowKeyBt.onclick = () => {
        window[GLOBAL_SHOW_I18N_KEY] = true;
        window[GLOBAL_CHANGE_LANGUAGE]()
        changeBtStatus()
    };
    function changeBtStatus(){
        if (window[GLOBAL_SHOW_I18N_KEY]) {
            i18nKeyShowKeyBt.style.display = "none";
            i18nKeyHiddenKeyBt.style.display = "block";
        } else {
            i18nKeyShowKeyBt.style.display = "block";
            i18nKeyHiddenKeyBt.style.display = "none";
        }
    }
  }
`;
}

     上述程式碼邏輯為, 當全域性GlobalShowI18nKeytrue時為展示i18n_key此時應該展示"還原按鈕"以此類推。

     將按鈕的點選事件放在這裡是因為怕某些專案賦予widnow.GlobalChangeLanguage方法是非同步的。

     之所以使用var而不是const是因為偶會出現重複定義的bug

八、相容未適配的專案

image.png

     大多數網站是沒有適配這個外掛的, 所以需要我們來適配這個情況, 先建立一個"專案未適配"的按鈕:

const oGlobalNoConfigurationBt = createBt({
  id: "am-global_no_configuration-bt",
  text: "此專案未適配",
});

     這個按鈕點選後會alert出提示框, 並且展示"外掛的官網"(雖然沒有), 但是比如把當前這篇文章地址複製到使用者的剪下板裡。

oGlobalNoConfigurationBt.addEventListener("click", () => {
  const aux = document.createElement("input");
  aux.setAttribute(
    "value",
    `xxxxxxxxxx官網地址`
  );
  document.body.appendChild(aux);
  aux.select();
  document.execCommand("copy");
  document.body.removeChild(aux);
  alert(`外掛文件url: 已複製到剪下板`);
});

image.png

九、增加專案資訊的展示

     只有切換語言這一個功能有點大材小用了, 所以當前增加了一個展示專案資訊的能力, 如圖所示:

image.png

     原理也是比較直白, 識別出webwindow.GlobalProjectInformation上有值, 然後再以table的形式進行展示, 先展示i18n專案的配置:

window.GlobalProjectInformation = {
  title:['name','Version', 'user', 'env'],
  context:[
    ['home頁面','v2.13.09', 'lulu', '測試環境'],
    ['user頁面','v3.8.06', 'lulu', '測試環境']
  ]
};

     這裡增加一個解析專案資訊的方法:

oTipWrap.addEventListener("mouseover", () => {
  createScript();
  creatScript2updataBtStyle();
  // 新增程式碼---- ↓
  showProjectInformation();
  // 新增程式碼---- ↑
  bodyAppendChildScript();
});

動態插入table元素即可, 若使用者未配置則不作操作:

function showProjectInformation() {
  script.innerHTML += `
  var GLOBAL_PROJECT_INFOR = 'GlobalProjectInformation';
  var data = window[GLOBAL_PROJECT_INFOR]
  if(data){
    var oProjectInfor = document.getElementById("am-project-information-wrap");
    oProjectInfor.style.display = "block"

    var tdTitleListString = ""
    data.title.forEach((item)=>{
      tdTitleListString += "<td>"+item+"</td>"
    })

    var tdContextListString = ""
    data.context.forEach((trItem)=>{
      var str = ""
      trItem.forEach((tdItem)=>{
         str += "<td>"+tdItem+"</td>"
      })
      tdContextListString += "<tr>"+ str +"</tr> "
    })
    
    oProjectInfor.innerHTML = \`
    <table id="am-project-information-table">
    <thead>
      <tr> \${tdTitleListString} </tr>
    </thead>
    <tbody> \${tdContextListString} </tbody>
  </table>
  \`
  }
  `;
}

end

     這次就是這樣, 希望與你一起進步。

相關文章