原來VSCode裏藏了騰訊文檔400行代碼?鵝廠源碼公開

圖片

圖片

👉騰小云導讀

Visual Studio Code「VSCode」是 Microsoft 在2015年推出的、針對於編寫現代 Web 和雲應用的跨平臺源代碼編輯器,受到廣大開發者熱捧。騰訊文檔向 VSCode 貢獻了一些核心代碼,主要涉及到 VSCode 配置化的部分,爲其顯著增強了配置化和插件化能力。作者希望將其中積累的經驗分享出來,貢獻給開源社區,爲廣大開發愛好者提供參考。本文詳細解讀源代碼。歡迎閱讀!

1 項目背景

2 騰訊文檔貢獻源碼分析

3 騰訊文檔給 VSCode 帶來了什麼

4 總結

01、項目背景

騰訊文檔在完善自己的配置化系統,在完善的過程探索了多種實現方案,分析了很多產品如大名鼎鼎的 VSCode 的實現方式。近期騰訊文檔向 VSCode 貢獻了 400 多行核心代碼,主要涉及到 VSCode 配置化的部分。這增強了其插件化能力,提供更多的匹配接口。騰訊文檔團隊整理了部分代碼結構和補充功能單測,希望把將這些積累的經驗貢獻給開源社區,與廣大開發愛好者共同進步。公衆號回覆「VSCode」獲取源代碼。

圖片

圖片

合入騰訊文檔代碼的是微軟 VSCode 團隊現主要負責人之一Alexdima(VScode 前身 Monaco Editor 的負責人),他和 Erich Gamma ( VSCode 之父) 來自同一團隊。

圖片

騰訊文檔團隊給 VSCode 的配置化貢獻了什麼功能?相信大部分的開發者都使用過 VSCode,所以對配置化應該不陌生。由於使用者衆多,任何編輯器其實都不能做到面面俱到去滿足所有的使用者。如果什麼用戶的需求都要滿足,就需要把所有的功能都塞進去。這不但臃腫,還不好維護。下面一起來看看我們如何解決。

02、騰訊文檔貢獻源碼分析

我們需要將配置化豐富和拓展,減輕編輯器本身的包袱,把部分內容移交給用戶/合作方去定製。例如:可以在 VSCode 的設置面板選擇編輯器的顏色,更換它的主題背景。

圖片

也可以在快捷鍵面板裏面綁定或者解綁此快捷鍵,更換字體大小和改變懸浮信息等,這些其實都離不開背後實現的一套配置化系統。

圖片

上面的舉例,都是有默認的配置。可以通過面板去更改,當然還有些隱藏的配置無需在面板改變也能實現配置。例如:縮小 VSCode 的界面大小,某些功能就會自動隱藏,這種也是屬於配置化。

圖片

我們除了通過面板可視化操作,還可以通過插件來配置界面,VSCode 中插件的核心就是一個配置文件 package.json,裏面提供了配置點。只需按要求編寫正確的配置點就可以改變 VSCode 的視圖狀態。裏面最主要的字段就是 contributes 字段:

這是更換編輯器部分位置顏色的配置參數。裏面的代碼思路其實是挖了一個「洞」給第三方,然後支持參數的填入。

{  
  "colors": {  
    "activityBar.background": "#333842",  
    "activityBar.foreground": "#D7DAE0",  
    "activityBarBadge.background": "#528BFF"  
  }  
}

下面代碼爲示例。把配置文件的顏色讀取出來,然後生成一個新的顏色規則,達到控制面板背景顏色的功能。

const color = theme.getColor(registerColor("activityBar.background"));  
if (color) {  
  collector.addRule(  
    `.monaco-workbench .activitybar > .content > .home-bar > .home-bar-icon-badge { background-color: ${color}}`  
  );  
}

上面這個最基本的功能在代碼裏面實現是毫無難度的,只需要挖空一個配置點即可,但是實際會更復雜。如果此時用戶想在此功能基礎上繼續做配置,例如編輯器在 Win 系統的時候顏色變深,在 Mac 系統的時候顏色變淺。

