深入 Vscode 源码(1)——设计模式(上)

1. 前言

VSCode 是一个开源的跨平台代码编辑器,支持多种编程语言,拥有强大的插件系统和活跃的社区。本文将介绍 VSCode 源代码中常用的设计模式,并通过实例来展示 VSCode 团队如何将这些设计模式付诸实践。

作为一名从初中就入坑的编程爱好者,每当我在使用 VSCode 时都会感到一种特别的情感联系——因为我不仅仅是在得心应手地使用一款优秀的工具,更是在体验一段由许多人共同努力所编织出来的故事。VSCode 从一个使用 js 编写的轻量级 editor 走到今天,使得笔者能够用 VSCode 来丝滑阅读 VSCode 自身的源码,这也算是一种很奇妙的体验吧。

1.1 写这篇文章的原因

作为一款基于 Electron 的大型代码编辑器,VSCode 的复杂度在一众 Electron 应用中可以说是首屈一指,因此我一直对 VSCode 有着强烈的探索欲望。在学习《设计模式》的过程中,我发现 VSCode 是一个很好的典范,这 prompted me to write this article. 我希望通过分享 VSCode 中常用的设计模式,帮助读者更好地理解设计模式的概念,并切身体会一款高度工程化、规范化、可复用化的软件是如何实现的。

网上已经有了一些关于 VSCode 代码解读的文章,但是这些文章并没有从设计模式的角度去切入。

另外,2019 年国内曾经刮起了一股研究 vscode 源码之风,到现在已经过去 5 年,网络上的很多文章已经过于陈旧了,其中的一些细节也和目前的事实不符。比如,某篇文章提到 vscode 源码中使用绝对路径 import 来引入 ts 模块,而如今的 vscode 其实已经使用相对路径 import 了。

vscode的模块相对路径引入

1.2 前置知识

本文默认读者已经对如下知识有一定的了解:

  • 熟悉 TypeScript 类型系统与装饰器,以及 OOP 编程
  • 熟悉前端工程化概念(如:构建工具、模块化与依赖管理、热更新、平台适配)
  • 了解 WebGL、WebWorker、WebAssembly、IndexedDB 等技术
  • 了解 NodeJS、Electron 中的底层技术与原理(如:C++ addon 注入、V8 引擎、事件循环)
  • 大致浏览过 VSCode 的一部分源代码,知悉其项目结构与组织形式

如果你目前还不了解 VSCode 的基本架构,可以先阅读一下这些文章,然后再回来阅读本文:

1.3 其他注意事项

本文所有内容基于 2024 年 9 月 21 日 23:10:12 的 VSCode 仓库主分支进行撰写。由于 VSCode 某些模块的更新迭代速度很快,本文所述的部分内容可能存在过时的风险,请读者注意。

2 依赖注入与单例模式

2.1 什么是 Service?为什么需要拆分为一个个 Service?

在软件架构中,Service(服务)是指封装了特定功能或业务逻辑的独立模块,它通过定义良好的接口向其他组件提供功能,从而促进代码的复用、解耦和维护。

软件工程的一个重要原则是“单一职责原则”(Single Responsibility Principle, SRP),属于 SOLID 原则之一。SRP 指出一个类应该只有一个引起它变化的原因,即只负责一项任务或功能。需要注意的是,SRP 并不代表着一个类只能有一份 implementation,而是指一个类应该只负责一项功能。

因此 VSCode 被设计为多个 Service,每个 Service 专注于处理既定逻辑。这样做的好处包括提高可维护性、增强复用性、单元测试更容易编写等等。下面这张图展示了 VSCode 的 Service 架构(并未罗列完全):

VSCode都有哪些Service

比如,FileService是 VSCode 中的核心服务之一,主要负责抽象操作系统级别的文件访问接口。它的存在让其他组件无需关心底层文件系统如何运作,只需调用FileService提供的方法即可完成复杂的文件操作。并且在原来的文件系统上进行了功能的横向扩展,有着更加定制化的文件监控、缓存、错误处理机制。此外,FileService还考虑到了跨平台兼容性问题,比如路径大小写敏感度的处理等细节。

当然,一个 Service 可以依赖其他 Service,比如FileLoggerService依赖FileService,但是不允许循环依赖,这会导致注入依赖时抛出异常。

2.2 依赖注入

2.2.1 依赖注入——概览

依赖注入(Dependency Injection, DI)是一种设计模式,用于实现控制反转(Inversion of Control, IoC),通过“注入”的方式将依赖对象注入到需要使用这个依赖的对象中,而不是通过构造函数或其他方式在对象创建时实例化依赖对象,以减少代码间的耦合,并使得依赖关系更加明确。

