作者:enoyao,騰訊工程師
在前幾天騰訊檔案 AlloyTeam 給 VSCode 合入了大概 400 行核心代碼,主要涉及到 VSCode 配置化的部分,增強其插件化能力,提供更多的匹配介面,整理部分代碼結構和補充功能單測,
由于騰訊檔案最近在完善我們的配置化系統,在完善的程序也探索了多種實作方案,也分析了很多產品的實作方式,如大名鼎鼎的 VSCode,我們也希望把我們積累經驗貢獻給開源社區,一起共同進步,
其中代碼的提交者為 AlloyTeam 的工程師 @Wscats ,而合入代碼的是微軟 VSCode 團隊現主要負責人之一 Alexdima,也是 Monaco Editor 負責人(VSCode 前身),也是同 Erich Gamma (VSCode 之父) 來自蘇黎世同一個團隊,特別感謝他和他團隊的支持,還幫我們挖坑的代碼寫了不少單測...
由于我們是給 VSCode 貢獻了這部分代碼,那我們就從 VSCode 本身開始聊起,我們給 VSCode 的配置化貢獻了什么?我相信大部分的開發者都使用過 VSCode,所以配置化應該不陌生,由于使用者眾多,任何編輯器其實都不能做到面面俱到去滿足所有的使用者,如果什么用戶的需求都要滿足,就需要把所有的功能都塞進去,這不但臃腫,還不好維護,
所以我們需要配置化去豐富和拓展,減輕編輯器本身的包袱,把部分內容移交給用戶/合作方去定制,就如我們可以在 VSCode 的設定面板選擇編輯器的顏色,更換它的主題背景,
也可以在快捷鍵面板里面系結或者解綁我們的快捷鍵,更換我們的字體大小和改變我們的懸浮資訊等,這些其實都離不開背后實作的一套配置化系統,
上面舉例的都是有默認的配置,可以通過面板去更改的,當然還有些隱藏的配置我們無需在面板改變也能實作配置,比如縮小 VSCode 的界面大小,某些功能就會自動隱藏,這種也是屬于配置化,
我們除了通過面板可視化操作,還可以通過插件來配置界面,VSCode 中插件的核心就是一個組態檔 package.json,里面擁有提供了配置點,只需按要求撰寫正確的配置點就可以改變 VSCode 的視圖狀態,里面最主要的欄位就是 contributes 欄位:
contributes.configuration:插件有哪些可供用戶配置的選項,提供的界面跟上面切背景顏色棉棒相似
contributes.configurationDefaults:覆寫 vscode 默認的一些編輯器配置
contributes.commands:向 vscode 的命令系統注冊一些可供用戶呼叫的命令
contributes.menus:擴展選單
...
這是更換編輯器各個位置顏色的配置引數,很簡單明了,
{
"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);
}
}
上面的只是列了下偽代碼,開發者想得很簡單,只提供純粹的 darken 和 lighter,不希望與頻繁的條件運算式耦合,所以可能會做判斷條件的前置化,那么后續開發者只需疊加裝飾器即可給用戶增加配置,并且可以動態保留一個裝飾器 @If(userRule) 作為組態檔的洞口,
然后提供官方配置檔案讓他們寫類似的 package.json 檔案填寫對應的引數,那么壓力就會轉嫁到使用者或者接入者身上,
這種寫法看似美好,但會出現很多致命情況,darken 和 lighter 在執行前已經被帶條件運算式給裝飾了,后面如果二次執行 darken 和 lighter 方法則不會再執行裝飾器中條件運算式的判斷,本質上這兩個函式的 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);
};
}
};
};
而正常情況下客戶端側 isLinux,isMacintosh 和 isWindows 是不會發生改變的,但是 window.innerWidth 在客戶端卻是有可能持續發生變化,所以一般情況下對待客戶端環境經常變化的值或者需要通過作用域判斷值,我不建議寫成上面裝飾器暴露介面的方案,但是如果這是一個比較固定的配置值,那么這種方案配合 webpack 的 DefinePlugin 會有意外的識訓,
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.darken 和 JSON.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 有完善的檔案,足夠大的體量去定制規范,對開發者能做到了強約束,
那說明上面這些決議器其實在有約束的情況下,不亂增加規則,正常情況都是夠用的,但是能用或者夠用不代表好用,開源專案和商業化專案對用戶側的約束和規范不可能一樣的,騰訊檔案基本把整個決議器實作完整了,并完善了 VSCode 的決議器,賦予其更多的配置功能,后續還會繼續推動并完善整個決議器,其實目前 VSCode 這方面還不是最完整的,
支持變數
支持常量:布林值、數字、字串
支持正則
支持全等
in和typeof支持全等
=、不等!支持與
&&、或||支持大于
>、小于<、大于等于>=、小于等于<=的比較運算支持非
!等邏輯運算
我們內部實作的的配置決議器支持上述所有的方法,這里再具體講述下思路:
我們使用下面這個再復雜的例子來概括不同的情況:
"when": "canEdit == true || platform == pc && window.innerWidth >= 1080"
我們可以封裝一個 deserialize 方法去決議 "when": "canEdit == true || platform == pc && window.innerWidth >= 1080" 這段字串,里面涉及了 ==,&&,>= 三個運算式的決議,使用 indexOf 和 split 進行分詞,一般切割成三部分,key、type 和 value,特殊情況 canEdit == true,只要有 key 和 value 即可,
根據優先級,先拆解 || 再拆解 && 這兩個運算式
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 物件,上面的存盤著 ContextKeyOrExpr,ContextKeyAndExpr,ContextKeyEqualsExpr 和 ContextKeyGreaterOrEqualsExpr 這些重要的規則類,將該 JS 物件存盤到 MenuRegistry 里面,后面只需遍歷 MenuRegistry 就可以把里面存著的 key 和 value 根據 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 的物件進行處理,它提供兩個關鍵的介面 setValue 和 getValue 方法,簡單的實作如下,
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 會收到配置表里面轉嫁給接入方或者可視化配置界面,以后每當變動配置表的資訊,就可以配合作用域收集得到全信的策略樹來渲染視圖或者更新視圖,
騰訊檔案團隊
歡迎更多志同道合的人加入我們騰訊檔案 AlloyTeam 團隊,一起跟騰訊檔案,AlloyTeam 和開源社區成長,介紹下我們團隊部分開源專案和成員,
AlloyTeam 官網
AlloyTeam 開源專案
AlloyTeam 創始人,HTML5 夢工場深圳負責人 TAT.Kinvix
Omi 核心開發者 Wscats
Hippy 核心開發者 XuQingKuang
《深入理解 Vue.js》美女作者 被刪
公眾號@前端控作者 Cooper
Hexo Even 主題作者 YueXun
總結
關于這方面的相關文章不多,一路走來跳了不少的坑,感謝團隊成員的支持,并讓這個方案落地,后續希望能貢獻更多代碼回饋開源社區,也希望有更多志同道合的人加入我們騰訊檔案 AlloyTeam 團隊,一起去探索和遨游,最后也希望這篇文章能給到你們一些啟發吧 ????
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/239165.html
標籤:AI
