前言
最近把公司內部在用的國際化插件(I18n Fast)開源了。這是一個基於 Hook 機制可動態擴展的插件,理論上可以支持任何技術棧,滿足任何複雜需求。
如果你:
- 項目技術棧比較特殊,沒有可用的 i18n 插件
- 有複雜的國際化需求無法滿足
- 想要完全掌控國際化流程
- 願意花時間瞭解並寫一些 js 代碼
不妨試試 I18n Fast,也許它就是你要找的工具。
背景
去年在對公司項目進行國際化改造時,我發現了一個普遍存在的問題:現有的國際化插件難以滿足複雜項目的需求。
國際化開發場景
場景一:添加文案的繁瑣流程
- 複製文本 ”確認刪除嗎?”
- 想個 key 名字... “confirm.delete”?“dialog.confirmDelete”?
- 打開 zh.json,添加
"confirm.delete": "確認刪除嗎?" - 打開 en.json,添加
"confirm.delete": "Confirm delete?" - 回到代碼,改成
formatMessage({ id: 'confirm.delete' }) - 哦對了,還要檢查是不是已經有重複的 key 了...
一個簡單的文案,3 分鐘就過去了。
場景二:老項目國際化改造
<div>歡迎使用系統</div>
<span>用户名不能為空</span>
// ... 還有幾百個
手動改?下班之前能改完算我輸。
場景三:團隊協作的混亂
- A定義了
user.name.required - B不知道,又定義了
form.username.empty - 結果同一個文案,兩個 key,維護的時候傻眼了
我的國際化需求
- 添加一個 i18n 文案要完全自動化
- 技術棧高適配性(因為公司有一些老項目要支持)
- 去重邏輯,避免重複 i18n 出現
- i18n 的 key 要做語義化處理,需要 AI 生成(因為公司有一個翻譯系統,需要語義化的 key 來協助翻譯)
現有插件的侷限性
嘗試了幾個市場中比較流行的 i18n 插件:
| 插件 | 優點 | 問題 |
|---|---|---|
| I18n Ally | 可視化翻譯管理、多框架支持、完善文檔 | 需手動輸入 key、團隊 key 風格不統一、不支持特殊技術棧 |
| Du I18N | 自動掃描中文、可自動生成 key | Hash key 無語義、無法直接寫入文件、不支持特殊技術棧 |
| Sherlock | 實時預覽翻譯、支持 i18next、代碼內編輯 | 配置複雜、需要 inlang 項目文件、功能相對固定 |
I18n Fast 核心理念
經過反覆思考,我意識到問題的根源在於:每個項目的國際化方案都不一樣,但插件卻試圖用一套規則覆蓋所有場景。
那為什麼不反過來,讓使用者自己定義規則呢?
於是 I18n Fast 誕生了,這是一個側重於可定製化的 i18n 管理插件。插件本身並未實現具體功能,而是通過 Hook 機制讓使用者自行實現,插件負責把這些 Hook 串聯起來跑通流程。
簡單來説,它通過增加配置成本和動態執行外部代碼,換取了通用性和靈活性,從而能夠滿足更多項目的國際化需求。
工作流
匹配 → 轉換 → 寫入 → 收集 → 展示
流程圖
使用
完整 demo 請參考:https://github.com/lvboda/vscode-i18n-fast-simple-demo
安裝好插件直接把項目拉下來就可以看到效果。
第一步:安裝插件
# VSCode 擴展商店搜索 ”I18n Fast”
# 或命令行安裝
code --install-extension lvboda.vscode-i18n-fast
第二步:基礎配置
在項目目錄下創建.vscode/settings.json(已有則忽略)
寫入配置:
// .vscode/settings.json
{
"i18n-fast.i18nFilePattern": "src/locales/**/*.{js,json}" // i18n 文件匹配規則
}
第三步:編寫 Hook
以最簡單的 React + react-intl 項目為例
在項目目錄下創建.vscode/i18n-fast.hook.js
Copy Hook 模版粘貼到該文件
開始寫 Hook:
這裏簡化了類型部分,實際的模版中會有 jsdoc 做類型提示
// .vscode/i18n-fast.hook.js
module.exports = {
/**
* 匹配:找出需要國際化的文本返回固定格式
* 可以不配置,默認有匹配中文和選中匹配的機制
*/
match(context) {
return [];
},
/**
* 轉換:拿到匹配到的文本數組,按你的需求進行轉換
* 可以不配置,這裏的邏輯可以放在 Write Hook 裏
*/
convert(context) {
const { convertGroups, document, _, uuid, safeCall, isInJsxElement, isInJsxAttribute } = context;
// 獲取當前文檔
const documentText = document.getText();
// 組裝成規定格式返回
return convertGroups.map(group => {
// 只有 type 為 new 才會新生成,type 的值根據 i18n-fast.conflictPolicy 配置+插件內部機制得來
const i18nKey = group.type === 'new' ? `i18n_fast_key_${_.replace(uuid.v4(), /-/g, '_')}` : group.i18nKey;
// 獲取當前匹配的文本在文檔中的位置
const startIndex = document.offsetAt(group.range.start);
const endIndex = document.offsetAt(group.range.end);
// 判斷這個位置是否在 JSX 中或在 JSX 屬性中
const inJsxOrJsxAttribute = safeCall(isInJsxElement, [documentText, startIndex, endIndex]) || safeCall(isInJsxAttribute, [documentText, startIndex, endIndex]);
// 覆寫代碼文本 inJsxOrJsxAttribute 為 true 加 {} 包裹
const overwriteText = inJsxOrJsxAttribute ? `{formatMessage({ id: '${i18nKey}' })}` : `formatMessage({ id: '${i18nKey}' })`;
return {
...group,
i18nKey: i18nKey,
i18nValue: group.i18nValue,
overwriteText,
};
});
},
/**
* 寫入:把你轉換後的結果寫入 i18n 文件和代碼文件
* 必須配置
*/
async write(context) {
const { convertGroups, document, writeFileByEditor, vscode, getConfig } = context;
// 遍歷 i18n 文件
for (const fileUri of await vscode.workspace.findFiles(getConfig().i18nFilePattern)) {
// 這裏簡單用文件名判斷語言
const isEn = fileUri.fsPath.endsWith('en-US.json');
// 讀 i18n 文件內容並轉成 JSON 方便後續追加
const i18nJSON = JSON.parse((await vscode.workspace.fs.readFile(fileUri)).toString());
// 將新生成的 key value 追加進去
convertGroups.forEach((group) => {
// 只有 type 為 new 才新建
if (group.type === 'new') {
// 這裏簡單模擬翻譯成英文的效果
i18nJSON[group.i18nKey] = isEn ? `模擬翻譯英文:${group.i18nValue}` : group.i18nValue;
}
});
// 寫入 i18n 文件
await writeFileByEditor(fileUri, JSON.stringify(i18nJSON, null, 2), true);
}
// 寫入代碼文件
await writeFileByEditor(document.uri, convertGroups.map(({ range, overwriteText }) => ({ range, content: overwriteText })));
},
/**
* 採集 i18n:收集 i18n 做顯示和去重
* 推薦配置,不然無法回顯,轉換時的去重機制(i18n-fast.conflictPolicy)也需要這個 Hook 支持
*/
async collectI18n(context) {
const { i18nFileUri, vscode, getICUMessageFormatAST, safeCall, _ } = context;
// 排除英文 這個案例中不用採集英文
if (_.includes(i18nFileUri.fsPath, 'en-US.json')) {
return [];
}
// 讀取 i18n 文件並轉為 JSON
const i18nJSON = JSON.parse((await vscode.workspace.fs.readFile(i18nFileUri)).toString());
// 組裝成規定格式返回
return Object.entries(i18nJSON).map(([key, value], index) => ({
key, // i18n key
value, // i18n value
valueAST: safeCall(getICUMessageFormatAST, [value]), // 用於格式化顯示 非必需 根據項目實際需求來
line: index + 2, // 用於跳轉至定義處 非必需 根據項目實際需求獲取
}));
},
/**
* 匹配 i18n:匹配文檔中的 i18n key 主要用於過濾和自定義顯示邏輯
* 可以不配置,用於對回顯做特殊處理的 Hook
*/
matchI18n(context) {
return context.i18nGroups; // 默認全匹配
},
};
配置和 Hook 都推薦以項目維度來配置(在項目目錄下添加配置和 Hook 文件),不同項目之間會起到隔離作用
Hook 代碼或配置改動完會立即生效,不需要重啓插件或編輯器
第四步:使用
cmd + option + c(macOS) /ctrl + alt + c(Windows|Linux):轉換當前文件匹配到的文本|選中文本cmd + option + v(macOS) /ctrl + alt + v(Windows|Linux):轉換剪切板文本並粘貼cmd + option + b(macOS) /ctrl + alt + b(Windows|Linux):撤銷操作
回顯效果:
- 文案後面有中文回顯
- hover 上去有完整的中文
cmd/ctrl + click下鑽跳轉至 i18n 定義位置
選中轉換:
- 選中需要轉換的文本
cmd + option + c/ctrl + alt + c- i18n 文件自動寫入,代碼文件自動更新
批量轉換:
- 在當前文件
cmd + option + c/ctrl + alt + c - i18n 文件自動寫入,代碼文件自動更新
轉換剪切板文本並粘貼:
- 複製需要轉換的文本
- 在要粘貼的位置
cmd + option + v/ctrl + alt + v - i18n 文件自動寫入,代碼文件自動更新
遇到重複 i18n 時:
- 根據
i18n-fast.conflictPolicy配置來執行對應策略 - 圖中為
smart模式,有超過一個 i18n 定義,所以彈出選擇器自行選擇:要複用的 key 、忽略(重新生成)、跳過
更多i18n-fast.conflictPolicy可選項參考配置
撤銷:
- 撤銷上一步的所有寫入操作,i18n 文件、代碼文件等
- 最大可撤銷次數:10 次
進階
配置
參考文檔:https://github.com/lvboda/vscode-i18n-fast?tab=readme-ov-file#plugin-configuration
Hook Context
為了使 Hook 編寫簡單,每個 Hook 的 Context 都提供了豐富的工具,通過參數傳遞
- 完整的 VSCode API
- lodash、babel、uuid 等常用庫
- 文件寫入、消息提示等封裝好的 API
參考文檔:https://github.com/lvboda/vscode-i18n-fast?tab=readme-ov-file#context
Hook 規範
- NodeJS 運行時
- 使用 CommonJS 規範
- 可以
require三方模塊 - 支持異步
基於這些,理論上可以實現任何需求
實際項目中的技巧
自定義匹配規則
自動匹配#()包裹的文本,解決一些匹配中文覆蓋不到的地方,比如要轉換一些英文或符號
module.exports = {
match(context) {
const { document } = context;
// 從當前文檔中匹配 #() 包裹的文本
const matchedArr = document.getText().match(/(?:(['"`])#\((.+?)\)\1|#\((.+?)\))/gs) || [];
return matchedArr
.map((matchedText) => {
// 提取文案
const i18nValue = [...matchedText.matchAll(/#\((.*?)\)/gs)]?.[0]?.[1];
if (!i18nValue) {
return;
};
return {
matchedText, // 完整文本
i18nValue // 文案
};
}).filter(Boolean);
}
}
JSX 內的轉換結果加{}包裹
在上面編寫的 Hook 裏,我們寫了根據匹配的文本位置如果在 JSX 中或 JSX 屬性中則加{}包裹的邏輯
這裏我們擴展一下,Context 裏帶的 isInJsxElement & isInJsxAttribute 這兩個方法都只是默認支持 js,如果遇到 ts 或者非 js 項目怎麼判斷呢?
以 ts 項目舉例,isInJsxElement & isInJsxAttribute 的第一個參數是可以傳 AST 進去的,所以這裏可以自己用 babel 解析成 AST 傳進去
如果是非 js|ts 項目,有類似邏輯自行實現或引入對應的三方庫實現即可。
舉一反三,如果有類似自動引入 formatMessage 的需求,也可以參考這種思路去寫。
module.exports = {
convert(context) {
const { convertGroups, document, _, uuid, safeCall, isInJsxElement, isInJsxAttribute, babel } = context;
// 獲取當前文檔
const documentText = document.getText();
// 組裝成規定格式返回
return convertGroups.map(group => {
// 只有 type 為 new 才新生成 type 的值根據 conflictPolicy 配置+插件內部機制得來
const i18nKey = group.type === 'new' ? `i18n_fast_key_${_.replace(uuid.v4(), /-/g, '_')}` : group.i18nKey;
// 獲取當前匹配的文本在文檔中的位置
const startIndex = document.offsetAt(group.range.start);
const endIndex = document.offsetAt(group.range.end);
// 將當前代碼轉為 AST 語法樹
const AST = safeCall(() => babel.parse(documentText, {
sourceType: 'module',
plugins: ['typescript', 'jsx'],
errorRecovery: true,
allowImportExportEverywhere: true,
allowReturnOutsideFunction: true,
allowSuperOutsideMethod: true,
allowUndeclaredExports: true,
allowAwaitOutsideFunction: true,
}));
// 判斷這個位置是否在 JSX 中或在 JSX 屬性中
const inJsxOrJsxAttribute = safeCall(isInJsxElement, [AST, startIndex, endIndex]) || safeCall(isInJsxAttribute, [AST, startIndex, endIndex]);
// 覆寫代碼文本 inJsxOrJsxAttribute 為 true 加 {} 包裹
const overwriteText = inJsxOrJsxAttribute ? `{formatMessage({ id: '${i18nKey}' })}` : `formatMessage({ id: '${i18nKey}' })`;
return {
...group,
i18nKey: i18nKey,
i18nValue: group.i18nValue,
overwriteText,
};
});
},
}
引入三方庫進行翻譯
在 Hook 中可以 require 三方模塊,只要確保項目中或全局 install 了這個模塊。
這個例子中,我們引入了 OpenAI SDK 來做翻譯,異步操作前後可以用 Context 中提供的 setLoading、showMessage 使功能更健壯。
const { OpenAI } = require('openai');
// 使用三方模塊進行翻譯,這裏省略實現代碼
// 在項目中或全局安裝 OpenAI SDK,確保可以 require 到
const aiTranslate = async (groups) => {
// use OpenAI...
}
module.exports = {
async write(context) {
const { convertGroups, document, writeFileByEditor, vscode, getConfig, setLoading, showMessage } = context;
try {
// 狀態欄全局 loading
setLoading(true);
// 遍歷 i18n 文件
for (const fileUri of await vscode.workspace.findFiles(getConfig().i18nFilePattern)) {
// 這裏簡單用文件名判斷語言
const isEn = fileUri.fsPath.endsWith('en-US.json');
// 讀i18n文件內容並轉成 JSON 方便後續追加
const i18nJSON = JSON.parse((await vscode.workspace.fs.readFile(fileUri)).toString());
// 將新生成的 key value 追加進去
// 如果是英文則調用翻譯
(isEn ? (await aiTranslate(convertGroups)) : convertGroups).forEach((group) => {
// 只有 type 為 new 才新建
if (group.type === 'new') {
i18nJSON[group.i18nKey] = group.i18nValue
}
});
// 寫入 i18n 文件
await writeFileByEditor(fileUri, JSON.stringify(i18nJSON, null, 2), true);
}
// 寫入代碼文件
await writeFileByEditor(document.uri, convertGroups.map(({ range, overwriteText }) => ({ range, content: overwriteText })));
} catch (e) {
showMessage('error', `<genI18nKey error> ${e?.stack || e}`)
} finally {
setLoading(false);
}
}
}
不是 app 開頭的 key 不顯示裝飾器
解決一些不是 i18n key 的文本卻被匹配到造成的”幻覺”,增加一些條件使匹配到的 i18n 更精準,相關 Issue:https://github.com/lvboda/vscode-i18n-fast/issues/3
matchI18n 這個 Hook 自由度比較高,可以控制裝飾器、HoverMessage、跳轉定義是否生效,還能自定義顯示的內容和樣式,可以自行探索。
module.exports = {
matchI18n(context) {
const { i18nGroups, _ } = context;
return i18nGroups.map((group) => {
if (!_.startsWith(group.key, 'app.')) {
group.supportType = 7 & ~1;
}
return group;
});
}
}
公司內部在用的完整 Hook
AI 生成 i18n key(React 項目)
Hook 示例:https://github.com/lvboda/vscode-i18n-fast/issues/21
demo:https://github.com/lvboda/vscode-i18n-fast/tree/main/test/react
AI 生成 i18n key(PHP + Jquery 項目)
Hook 示例:https://github.com/lvboda/vscode-i18n-fast/issues/22
demo:https://github.com/lvboda/vscode-i18n-fast/tree/main/test/php
Hook 分享
Hook 本質上就是代碼片段,所以是可以進行復用或參考的。
歡迎提交 Issue 來分享你的 Hook,我會在這類 Issue 上打上 hook-example 標籤,以供別人參考或複用。
Hook Example 列表:https://github.com/lvboda/vscode-i18n-fast/labels/hook%20example
思考
I18n Fast 的理念和主流的“約定優於配置”相反,我把其配置的部分“複雜化”了,所以無法像其他插件一樣安裝即可使用。
為什麼要這麼做?
因為國際化這件事,不同項目或團隊之間的差異實在太大了:
- key 命名風格千差萬別:駝峯、下劃線、點分隔,各有偏好
- 存儲方式五花八門:JSON、YAML、數據庫、Properties 文件等
- 技術棧的多樣性:React、Vue、Angular、原生 js 甚至是後端項目
- 各種定製化需求:引入 AI、調用接口、同步文件等等
與其做一個“看起來什麼都支持但實際處處受限”的插件,不如提供一個足夠靈活的機制,讓使用者自行實現。
當然,靈活性是有代價的 —— 需要寫 Hook。但考慮到:
- Hook 只需要寫一次
- 可以複用和分享
- 完全滿足需求帶來的效率提升
這個代價是值得的。
最後
I18n Fast 已經開源:
- GitHub: https://github.com/lvboda/vscode-i18n-fast
- VSCode Marketplace: https://marketplace.visualstudio.com/items?itemName=lvboda.vs...
- Hook 模版: https://github.com/lvboda/vscode-i18n-fast/blob/main/example/i18n-fast.hook.template.js
- Hook 示例: https://github.com/lvboda/vscode-i18n-fast/labels/hook%20example
- 問題反饋: https://github.com/lvboda/vscode-i18n-fast/issues
歡迎 Star ⭐、提 Issue、分享你的 Hook 配置。您的每一個反饋都是我繼續前進的動力!!!