博客 / 詳情

返回

一個 VSCode 插件搞定所有國際化項目:I18n Fast 使用指南

前言

最近把公司內部在用的國際化插件(I18n Fast)開源了。這是一個基於 Hook 機制可動態擴展的插件,理論上可以支持任何技術棧,滿足任何複雜需求

如果你:

  • 項目技術棧比較特殊,沒有可用的 i18n 插件
  • 有複雜的國際化需求無法滿足
  • 想要完全掌控國際化流程
  • 願意花時間瞭解並寫一些 js 代碼

不妨試試 I18n Fast,也許它就是你要找的工具。

背景

去年在對公司項目進行國際化改造時,我發現了一個普遍存在的問題:現有的國際化插件難以滿足複雜項目的需求

國際化開發場景

場景一:添加文案的繁瑣流程

  1. 複製文本 ”確認刪除嗎?”
  2. 想個 key 名字... “confirm.delete”?“dialog.confirmDelete”?
  3. 打開 zh.json,添加 "confirm.delete": "確認刪除嗎?"
  4. 打開 en.json,添加 "confirm.delete": "Confirm delete?"
  5. 回到代碼,改成 formatMessage({ id: 'confirm.delete' })
  6. 哦對了,還要檢查是不是已經有重複的 key 了...

一個簡單的文案,3 分鐘就過去了。

場景二:老項目國際化改造

<div>歡迎使用系統</div>
<span>用户名不能為空</span>
// ... 還有幾百個

手動改?下班之前能改完算我輸。

場景三:團隊協作的混亂

  • A定義了 user.name.required
  • B不知道,又定義了 form.username.empty
  • 結果同一個文案,兩個 key,維護的時候傻眼了

我的國際化需求

  1. 添加一個 i18n 文案要完全自動化
  2. 技術棧高適配性(因為公司有一些老項目要支持)
  3. 去重邏輯,避免重複 i18n 出現
  4. i18n 的 key 要做語義化處理,需要 AI 生成(因為公司有一個翻譯系統,需要語義化的 key 來協助翻譯)

現有插件的侷限性

嘗試了幾個市場中比較流行的 i18n 插件:

插件 優點 問題
I18n Ally 可視化翻譯管理、多框架支持、完善文檔 需手動輸入 key、團隊 key 風格不統一、不支持特殊技術棧
Du I18N 自動掃描中文、可自動生成 key Hash key 無語義、無法直接寫入文件、不支持特殊技術棧
Sherlock 實時預覽翻譯、支持 i18next、代碼內編輯 配置複雜、需要 inlang 項目文件、功能相對固定

I18n Fast 核心理念

經過反覆思考,我意識到問題的根源在於:每個項目的國際化方案都不一樣,但插件卻試圖用一套規則覆蓋所有場景

那為什麼不反過來,讓使用者自己定義規則呢?

於是 I18n Fast 誕生了,這是一個側重於可定製化的 i18n 管理插件。插件本身並未實現具體功能,而是通過 Hook 機制讓使用者自行實現,插件負責把這些 Hook 串聯起來跑通流程。

簡單來説,它通過增加配置成本和動態執行外部代碼,換取了通用性和靈活性,從而能夠滿足更多項目的國際化需求。

工作流

匹配 → 轉換 → 寫入 → 收集 → 展示

流程圖

 title=

使用

完整 demo 請參考:https://github.com/lvboda/vscode-i18n-fast-simple-demo

安裝好插件直接把項目拉下來就可以看到效果。

第一步:安裝插件

# VSCode 擴展商店搜索 ”I18n Fast”
# 或命令行安裝
code --install-extension lvboda.vscode-i18n-fast

 title=

第二步:基礎配置

在項目目錄下創建.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 定義位置

 title=

選中轉換:

  1. 選中需要轉換的文本
  2. cmd + option + c / ctrl + alt + c
  3. i18n 文件自動寫入,代碼文件自動更新

 title=

批量轉換:

  1. 在當前文件cmd + option + c / ctrl + alt + c
  2. i18n 文件自動寫入,代碼文件自動更新

 title=

轉換剪切板文本並粘貼:

  1. 複製需要轉換的文本
  2. 在要粘貼的位置cmd + option + v / ctrl + alt + v
  3. i18n 文件自動寫入,代碼文件自動更新

 title=

遇到重複 i18n 時:

  • 根據i18n-fast.conflictPolicy配置來執行對應策略
  • 圖中為smart模式,有超過一個 i18n 定義,所以彈出選擇器自行選擇:要複用的 key 、忽略(重新生成)、跳過

更多i18n-fast.conflictPolicy可選項參考配置

 title=

撤銷:

  • 撤銷上一步的所有寫入操作,i18n 文件、代碼文件等
  • 最大可撤銷次數:10 次

 title=

進階

配置

參考文檔: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;
        });
    }
}

 title=

公司內部在用的完整 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 配置。您的每一個反饋都是我繼續前進的動力!!!

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.