背景、
國際化專案會用到一大堆的i18n_key來處理文案, 直接看下面的例子吧:
但是實際上我們的程式碼裡可能是這樣的:
<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>
我希望做一款谷歌外掛, 它可以讓網站隨意切換為下面這個樣子:
一、這外掛什麼場景使用?
隨著專案的不斷壯大, 像是上圖的 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資料夾,以存放國際化相關配置:
對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";
在元件中就可以正常使用了, 這裡用的是react
的 function
元件來演示:
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;
可以看到useTranslation
是hook
的形式。
三、對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;
};
};
- 上面我延續了使用hook這種模式。
- 增加接收
defaultVal
預設值, 這樣當i18n_key
翻譯失敗的時候, 可以展示兜底文案。 value === key ? defaultVal : value
這裡的比較是因為,react-i18next
預設是當無法翻譯的時候返回i18n_key
,但這樣的處理很不友好, 因為失去了可讀性。- 翻譯失敗的場景有, 前端寫錯了
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"]
}
]
}
千萬別忘了開啟開發者模式, 然後就可以匯入manifest.json
所在資料夾了:
五、content_scripts 靠你了
content_scripts
是谷歌外掛提供的一種能力, 開發者可以向"任意網站"或"指定網站"的html
程式碼裡插入一個script
標籤, 也就是開發者寫的一段js程式碼
可以執行在任何的web
中, 可以獲取到當前網站的dom
與window
資訊。
能夠把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按鈕"與"展示翻譯結果"。
點選可以展示i18n_key
先封裝一個建立按鈕的方法, 並附加上一些基本樣式:
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";
}
}
}
`;
}
上述程式碼邏輯為, 當全域性GlobalShowI18nKey
為true
時為展示i18n_key
此時應該展示"還原按鈕"以此類推。
將按鈕的點選事件放在這裡是因為怕某些專案賦予widnow.GlobalChangeLanguage
方法是非同步的。
之所以使用var
而不是const
是因為偶會出現重複定義的bug
。
八、相容未適配的專案
大多數網站是沒有適配這個外掛的, 所以需要我們來適配這個情況, 先建立一個"專案未適配"的按鈕:
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: 已複製到剪下板`);
});
九、增加專案資訊的展示
只有切換語言這一個功能有點大材小用了, 所以當前增加了一個展示專案資訊的能力, 如圖所示:
原理也是比較直白, 識別出web
的window.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
這次就是這樣, 希望與你一起進步。