if (color) {  
  if (isMacintosh) {  
    color = darken(color);  
  }  
  if (isWindows) {  
    color = lighter(color);  
  }  
  collector.addRule(  
    `.monaco-workbench .activitybar > .content > .home-bar > .home-bar-icon-badge { background-color: ${color}}`  
  );  
}

這些操作對於對開發人員而言難度雖不是很大,只需在代碼裏面插入幾段條件判斷的代碼。但是如果用戶又要求更改的話,可以更改爲在分辨率大於 855 的時候使顏色變深,在分辨率小於或等於 855 的時候使顏色變淺,並且遇到 Linux 系統也會顏色變深。此時可能再變更代碼來滿足客戶的需求,需要繼續加如下的代碼。這樣做會增加開發人員的任務量。編輯器用戶量不止上千萬,用戶需求非常多樣,必然難以招架。

if (color) {  
  if (isMacintosh || window.innerWidth > 855) {  
    color = darken(color);  
  }  
  if (isLinux) {  
    color = darken(color);  
  }  
  if (isWindows || window.innerWidth <= 855) {  
    color = lighter(color);  
  }  
  collector.addRule(  
    `.monaco-workbench .activitybar > .content > .home-bar > .home-bar-icon-badge { background-color: ${color}}`  
  );  
}

圖片

這時就需我們自行定製規範。提供變暗和變深的接口,不負責寫規則,而是需用戶提供。具體調整代碼如下:

class Color {  
  color = theme.getColor(registerColor("activityBar.background"));  
  
  @If(isLinux)  
  @If(isMacintosh || window.innerWidth > 855)  
  darken() {  
    return darken(this.color);  
  }  
  
  @If(userRule1)  
  @If(userRule2)  
  @If(userRule3)  
  @If([isWindows, window.innerWidth <= 855].includes(true))  
  lighter() {  
    return lighter(this.color);  
  }  
}

上面只是列出僞代碼,並非很簡單。只提供純粹的 darkenlighter,不與頻繁的條件表達式耦合,所以可能會做判斷條件的前置化。那麼後續開發人員只需疊加裝飾器即可,並且動態保留一個裝飾器 @If(userRule) 作爲配置文件的洞口。再提供官方配置文檔給用戶寫類似的 package.json 文件填寫對應的參數,這樣壓力就會轉移到使用者(用戶)或者接入者身上。

這種寫法看似美好,但會出現很多致命問題,darkenlighter 在執行前已經被帶條件表達式裝飾,後面如果二次執行 darkenlighter 方法則不會再執行裝飾器中條件表達式的判斷,本質上這兩個函數的 descriptor.value 已經被覆寫,但邏輯從根本上發生了改變。

export const If = (condition: boolean) => {  
  console.log(condition);  
  return (target: any, name?: any, descriptor?: any) => {  
    const func = descriptor?.value;  
    if (typeof func === "function") {  
      descriptor.value = function (...args: any) {  
        return condition && func.apply(this, args);  
      };  
    }  
  };  
};

正常情況下客戶端側 isLinuxisMacintoshisWindows 是不會發生改變的,但是 window.innerWidth 在客戶端卻是有可能持續發生變化。所以一般情況下對待客戶端環境經常變化的值或者需要通過作用域判斷的值,我不建議寫成上面裝飾器暴露接口的方案。如果這是一個比較固定的配置值,這種方案配合 webpackDefinePlugin 會有意外的收穫。

new webpack.DefinePlugin({  
  isLinux: JSON.stringify(true),  
  VERSION: JSON.stringify("5fa3b9"),  
  BROWSER_SUPPORTS_HTML5: true,  
  TWO: "1+1",  
  "typeof window": JSON.stringify("object"),  
  "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV),  
});

但是很多時候是需要在程序運行的時候進行配置化的,上述的大部分內容都是靜態的配置(俗話說是寫死的),比如 if (window.innerWidth > 855) 這個配置參數:

左邊 window.innerWidth 在運行時是變化的,右邊 855 代碼是寫死的,所以一般把這一整段留出一個缺口來進行外部的配置化,會選用 json 去描述這份配置。

在 VSCode 等應用中,很多地方沒有 json 文件進行配置,因爲大部分情況它會提供可視化界面用來修改配置。其實本質是改動了 json 的配置文件來達到目的的,例如上面的 if(isMacintosh || window.innerWidth > 855) 就被插入到外面的 json 文件中了。

