作者: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 文件填写对应的参数,那么压力就会转嫁到使用者或者接入者身上。

这种写法看似美好,但会出现很多致命情况,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 有完善的文档,足够大的体量去定制规范,对开发者能做到了强约束。

那说明上面这些解析器其实在有约束的情况下,不乱增加规则,正常情况都是够用的,但是能用或者够用不代表好用开源项目和商业化项目对用户侧的约束和规范不可能一样的,腾讯文档基本把整个解析器实现完整了,并完善了 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,只要有 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 对象,上面的存储着 ContextKeyOrExprContextKeyAndExprContextKeyEqualsExprContextKeyGreaterOrEqualsExpr 这些重要的规则类,将该 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 的对象进行处理,它提供两个关键的接口 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 会收到配置表里面转嫁给接入方或者可视化配置界面,以后每当变动配置表的信息,就可以配合作用域收集得到全信的策略树来渲染视图或者更新视图。

腾讯文档团队

欢迎更多志同道合的人加入我们腾讯文档 AlloyTeam 团队,一起跟腾讯文档,AlloyTeam 和开源社区成长,介绍下我们团队部分开源项目和成员。

总结

关于这方面的相关文章不多,一路走来跳了不少的坑,感谢团队成员的支持,并让这个方案落地,后续希望能贡献更多代码回馈开源社区,也希望有更多志同道合的人加入我们腾讯文档 AlloyTeam 团队,一起去探索和遨游,最后也希望这篇文章能给到你们一些启发吧 😁

文章来源于腾讯云开发者社区,点击查看原文