文末福利:開發者藏經閣

NO.1

前言

傳統前端 App 多語言最簡單的實現可以由一套響應式數據流管理系統來託管多語言文案,切換語言時通過數據流的變化使得界面根據文案重新渲染。但由於 VS Code 架構的複雜性,需要有一套能兼容 Electron 渲染窗口(Chromium)及 Node.js 進程的多語言方案。

NO.2

VS Code 實現

我們從源碼開始來一步一步瞭解 VS Code 是如何基於語言包插件實現多語言的。

NLS

VS Code 主進程的入口是 src/main.js (https://github.com/microsoft/vscode/blob/master/src/main.js) ,我們重點關注第63行 (https://github.com/microsoft/vscode/blob/master/src/main.js#L63)

if (locale) {
    nlsConfigurationPromise = lp.getNLSConfiguration(product.commit, userDataPath, metaDataFile, locale);
}

這裏使用 lp.getNLSConfiguration 創建了一個  nlsConfigurationPromise 對象,當 Electron 窗口  onReady 事件觸發時,會將初始化完的 nlsConfig 設置到 VSCODE_NLS _ CONFIG 環境變量中。

其中 nls 是指 Native Language Support (http://wiki.linuxquestions.org/wiki/Native Language Support) ,根據變量名可以得知這是一個獲取多語言配置的 Promise 對象,其入參爲  product.commit 、  userDataPath 、  metaDataFile 以及表示當前用戶設置語言的  locale 。前三個參數非常重要,因爲它涉及到多語言實現的核心細節,我們一個一個來解釋他們的作用。

product.commit

product product.json (https://github.com/microsoft/vscode/blob/master/product.json) 文件,在 VS Code 編譯打包後會補充一些字段,其中包括當前版本的代碼 commit 號。那麼爲什麼多語言的配置要依賴代碼具體版本的 commit 號呢?簡單來說這是由於 VS Code 的語言包爲官方維護的一個插件 vscode-loc (https://github.com/microsoft/vscode-loc) ,在每次 VS Code 新版本 release 發佈後一同發佈到插件市場。爲了區分不同版本的語言文案,所以每個 release 版本的 VS Code 版本都對應相同版本號的語言包插件。實際上問題依然存在,爲什麼語言包要跟隨軟件版本一起 release ?理論上語言包只是一堆文案,和軟件本身分開單獨維護,社區可以隨時貢獻翻譯不是更好嗎?這裏先賣個關子我們後面再說。

userDataPath

userDataPath 很容易理解,這是 VS Code 的用戶數據目錄,不同操作系統下的路徑不一樣

# MacOS
~/Library/Application Support/Code

# Linux
~/.config

# Windows
$(USERPROFILE)/AppData/Roaming

metaDataFile

metaDataFile 是一個名爲 nls.metadata.json 的文件。

const metaDataFile = path.join(__dirname, 'nls.metadata.json');

查看 VS Code 源碼會發現這個文件並不存在,實際上只有在完整編譯打包一遍 VS Code 後纔會生成這個文件,這個文件的大致內容長這樣

{
    "keys": {
        "vs/code/electron-browser/processExplorer/processExplorerMain": <a href="https://github.com/microsoft/vscode-loc/blob/master/i18n/vscode-language-pack-zh-hans/translations/main.i18n.json#L1486">
            "cpu",
            "memory",
            "pid",
            "name",
            "killProcess",
            "forceKillProcess",
            "copy",
            "copyAll",
            "debug"
        ]
    },
    "messages": {
        "vs/code/electron-browser/processExplorer/processExplorerMain": [
            "CPU %",
            "Memory (MB)",
            "pid",
            "Name",
            "Kill Process",
            "Force Kill Process",
            "Copy",
            "Copy All",
            "Debug"
        ]
    },
    "bundles": {
        "vs/code/electron-browser/processExplorer/processExplorerMain": [
            "vs/code/electron-browser/processExplorer/processExplorerMain"
        ]
    }
}

主要包含了 keys 、  messages 、  bundles 三個對象,其中 keys 裏是每個源碼文件中語言文案的 key 名,messages 則是每個源碼文件中語言文案的默認值,bundles 比較奇怪,我們一會再說。這裏很明顯可以看出 keys 裏的鍵名對應到了 messages 裏的文案,我們把它們合併成一個對象

{
    ”vs/code/electron-browser/processExplorer/processExplorerMain“:{
        "cpu": "CPU %",
        "memory": "Memory (MB)",
        //...
    }
}

如果好奇點開上文中 vscode-loc 插件並且看了語言包文件的話會發現這就是[語言包文件 (https://github.com/microsoft/vscode-loc/blob/master/i18n/vscode-language-pack-zh-hans/translations/main.i18n.json#L1486) 的結構。

{
    "vs/code/electron-browser/processExplorer/processExplorerMain": {
        "cpu": "CPU %",
        "memory": "內存 (MB)"// ...
    }
}

到這裏似乎有了一點頭緒,語言包文件的文案內容對應到了 VS Code 源碼的具體文件,根據文件相對路徑分爲一個一個的 namespace ,在編譯時分析了所有包含多語言調用的文件並生成了這個 nls.metadata.json 文件。

bundles 裏記錄的實際爲不同模塊的入口,它定義在 gulpfile.vscode.js (https://github.com/microsoft/vscode/blob/master/build/gulpfile.vscode.js#L49) 。在打包時會以這些文件爲入口,逐個解析 AST ,根據 import 節點的引用一層一層尋找代碼中包含  import * as nls from 'vs/nls' 以及  nls.localize 調用的文件並記錄下來。之後根據這些文件再逐個解析 AST 記錄所有  nls.localize 調用的 key 值以及默認值 message 分別記錄到前文中的 keys 以及 messages 中。

vs/nls

我們再來看一下 vs/nls 模塊是什麼,源碼在這裏 (https://github.com/microsoft/vscode/blob/master/src/vs/nls.js) ,這個看起來像編譯後代碼的實際源碼在 vscode-loader (https://github.com/microsoft/vscode-loader/blob/master/src/nls.ts) 項目中。簡單看一下代碼會發現它是一個 vscode-loader 的插件,vscode-loader 是 VS Code 實現的一個 AMD 規範的異步模塊加載器,由於篇幅限制這裏不詳細敘述它的具體原理。我們只需要瞭解的是當  import * as nls from 'vs/nls' 時實際加載了一個 nlsPlugin 對象,而  nls.localize 是它的一個屬性,粗看它的實現似乎只是負責格式化一下文案及參數原樣返回。

this.localize = (data, message, ...args: (string | number | boolean | undefined | null)<a href="https://github.com/microsoft/vscode/blob/master/src/vs/code/electron-browser/processExplorer/processExplorerMain.ts#L245">]) => localize(this._env, data, message, ...args);

function localize(env: Environment, data, message, ...args: (string | number | boolean | undefined | null)[]) {
    return _format(message, args, env);
}

function _format(message: string, args: (string | number | boolean | undefined | null)[], env: Environment): string {
    let result: string;

    if (args.length === 0) {
        result = message;
    } else {
        result = message.replace(/\{(\d+)\}/g, (match, rest) => {
            let index = rest[0];
            let arg = args[index];
            let result = match;
            if (typeof arg === 'string') {
                result = arg;
            } elseif (typeof arg === 'number' || typeof arg === 'boolean' || arg === void0 || arg === null) {
                result = String(arg);
            }
            return result;
        });
    }
    if (env.isPseudo) {
        // FF3B and FF3D is the Unicode zenkaku representation for [ and ]
        result = '\uFF3B' + result.replace(/[aouei]/g, '$&$&') + '\uFF3D';
    }

    return result;
}

上文我們說 VS Code 的多語言對應到源碼具體文件,實際上這個說法還不夠準確,多語言精確對應到源碼中調用 nls.localize 的具體順序,考慮這段代碼

// vs/path/to/code.ts import * as nls from'vs/nls';


const value = nls.localize('key1', 'Message1');
const value2 = nls.localize('key2', 'Message2');

在生成的 nls.matada.json 中應該是

{
    "keys": {
        "vs/path/to/code": [
            "key1",
            "key2"
        ]
    },
    "messages": {
        "vs/path/to/code": [
            "Message1",
            "Message2"
        ]
    }
}

nls.localize 的用法是  nls.localize(key, defaultMesage, [...args]) ,參數中沒有任何代碼路徑的信息,nls 模塊如何知道調用的是具體哪個文件的 key 呢?

以前文中的 vs/code/electron-browser/processExplorer/processExplorerMain 爲例,再運行  gulp vscode-linux-x64 --old-space-max-size=10240 來看一下源碼 完整編譯 後變成了什麼。

// vs/code/electron-browser/processExplorer/processExplorerMain 編譯後 var __m = ["exports","require",/*"..."*/"vs/nls!vs/code/electron-browser/processExplorer/processExplorerMain"];


// ...const tableHead = document.createElement('thead');
tableHead.innerHTML = `<tr>
<th scope="col" class="cpu">${nls_1.localize(0, null)}</th>
<th scope="col" class="memory">${nls_1.localize(1, null)}</th>
<th scope="col" class="pid">${nls_1.localize(2, null)}</th>
<th scope="col" class="nameLabel">${nls_1.localize(3, null)}</th>
</tr>`
;

對比[源代碼 (https://github.com/microsoft/vscode/blob/master/src/vs/code/electron-browser/processExplorer/processExplorerMain.ts#L245) 會發現兩處不一樣的地方,首先  __m 記錄了代碼中所有引用的模塊名,其中  vs/nls 後面多了一個  ! 以及當前文件相對路徑。其次代碼中的  nls.localize(key, message) 變成了  nls_1.localize(index, args) ,沒有了 key 也沒有 defaultMessage。前文中所說的 多語言精確對應到源碼中調用  nls.localize  的具體順序 ,這裏看就很清楚了,這段編譯後的代碼中 localize 的參數 0、1、2、即表示調用的順序,那麼理論上在 localize 內部執行時應該從類似 nls.metadata.json 中 messages.vs/code/electron-browser/processExplorer/processExplorerMain 數組中獲取字符串。

// 僞代碼 const languageBundles = <a href="https://zhaoda.net/webpack-handbook/amd.html">

"CPU %",
"內存 (MB)",
"PID",
"名稱",
"結束進程",
"強制結束進程",
"複製",
"全部複製",
"調試"
];
function localize(index, args) {
// ...return format(languageBundles[index], args);
}

那麼 vs/nls 是如何得到 messages.vs/code/electron-browser/processExplorer/processExplorerMain 的呢?先來快速複習一下 [AMD 模塊規範 (https://zhaoda.net/webpack-handbook/amd.html)

模塊通過 define 函數定義在閉包中,格式如下:

define(id?: String, dependencies?: String<a href="https://github.com/microsoft/vscode-loader/blob/master/src/core/main.ts#L19">], factory: Function|Object);

id 是模塊的名字,它是可選的參數。dependencies 指定了所要依賴的模塊列表,它是一個數組,也是可選的參數,每個依賴的模塊的輸出將作爲參數一次傳入 factory 中。如果沒有指定 dependencies,那麼它的默認值是 ["require", "exports", "module"]。

define(function(require, exports, module) {})

factory 是最後一個參數,它包裹了模塊的具體實現,它是一個函數或者對象。如果是函數,那麼它的返回值就是模塊的輸出接口或值。

再來看看編譯後的代碼如何定義模塊的

// __M 是一個根據給定的參數從 __m 中獲取模塊列表的函數
define(__m[34/*vs/code/electron-browser/processExplorer/processExplorerMain*/], __M([1/*...*/,36/*vs/nls!vs/code/electron-browser/processExplorer/processExplorerMain*/]), function (require, exports, electron_1, strings_1, os_1, product_1, nls_1, browser, platform, contextmenu_1, dom_1, lifecycle_1, diagnostics_1) {

與我們熟知的 AMD 規範不同的是,這裏的依賴列表中 vs.nls 後面多了  !vs/code/electron-browser/processExplorer/processExplorerMain ,我們知道 vscode-loader 是 VS Code 自己實現的一個模塊加載器,簡單來看一下 define 的調用鏈

  • define

    • ModuleManager.defineModule (https://github.com/microsoft/vscode-loader/blob/master/src/core/moduleManager.ts#L502:10) 調用 ModuleManager.defineModule 定義模塊

    • ModuleManager._normalizeDependency (https://github.com/microsoft/vscode-loader/blob/master/src/core/moduleManager.ts#L536) 標準化依賴

    • ModuleManager._normalizeDependencies (https://github.com/microsoft/vscode-loader/blob/master/src/core/moduleManager.ts#L549) 標準化依賴數組

    • [DefineFuc (https://github.com/microsoft/vscode-loader/blob/master/src/core/main.ts#L19) 包裝前的 Define 方法

let bangIndex = dependency.indexOf('!');

if (bangIndex >= 0) {
    let strPluginId = moduleIdResolver.resolveModule(dependency.substr(0, bangIndex));
    let pluginParam = moduleIdResolver.resolveModule(dependency.substr(bangIndex + 1));
    let dependencyId = this._moduleIdProvider.getModuleId(strPluginId + '!' + pluginParam);
    let pluginId = this._moduleIdProvider.getModuleId(strPluginId);
    returnnew PluginDependency(dependencyId, pluginId, pluginParam);
}

記得前文中說 vs/nls 實際是 vscode-loader 的一個插件嗎,這裏就是負責處理前文中 vs/nls!path/to/module 的地方,它將 ! 後面的字符作爲 nlsPlugin 的參數,這裏的 plugin 會作爲一個特殊的依賴項(PluginDependency),當完成標準化模塊關係後,defineModule 裏會調用 this._resolve 解析模塊。我們直接看 _resolve 中對 PluginDependency 的處理 (https://github.com/microsoft/vscode-loader/blob/master/src/core/moduleManager.ts#L896:L913)

if (dependency instanceof PluginDependency) {
    let plugin = this._modules2<a href="https://github.com/microsoft/vscode-loader/blob/master/src/nls.ts#L142:L177">dependency.pluginId];
    if (plugin && plugin.isComplete()) {
        // 加載插件依賴 this._loadPluginDependency(plugin.exports, dependency);
        continue;
    }

    // Record dependency for when the plugin gets loaded let inversePluginDeps: PluginDependency[] = this._inversePluginDependencies2.get(dependency.pluginId);
    if (!inversePluginDeps) {
        inversePluginDeps = [];
        this._inversePluginDependencies2.set(dependency.pluginId, inversePluginDeps);
    }

    inversePluginDeps.push(dependency);

    this._loadModule(dependency.pluginId);
    continue;
}

重點來看 _loadPluginDependency 的實現,第一個參數即是一個 vs/nls 插件實例,並將模塊依賴的資源加載行爲委託給了插件。

private _loadPluginDependency(plugin: ILoaderPlugin, pluginDependency: PluginDependency): void {
    if (this._modules2[pluginDependency.id] || this._knownModules2[pluginDependency.id]) {
        // known module return;
    }
    this._knownModules2[pluginDependency.id] = true;

    // Delegate the loading of the resource to the pluginlet load: IPluginLoadCallback = <any>((value: any) => {
        this.defineModule(this._moduleIdProvider.getStrModuleId(pluginDependency.id), [], value, null, null);
    });
    load.error = (err: any) => {
        this._config.onError(this._createLoadError(pluginDependency.id, err));
    };

    // 調用插件 load 方法加載依賴
    plugin.load(pluginDependency.pluginParam, this._createRequire(ModuleIdResolver.ROOT), load, this._config.getOptionsLiteral());
}

而在 vs/nls 插件的 [load 方法 (https://github.com/microsoft/vscode-loader/blob/master/src/nls.ts#L142:L177) 中,首先會判斷 name 是否存在,若不存在則加載一個默認的  localize 方法,若存在則讀取插件的配置,調用由插件配置傳入的 loadBundle (https://github.com/microsoft/vscode/blob/master/src/bootstrap.js#L203:L227) (參數爲對應的文件名及語言) 函數獲取可用的語言包。插件配置在 VS Code 啓動時獲取到語言信息完成語言包初始化後傳入(開頭的 nlsConfigurationPromise)。當語言包加載完成,再調用 _loadPluginDependency 中傳入的 load 方法將其定義爲一個模塊依賴,同時傳入一個 scopedLoadlize (https://github.com/microsoft/vscode-loader/blob/master/src/nls.ts#L113:L118) 作爲 localize 的實現,這就是運行時真正的 localize 方法。

以上就是 VS Code 中多語言的實現方式,我們會發現整個方案非常依賴一個自定義的模塊加載器以及代碼編譯時的行爲,但作爲可以獨立開發並運行的插件進程不可能爲了實現多語言強行用 vscode-loader 作爲模塊加載方案。那麼插件又是如何正確的讀取語言包顯示對應文案的呢?

NO.3

插件

插件中同樣會讀取 VSCODE_NLS _ CONFIG 環境變量,不同的是插件中沒有 vs/nls 模塊,而是由一個 vscode-nls (https://github.com/microsoft/vscode-nls) 替代,這是 VS Code 爲插件獨立開發的一個多語言模塊,這裏的實現相對簡單一些。首先在插件運行前就會自動執行  initializeSettings (https://github.com/microsoft/vscode-nls/blob/master/src/main.ts#L146) 函數,簡而言之這裏會讀取 VSCODE_NLS _ CONFIG 環境變量並將配置記錄下來,在插件中使用 vscode-nls 模塊前需要調用 nls.loadMessageBundle 方法,查看 loadMessageBundle 方法會發現它需要一個  file 文件名作爲參數,實際上插件代碼中並沒有傳入這個參數。那麼很顯然插件編譯時代碼也經過了修改,具體來說編譯時會將  nls.loadMessageBundle() 修改爲  nls.loadMessageBundle(__filename) ,再同樣將  nls.localize(key, message) 修改爲  nls.localize(index, args) ,具體的邏輯可以查看 vscode-nls-dev (https://github.com/microsoft/vscode-nls-dev) 模塊,這是 VS Code 爲插件編譯開發的一個模塊,編譯時也會分析插件 AST 生成 nls.metatdata.json 以及 nls.header.json 文件來記錄插件默認的文案以及相關的多語言信息。

NO.4

最後

VS Code 的多語言實現涉及到了依賴分析, AST 操作,模塊加載器等許多技術細節,針對這部分工作原理我閱讀了兩三遍源代碼,而且由於其實現的特殊性,如果不完整編譯(執行 VS Code 打包編譯任務,單純使用 tsc 編譯不會修改 nls 調用行爲)並且閱讀編譯後代碼的話可能會一直繞進坑裏。希望這篇文章能給希望瞭解 VS Code 源碼細節的同學帶來幫助。

推薦閱讀

揭祕手淘召喚術| 幫助千萬級用戶直達手淘的黑科技

WEB 前端菜鳥,感覺很迷茫,該怎麼做?

大廠如何開發和部署前端代碼?淘寶8年案例解讀

文末福利

關注後回覆“藏經閣”

喜歡就點在看哦〜

相關文章