// if(isMacintosh || window.innerWidth > 855) ...  
// if(isWindows || window.innerWidth <= 855) ...  
// ↓  
  
{  
  "darken": { "when": "isMacintosh || window.innerWidth > 855" },  
  "lighter": { "when": "isWindows || window.innerWidth <= 855" }  
}

一般需要接入方或者使用者寫成上面類似的文件,然後通過服務器配置系統,下發到客戶端。然後把貢獻點放入裝飾器中的缺口,再執行對應的配置邏輯。大致如下:

@If(JSON.darken)  
darken() {  
return darken(this.color);  
}  
  
@If(JSON.lighter)  
lighter() {  
return lighter(this.color);  
}

JSON.darkenJSON.lighter 分別是對應 JSON 文件中的配置項,實際在代碼運行時接受的字符串參數是:

@If("isMacintosh || window.innerWidth > 855")  
darken() {  
return darken(this.color);  
}  
  
@If("isWindows || window.innerWidth <= 855")  
lighter() {  
return lighter(this.color);  
}

這是大部分配置化繞不開的問題,簡單的配置只需要傳承好字符串語義即可,但是複雜的配置化可能是帶有條件表達式,代碼語法等。這是VSCode 官方插件的配置代碼,均是配置表達式。

圖片

本質上這些字符串最終是要解析爲布爾值,作爲開關去啓動 darken 或者 lighter 接口的。所以這裏需要付出一些代價去實現表達式解析器,和字符串轉義解釋引擎的。

"window.innerWidth" => window.innerWidth
"isWindows" => isWindows
"isMacintosh || window.innerWidth > 855" => true/false

這個過程中還需要實現校驗函數,如果識別到是非法的字符串則不允許解析,避免非法啓動配置接口。

"isMacintosh || window.innerWidth > 855" => 合法配置參數
"isMacintosh |&&| window.innerWidth > 855" => 非法配置參數
"isMacintosh \\// window.innerWidth > 855" => 非法配置參數

這種引擎的實現設計其實還有一種更暴力的解決方案,就是把讀取的配置字符串完全交給 eval 去處理。這當然可以很快去實現,但是還是上面說到的問題,這個操作如果接受了一段非法的字符串,就會很容易執行一些非法的腳本,絕對不是一個最優的方案。

eval("window.innerWidth > 855"); // true 或者 false

圖片

"darken": { "when": "isMacintosh || window.innerWidth > 855" },  
  "lighter": { "when": "isWindows || window.innerWidth <= 855" }  
}

下面介紹解決方案。先讀取 json 文件,定位到關鍵詞when: xxx (VSCode 目前只能暴露 when 對外匹配,騰訊文檔實際還沒開源的代碼是可以實現暴露更多的鍵值規則給使用方去匹配),不管是後端配置系統讀取還是前端配置系統讀取,解題思路均一致。

將讀取條件表達式字符串 "isMacintosh || window.innerWidth > 855",按照表達式的優先級拆解成幾個部分,放入下面的 contextMatchesRules 去匹配預埋的作用域返回布爾值,( VSCode 只做到按對應的鍵值去解析,騰訊文檔可以做到對整個 JSON 配置表的鍵值掃描解析)。

context.set("isMacNative", isMacintosh && !isWeb);  
context.set("isEdge", _userAgent.indexOf("Edg/") >= 0);  
context.set("isFirefox", _userAgent.indexOf("Firefox") >= 0);  
context.set("isChrome", _userAgent.indexOf("Chrome") >= 0);  
context.set("isSafari", _userAgent.indexOf("Safari") >= 0);  
context.set("isIPad", _userAgent.indexOf("iPad") >= 0);  
context.set(window.innerWidth, () => window.innerWidth);  
  
contextMatchesRules(context, ["isMacintosh"]); // true  
contextMatchesRules(context, ["isEdge"]); // false  
contextMatchesRules(context, ["window.innerWidth", ">=", "800"]); // true

