共计 13458 个字符,预计需要花费 34 分钟才能阅读完成。
👉腾小云导读
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);
}
}
下面只是列出伪代码,并非很简略。只提供纯正的 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 有欠缺的文档,足够大的体量去定制标准,对开发人员能做到了强束缚。下面这些解析器其实在有束缚的状况下,不会被乱减少规定,失常状况是够用的。然而能用或者够用不代表好用。开源我的项目和商业化我的项目对用户侧的束缚和标准不会一样。
03、腾讯文档给 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 会到配置表外面转嫁给接入方或者可视化配置界面,之后每当变动配置表的信息,都能够配合作用域收集失去全信的策略树来渲染视图或者更新视图。
04、总结
腾讯文档团队一路走来遇到很多问题、一一击破,最终才奉献出这个计划。后续心愿能输入更多代码回馈开源社区,也心愿有更多气味相投的开发者们一起去摸索和漫游技术开发常识,最初也心愿这篇文章能给到大家一些启发。公众后回复「VSCode」获取源代码。
以上是本次分享全部内容,欢送大家在评论区分享交换。如果感觉内容有用,欢送转发~
-End-
原创作者|姚嘉隆
技术责编|姚嘉隆
最近微信改版啦,有粉丝反馈收不到小云的文章🥹。
请关注「腾讯云开发者」并 点亮星标,
周一三晚 8 点 和小云一起 涨(领)技 (福) 术(利)!
开源无国界。开发者群体对于翻新和发明的酷爱,让「更早更多地参加开源奉献」成为趋势。
- 你如何了解开源精力?怎么对待当下的开源现状?
- 如果只能给其余开发者举荐一个开源我的项目,你会举荐什么?
欢送在 公众号 评论区聊一聊你的认识。快来加腾小云的微信(微信号 yun\_assistant,对立解决工夫 9:00-18:00),在 3 月 17 日前将你的评论记录截图发送给小云,可支付腾讯云「开发者秋季限定红包封面」一个,数量无限先到先得😄。咱们还将选取点赞量最高的 1 位敌人,送出腾讯 QQ 公仔 1 个。3 月 22 日中午 12 点开奖。快邀请你的开发者敌人们一起来参加吧!