依赖注入示意图(伪代码):

我们回到 VSCode 的源码环境中。假设我自己编写了一个类,并且我想在这个类中使用 VSCode 内置的服务FileService,那么我只需要在该类的constructor中使用装饰器@IFileService来声明对于这个服务的依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class MyClass extends IDisposable {
constructor(
// 声明依赖
@IFileService private readonly fileService: IFileService
) {
// 完成组件的构造函数逻辑...,这里省略了
}
}

// 只有使用createInstance实例化,装饰器才会生效
const myClass = instantiationService.createInstance(MyClass) // 正确示范
// 不能直接用new关键字,错误示范:
const myClass = new MyClass() // 错误示范
// 如果非要使用new关键字,则需要手动传入依赖对象:(实际上vscode的某些服务就是这样创建的)
// 这其实是因为构造函数参数的装饰器语法是向下兼容的:
const myClass = new MyClass(fileService) // 也是正确的,相当于手动接管DI的控制权

一旦这么写,这个类就被打上了标记,VSCode 的 DI 框架就会在该类实例化时进行依赖收集,尝试把FileService注入到类中,之后我们就可以在成员函数内通过this.fileService直接访问到他了。也就是说,我们把依赖的管理权交给了 DI 框架,无需手动干预不同依赖之间的创建顺序、同一依赖的生命周期。

2.2.2 实现依赖注入的关键——instantiationService

上文我只是简单介绍了“依赖注入”在 VSCode 源码中的大致使用方法,下面详细讲解一下它的具体实现。

InstantiationService实现了 DI 框架的核心功能,是 VSCode 的核心服务之一,负责管理大多数服务、以及一些非服务实例的实例化和注入。

之所以说是“大多数”服务,是因为还有很多与 Electron 环境强相关的基础服务,需要在 VSCode 的 NodeJS 主进程启动期间立即实例化,而此时 DI 框架(instantiationService)还没有启动,所以只能通过最原始的new xxxService(args)的方式来实例化,这些基础服务包括但不限于:

  1. Product Service (IProductService) 产品信息服务,获取 VSCode 的版本号、名称、构建信息等

  2. Environment Main Service (IEnvironmentMainService) 环境服务,管理 VSCode 的工作路径、用户数据路径等等

  3. Logger Service (ILoggerMainService) 日志

  4. Log Service (ILogService) 也是日志

  5. File Service (IFileService) 文件系统的二次封装

  6. State Service (IStateService) 状态管理服务,存储全局状态,包括工作区备份、窗口高度、主题配置信息等等。这些状态会在 debounce 之后以 JSON 格式持久化落盘。

  7. User Data Profiles Main Service (IUserDataProfilesMainService)

这些服务仍然难以避免依赖其他模块,所以通过向构造函数直接传入依赖对象来实例化,如new EnvironmentMainService(this.resolveArgs(), productService);

随着 VSCode 启动,待上述这些核心服务创建完毕后,instantiationService也被创建,我们就可以使用它的createInstance方法来创建服务的实例了。

DI 框架在整个过程中,承担的职责如下:用户声明依赖->DI 框架收集依赖->DI 框架创建或复用依赖->DI 框架注入依赖->DI 框架继续管理依赖的生命周期。

2.3 单例模式

2.3.1 单例模式——概览

单例模式(Singleton Pattern)是一种创建型设计模式,保证一个类只有一个实例,并提供一个全局访问点。在 VSCode 中,上面提到的很多服务就是采用了单例模式,即:所有依赖于这些服务的组件都共享同一个实例。

2.3.2 with DI 框架

当 DI 框架激活后,每当调用instantiationService.createInstance()时,由于装饰器提前为被实例化的类记录了依赖,我们就可以据此收集依赖,然后以 DFS 方式扫描依赖树,依次按需创建或者复用所依赖的服务实例。