VSCode 只是實現了很簡單的表達式解析就支撐起了上萬個插件的配置。因爲 VSCode 有完善的文檔,足夠大的體量去定製規範,對開發人員能做到了強約束。上面這些解析器其實在有約束的情況下,不會被亂增加規則,正常情況是夠用的。但是能用或者夠用不代表好用。開源項目和商業化項目對用戶側的約束和規範不會一樣。

圖片

03、騰訊文檔給 VSCode帶來了什麼

騰訊文檔把整個解析器實現完整化,並完善了 VSCode 的解析器,賦予其更多的配置功能,後續還會繼續推動並完善整個解析器,因爲目前 VSCode 這方面還不是最完整的。

我們的配置解析器支持下面所有的方法。

  • 支持變量

  • 支持常量:布爾值、數字、字符串

  • 支持正則

  • 支持全等 intypeof

  • 支持全等 =、不等 !

  • 支持與 &&、或 ||

  • 支持大於 >、小於 <、大於等於 >=、小於等於 **<=**的比較運算

  • 支持非 ! 等邏輯運算

我們接下來再具體講述下思路。使用下面這個複雜的例子來概括不同的情況:

"when": "canEdit == true || platform == pc && window.innerWidth >= 1080"

封裝一個 deserialize 方法去解析 "when": "canEdit == true || platform == pc && window.innerWidth >= 1080" 這段字符串,裏面涉及了 ==,&&,>= 三個表達式的解析。

使用 indexOfsplit 進行分詞,一般切割成三部分,key、type 和 value,特殊情況 canEdit == true,只要有 keyvalue 即可。根據優先級,先拆解 || 再拆解 && 這兩個表達式。

const _deserializeOrExpression: ContextKeyExpression | undefined = (  
  serialized: string,  
  strict: boolean  
) => {  
  // 先解 ||  
  let pieces = serialized.split("||");  
  // 再解 &&  
  return ContextKeyOrExpr.create(pieces.map((p) => _deserializeAndExpression(p, strict)));  
};  
  
const _deserializeAndExpression: ContextKeyExpression | undefinedn = (  
  serialized: string,  
  strict: boolean  
) => {  
  let pieces = serialized.split("&&");  
  return ContextKeyAndExpr.create(pieces.map((p) => _deserializeOne(p, strict)));  
};

再拆解其他表達式。這裏代碼解析的順序非常重要,比如有些時候需要增加 !== 這種表達式的解析,那麼一定注意先解析 == 再解析 !==,不然會拆解有誤,代碼的解析順序也決定表達式的執行優先級,由於大部分都是字符串比對,所以一般無需比對類型,特殊情況在使用大於和小於號的時候,如果出現 5 < '6' 也是判斷執行成功的。

const _deserializeOne: ContextKeyExpression = (  
  serializedOne: string,  
  strict: boolean  
) => {  
  serializedOne = serializedOne.trim();  
  
  if (serializedOne.indexOf("!=") >= 0) {  
    let pieces = serializedOne.split("!=");  
    return ContextKeyNotEqualsExpr.create(  
      pieces[0].trim(),  
      this._deserializeValue(pieces[1], strict)  
    );  
  }  
  
  if (serializedOne.indexOf("==") >= 0) {  
    let pieces = serializedOne.split("==");  
    return ContextKeyEqualsExpr.create(  
      pieces[0].trim(),  
      this._deserializeValue(pieces[1], strict)  
    );  
  }  
  
  if (serializedOne.indexOf("=~") >= 0) {  
    let pieces = serializedOne.split("=~");  
    return ContextKeyRegexExpr.create(  
      pieces[0].trim(),  
      this._deserializeRegexValue(pieces[1], strict)  
    );  
  }  
  
  if (serializedOne.indexOf(" in ") >= 0) {  
    let pieces = serializedOne.split(" in ");  
    return ContextKeyInExpr.create(pieces[0].trim(), pieces[1].trim());  
  }  
  
  if (serializedOne.indexOf(">=") >= 0) {  
    const pieces = serializedOne.split(">=");  
    return ContextKeyGreaterEqualsExpr.create(pieces[0].trim(), pieces[1].trim());  
  }  
  
  if (serializedOne.indexOf(">") >= 0) {  
    const pieces = serializedOne.split(">");  
    return ContextKeyGreaterExpr.create(pieces[0].trim(), pieces[1].trim());  
  }  
  
  if (serializedOne.indexOf("<=") >= 0) {  
    const pieces = serializedOne.split("<=");  
    return ContextKeySmallerEqualsExpr.create(pieces[0].trim(), pieces[1].trim());  
  }  
  
  if (serializedOne.indexOf("<") >= 0) {  
    const pieces = serializedOne.split("<");  
    return ContextKeySmallerExpr.create(pieces[0].trim(), pieces[1].trim());  
  }  
  
  if (/^\!\s*/.test(serializedOne)) {  
    return ContextKeyNotExpr.create(serializedOne.substr(1).trim());  
  }  
  
  return ContextKeyDefinedExpr.create(serializedOne);  
};

最終 when 會被解析爲下面的樹結構,type 是預先定義對錶達式的轉義,如下表所示:

這裏留了一個很有意思的 Defined 接口,它不屬於任何的表達式語法,後續可以這樣使用:

export class RawContextKey<T> extends ContextKeyDefinedExpr {  
  
  private readonly _defaultValue: T | undefined;  
  
  constructor(key: string, defaultValue: T | undefined) {  
    super(key);  
    this._defaultValue = defaultValue;  
  }  
  
  public toNegated(): ContextKeyExpression {  
    return ContextKeyExpr.not(this.key);  
  }  
  
  public isEqualTo(value: string): ContextKeyExpression {  
    return ContextKeyExpr.equals(this.key, value);  
  }  
  
  public notEqualsTo(value: string): ContextKeyExpression {  
    return ContextKeyExpr.notEquals(this.key, value);  
  }  
}  
  
const Extension = new RawContextKey<string>('resourceExtname', undefined);  
Extension.isEqualTo("abc");  
const ExtensionContext = new Maps();  
ExtensionContext.setValue("resourceExtname", "abc");  
console.log(contextMatchesRules(ExtensionContext, Extension.isEqualTo("abc")));

在任何地方創建一個 ExtensionContext 作用域,再建立鍵值對來使用 isEqualTo 進行等值比對。

條件表達式分詞規則用一張圖來表示,以下面這顆樹生成的思路爲例,遵循常用表達式的一些語法規範和優先級規則,優先切割 || 兩邊的表達式,然後遍歷兩邊的表達式往下去切割 && 表達式,切完所有的 ||&& 兩邊的表達式後,再處理子節點的 !===>= 等符號。

圖片

當切割完整個 when 配置項,將這個樹結構結合上面的 ContextKey-Type 映射表,轉換出下面的 JS 對象,上面存儲着 ContextKeyOrExprContextKeyAndExpr ContextKeyEqualsExprContextKeyGreaterOrEqualsExpr 這些重要的規則類,再將該 JS 對象存儲到 MenuRegistry 裏面,後面只需遍歷 MenuRegistry 就可以把裏面存着的 keyvalue ,根據 type 運算規則取出來進行比對,並返回布爾值。

when: {  
    ContextKeyOrExpr: {  
        expr: [{  
            ContextKeyDefinedExpr: {  
                key: "canEdit",  
                type: 2  
            }  
        }, {  
            ContextKeyAndExpr: {  
                expr: [{  
                    ContextKeyEqualsExpr: {  
                        key: "platform",  
                        type: 4,  
                        value: "pc",  
                    },  
                    ContextKeyGreaterOrEqualsExpr: {  
                        key: "window.innerWidth",  
                        type: 12,  
                        value: "1080",  
                    }  
                }],  
                type: 6  
            }  
        }],  
        type: 9  
    }  
}

上面提到,"window.innerWidth" ,canEdit 和 "platform" 這些是字符串,不是真正可用於判斷的值。這些 key 有些是運行時纔會得到值,有些是在某個作用域下才會得到值。所以需要將這些 key 進行轉化,借鑑Vscode 的做法,在 Vscode 中,它會將這部分邏輯交給一個叫 context 的對象進行處理,它提供兩個關鍵的接口 setValuegetValue 方法,簡單的實現如下。

export class Maps {  
  protected readonly _values = new Map<string, any>();  
  public get values() {  
    return this._values;  
  }  
  