上图描绘了依赖树构建的具体过程,其中包含若干关键原则:

  1. 已经完成初始化的服务不会重复实例化,而是直接复用现有实例;对于尚未初始化(标记为SyncDescriptor)的对象,则将其纳入到依赖图中,持续迭代直至所有依赖解析完毕。通过这种方式,就可以实现单例模式。
  2. DI 系统严格禁止循环依赖的发生,会使用一个 limited counter 对递归深度进行评估,若出现 A 依赖于 B、B 依赖于 C、而 C 又反过来依赖于 A 的情况,则会抛CyclicDependencyError异常。这个问题相当于判断无向图中是否存在回环,除了使用 counter 实现之外,也可以使用 hashMap 来实现,时间为 O(v+e),但是相比 counter 需要额外的空间。
  3. 依赖可以被配置成“可选项”(通过检查dependency.optional属性),所以即使某依赖未注册,也不会产生 fail。
  4. 整个过程遵循“依赖就近、延迟执行”的懒加载原则,对于注册在 DI 框架中的依赖,只有在被用到时,才会触发其实例化过程。btw,“懒加载”这一性能优化理念其实被 VSCode 玩的很深入,已经被封装成了工具类Lazy,详情请见这篇文章:深入剖析 vscode 工具函数(五)lazy 模块

最后需要注意的是,instantiationService本身只是一个实例化其他对象(不限于 Service)的工具,它仅仅保证依赖的单例性,并不保证创建出的实例的单例性。

2.3.3 without DI 框架

而当内置 DI 容器还没有启动时,VSCode 的单例模式是通过ServiceAccessor来实现的,它提供了获取服务实例的全局访问点。VSCode 启动时,需要创建ServiceAccessor实例,并通过它来获取(如果获取不到则自动创建)其他服务的实例。下面的代码展示了ServiceAccessor的使用方法,get方法可以获取服务的实例:

1
2
3
4
5
// src/vs/code/electron-main/main.ts
const logService = accessor.get(ILogService);
const lifecycleMainService = accessor.get(ILifecycleMainService);
const fileService = accessor.get(IFileService);
const loggerService = accessor.get(ILoggerService);

这个get方法的实现比较简单,它只是从一个 Map 中获取服务的实例,如果没有找到,则创建一个新的实例并放入 Map 中,即下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
// src/vs/platform/instantiation/common/instantiationService.ts
protected _getOrCreateServiceInstance<T>(id: ServiceIdentifier<T>, _trace: Trace): T {
// 尝试获取服务的实例
const thing = this._getServiceInstanceOrDescriptor(id);
if (thing instanceof SyncDescriptor) {
// 如果是SyncDescriptor,则进行依赖实例化,并放入Map缓存中
return this._safeCreateAndCacheServiceInstance(id, thing, _trace.branch(id, true));
} else {
// 如果不是SyncDescriptor,说明实例已经创建过了,则直接返回实例
_trace.branch(id, false);
return thing;
}
}

使用一个 Map 来保证了服务的全局唯一性,这样就很好的实现了单例模式。如果想了解更详细的过程,可以参考下面的序列图,展示了当在Main::startup中调用accessor.get(ILogService)时,内部是如何通过 InstantiationService 来获取并返回相应的 Service 实例的:

3 UI 设计模式之 MVP 与 MVVM

3.1 Overview

MVC、MVP、MVVM 是三种常见的 UI 设计模式,关于他们的概念和定义,网上已经有很多文章介绍了,因此这里不再赘述,我们直接进入主题。

VSCode 的 wiki 内有一篇官方文章介绍了 MVVM 模式在 VSCode 中的使用,但是内容已经过时,文中的代码也与目前最新的 repo 有很大出入。链接在这里:https://github.com/microsoft/vscode/wiki/%5BWIP%5D-Code-Editor-Design-Doc

3.2 从“右键菜单”看 MVP 模式

3.2.1 我想拥有右键菜单!

我们从一个最为日常的使用场景——右键菜单引入。

VSCode 几乎所有 UI 组件都能够唤出右键菜单,这个右键菜单就是 MVP 模式的一个典型体现:

从顶部标签栏呼出的右键菜单

从开发者角度看,任意一个 UI 组件若想支持右键菜单功能,需要采取以下三步:

  1. 在该组件构造函数的参数列表内,使用装饰器语法注入菜单服务依赖:@IMenuService menuService: IMenuService
  2. 注册一个类型为TouchEventType.Contextmenu的事件监听器,用来监听右键点击事件
  3. 在事件回调函数中调用contextMenuService.showContextMenu方法来显示菜单
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// src/vs/workbench/browser/parts/views/viewPaneContainer.ts
// 注册右键菜单事件监听器
// 在VSCode的代码环境中,addDisposableListener是addEventListener的二次封装,提供自动的生命周期管理
this._register(addDisposableListener(parent, TouchEventType.Contextmenu, (e: MouseEvent) => this.showContextMenu(new StandardMouseEvent(getWindow(parent), e))));