  public getValue(key: string): any {  
    if (this._values.has(key)) {  
      let value = this._values.get(key);  
      // 執行獲取最新的值,並返回  
      if (typeof value == "function") {  
        value = value();  
      }  
      return value;  
    }  
  }  
  
  public removeValue(key: string): boolean {  
    if (key in this._values) {  
      this._values.delete(key);  
      return true;  
    }  
    return false;  
  }  
  
  public setValue(key: string, value: any) {  
    this._values.set(key, value);  
  }  
}

它本質是維護着一份 Map 對象,需要把 "window.innerWidth"canEdit 和 "platform" 這些值綁定進去,從而讓 key 可以轉化對應的變量或者常量。

這裏注意的是 getValue 裏面有一段代碼是判斷是否是函數,如果是函數則執行獲取最新的值。這個地方非常關鍵,因爲去收集 window.innerWidth 這些的值,很可能是實時變化的。需要在判斷的時候觸發這個回調獲取真正最新的值,保證條件表達式解析最終結果的正確性。當然如果是 platform 或者 isMacintosh 這些在運行的時候通常不會變,直接寫入即可,不需要每次都觸發回調來獲取最新的值。

const context = new Context();
context.setValue("platform", "pc");
context.setValue("window.innerWidth", () => window.innerWidth);
context.setValue(
  "canEdit",
  window.SpreadsheetApp.sheetStatus.rangesStatus.status.canEdit
);

當然有些常量或者全局的固定變量,需要事先預埋,比如字符串 "true" 對應就是 true,字符串 "false" 對應就是 false

context.setValue(JSON.stringify(true), true);
context.setValue(JSON.stringify(false), false);

如果要交給第三方配置,就需要提前在這裏規定好 key 值綁定的變量和常量,輸出一份配置文檔就可以讓第三方使用這些關鍵 key 來進行個性化配置。

圖片

那麼最後只要封裝上面例子用到的 contextMatchesRules 方法,先讀取 json 配置文件爲對象,遍歷出每一個 when,並關聯 context 最終得出一個布爾值,這個布爾值來之不易,生成的最終結果其實是一個帶布爾值的策略樹,這棵樹的前後最終節點的目的都是爲了求出布爾值,如果是服務端下發的動態配置,本質是 0 和 1 的策略樹即可。

圖片

圖片

實現一個強大的配置系統還能保證整體的質量和性能是很不容易的,上圖是實際項目中的一個改造例子,左邊的表達式收集會轉化成右邊表達式配置,左邊所有的 if 會到配置表裏面轉嫁給接入方或者可視化配置界面,之後每當變動配置表的信息,都可以配合作用域收集得到全信的策略樹來渲染視圖或者更新視圖。

04、總結

騰訊文檔團隊一路走來遇到很多問題、逐個擊破,最終才貢獻出這個方案。後續希望能輸出更多代碼回饋開源社區,也希望有更多志同道合的開發者們一起去探索和遨遊技術開發知識,最後也希望這篇文章能給到大家一些啓發 。公衆後回覆「VSCode」獲取源代碼。

以上是本次分享全部內容,歡迎大家在評論區分享交流。如果覺得內容有用,歡迎轉發~

-End-

原創作者|姚嘉隆

技術責編|姚嘉隆

最近微信改版啦,有粉絲反饋收不到小云的文章🥹。

請關注「騰訊雲開發者」並點亮星標

週一三晚8點 和小云一起漲(領)技(福)術(利)

開源無國界。開發者羣體對於創新和創造的熱愛,讓「更早更多地參與開源貢獻」成爲趨勢。

  • 你如何理解開源精神?怎麼看待當下的開源現狀?

  • 如果只能給其他開發者推薦一個開源項目,你會推薦什麼?

歡迎在公衆號評論區聊一聊你的看法。快來加騰小云的微信(微信號yun_assistant,統一處理時間9:00-18:00),在3月17日前將你的評論記錄截圖發送給小云,可領取騰訊雲「開發者春季限定紅包封面」一個,數量有限先到先得😄。我們還將選取點贊量最高的1位朋友,送出騰訊QQ公仔1個。3月22日中午12點開獎。快邀請你的開發者朋友們一起來參與吧!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章