private showContextMenu(event: StandardMouseEvent): void {
for (const paneItem of this.paneItems) {
// Do not show context menu if target is coming from inside pane views
if (isAncestor(event.target, paneItem.pane.element)) {
return;
}
}

event.stopPropagation();
event.preventDefault();
// 调用服务,显示菜单
this.contextMenuService.showContextMenu({
getAnchor: () => event,
getActions: () => this.menuActions?.getContextMenuActions() ?? []
});
}

在调用contextMenuService.showContextMenu方法时,我们需要传入菜单项的配置信息,其中就包括菜单列表(getActions方法),以及菜单的锚点(挂载点)(getAnchor方法)。对于菜单列表,我们可以这样创建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// src\vs\editor\browser\widget\diffEditor\components\diffEditorViewZones\inlineDiffDeletedCodeMargin.ts
const actions: Action[] = [];
// 依次创建菜单列表item
actions.push(
new Action(
"diff.inline.revertChange",
localize("diff.inline.revertChange.label", "Revert this change"),
undefined,
true,
async () => {
// 点击菜单时触发的回调函数
this._editor.revert(this._diff);
}
)
);
actions.push(/* ... */); // 类似项,省略
actions.push(/* ... */); // 类似项,省略
actions.push(/* ... */); // 类似项,省略

return actions;

明显可以看出菜单列表是由一系列 Action 对象组成的,每个 Action 对象代表一个菜单项,包含菜单项的名称、显示文本、是否可用、是否默认选中、以及点击菜单项时触发的回调函数(可以异步,可以有副作用),这个回调函数可以用来执行实际的操作,比如this._editor.revert(this._diff)就是一个例子。

还有其他的一些细节,在这里省略不表。完成这些步骤之后,不出意外的话,我们就拥有了一个支持右键菜单的 UI 组件。

总结一下,当某个 UI 组件在初始化时,通过 addEventListener 注册右键点击事件的监听器;当该监听器被用户右键操作触发时,该监听器马上通过“右键菜单服务”提交了右键菜单的 handler 函数(用户选中菜单项时触发),这个 handler 函数是在该 UI 组件内创建的,持有该 UI 组件的作用域闭包,可以发挥副作用,进而更改该 UI 组件的本地状态。值得一提的是,这个回调函数的使用遵循了命令模式,使得 caller 与 receiver 解耦。

3.2.2 右键菜单的实现原理

一张图说明全部:

图中黑色箭头表示数据向下层级传递,红色箭头表示数据向上层级传递。图中 Model 层的“caller 组件”指的是触发右键菜单的组件(亦即使用contextMenuService这个服务的组件)关于图中的 View 层,受限于图片的展示面积,我把 View 层中的一些类的说明以文字形式放在下面:

  • ContextView可以看做一个智能的浮动容器,能够根据窗口大小和锚点位置动态调整自己的位置,确保不会被屏幕边缘截断,并且尽可能地靠近触发它的 UI 元素。支持焦点管理。

  • Menu组件其实就是我们用户看到的菜单本体,它只负责菜单的渲染。而菜单的位置和焦点管理则交由ContextView

可以看出 Model 与 View 之间是解耦的,它们被中间的 Presenter 层隔离开来,相互不可见,这样的实现方式符合 MVP 模式,我们对应到 MVP 的三大角色就是:

  • ModelAction[]以及 caller 组件的本地状态充当了 Model 的角色。

  • ViewMenu组件和ContextView组件负责视图部分,负责显示右键菜单、处理 UI 位置等。它们直接和 DOM 打交道,能够操作 DOM,亦能够监听 DOM 事件。

  • PresenterContextMenuService接收来自应用其他部分的请求,并协调ContextMenuHandler进行进一步处理。此外它还承担了菜单的显示和隐藏等等事件,能够触发相应的监听器。

补充一下,”触发相应的监听器”在此处通过“发布-订阅”模式(亦称“观察者模式”)实现。“发布-订阅”模式有两大角色:ContextMenuService作为发布者,负责发布事件;而 caller 组件作为订阅者,负责订阅事件并执行相应的逻辑。这是一种典型的低耦合设计模式:

  1. 事件发布(Emit):
    ContextMenuService类中,可以看到使用了Emitter来创建事件:

    1
    2
    3
    4
    5
    private readonly _onDidShowContextMenu = this._store.add(new Emitter<void>());
    readonly onDidShowContextMenu = this._onDidShowContextMenu.event;

    private readonly _onDidHideContextMenu = this._store.add(new Emitter<void>());
    readonly onDidHideContextMenu = this._onDidHideContextMenu.event;
  2. 事件触发(Publishing Events):
    当右键菜单显示或隐藏时,会触发相应的事件:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    showContextMenu(delegate: IContextMenuDelegate | IContextMenuMenuDelegate): void {
    // ...
    ModifierKeyEmitter.getInstance().resetKeyStatus();
    this._onDidShowContextMenu.fire(); // 触发显示事件
    }

    // 在ContextMenuHandler中的hideContextView方法里,会调用delegate的onHide方法,进而触发隐藏事件
    onHide: (didCancel) => {
    delegate.onHide?.(didCancel);
    this._onDidHideContextMenu.fire(); // 触发隐藏事件
    }
  3. 事件订阅(Subscribing to Events):
    其他组件通过订阅这些事件来响应右键菜单的显示和隐藏:

    1
    2
    3
    4
    5
    6
    7
    contextMenuService.onDidShowContextMenu(() => {
    // 右键菜单显示时执行的逻辑
    });

    contextMenuService.onDidHideContextMenu(() => {
    // 右键菜单隐藏时执行的逻辑
    });

3.3 从“搜索框”看 MVVM 模式

我们可以认为 MVVM 是 MVP 模式的升级版,它在 MVP 模式的基础上,使得 UI 组件与数据模型之间可以双向绑定,从而实现了 UI 的自动更新。

我们再来看另一个例子——搜索框(FindWidget)的实现。

FindWidget 位于 VSCode 的 editor 贡献点中,具体路径为 src/vs/editor/contrib/find/browser/findWidget.ts。它是一个覆盖在编辑器上方的浮动窗口,用于输入查找和替换的关键词,并提供相关的操作按钮。

搜索框

注意不要混淆了FindWidgetSearchWidget,前者是 Monaco Editor 的贡献点,后者是 Workbench 的贡献点(侧边栏搜索页面)。

4 策略模式

策略模式是一种行为型设计模式,开发者需要事先定义一系列算法,然后把每个算法封装起来,使它们可以互换使用。其主要优点在于能够低成本切换不同的算法实现,而无需显式修改代码,可提高系统的灵活性和扩展性。

4.1 VSCode 中的策略模式——WorkbenchKeybindingService

由于 VSCode 是跨平台的,我们不得不考虑平台之间的细微差异。

比如同样都是命令按键,在 macOS 是Command键,而在 Windows 和 Linux 上是Ctrl键。除此之外还有很多按键上的差异需要开发者手动抹平。

因此KeybindingService(具体实现为 WorkbenchKeybindingService)是 VSCode 中一个非常重要的部分,负责负责解析用户的键盘输入,并将其转换成命令调用。它会根据当前的操作系统、用户设置、extension 提供的快捷键规则等来决定哪一个快捷键组合应该触发哪一个命令。

VSCode 原本是提供了一组默认的快捷键配置的,但同时也允许用户通过keybindings.json文件来自定义自己的快捷键。所以KeybindingService需要确保既能正确加载默认值又能 apply 用户的更改。(其监听用户对keybindings.json文件所做的修改,而且用户定义的快捷键具有更高的优先级,可以覆盖默认或 extension 提供的快捷键;当 extension 被卸载时,自动清理其贡献的所有快捷键规则。)

WorkbenchKeybindingService类里,我们可以看到策略模式被用来处理不同操作系统的快捷键配置。为了应对这种平台的多样性,WorkbenchKeybindingService提供了一个静态方法bindToCurrentPlatform来根据当前的操作系统选择适当的快捷键组合。该方法接收四个参数(通用、macOS、Linux 和 Windows 版本),然后返回适用于当前平台的那个版本。

1
2
3
4
5
6
7
8
9
10
// src/vs/workbench/services/keybinding/browser/keybindingService.ts
private static bindToCurrentPlatform(key: string | undefined, mac: string | undefined, linux: string | undefined, win: string | undefined): string | undefined {
if (OS === OperatingSystem.Windows && win) {
return win;
} else if (OS === OperatingSystem.Macintosh && mac) {
return mac;
} else {
return linux || key; // 默认回退到 Linux 或者通用版本
}
}

当 extension 或用户贡献新的快捷键时,_asCommandRule 方法会调用上述的 bindToCurrentPlatform 方法来确定哪个平台上的快捷键应该生效。这种方式使得我们可以很容易地为不同的平台添加新的快捷键规则,或者调整现有规则,而不会影响其他部分的代码逻辑。

每个快捷键规则实际上都是一个独立的对象(即策略),包含了如何响应特定按键输入的所有信息,我们可以动态的加载或卸载这些规则。通过加断点调试,我们可以在控制台看到一部分的策略: