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

深入 Vscode 源码(1)——设计模式(上)
gy1. 前言
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
了。
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 架构(并未罗列完全):
比如,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 |
|
一旦这么写,这个类就被打上了标记,VSCode 的 DI 框架就会在该类实例化时进行依赖收集,尝试把FileService
注入到类中,之后我们就可以在成员函数内通过this.fileService
直接访问到他了。也就是说,我们把依赖的管理权交给了 DI 框架,无需手动干预不同依赖之间的创建顺序、同一依赖的生命周期。
2.2.2 实现依赖注入的关键——instantiationService
上文我只是简单介绍了“依赖注入”在 VSCode 源码中的大致使用方法,下面详细讲解一下它的具体实现。
InstantiationService
实现了 DI 框架的核心功能,是 VSCode 的核心服务之一,负责管理大多数服务、以及一些非服务实例的实例化和注入。
之所以说是“大多数”服务,是因为还有很多与 Electron 环境强相关的基础服务,需要在 VSCode 的 NodeJS 主进程启动期间立即实例化,而此时 DI 框架(instantiationService
)还没有启动,所以只能通过最原始的new xxxService(args)
的方式来实例化,这些基础服务包括但不限于:
Product Service (
IProductService
) 产品信息服务,获取 VSCode 的版本号、名称、构建信息等Environment Main Service (
IEnvironmentMainService
) 环境服务,管理 VSCode 的工作路径、用户数据路径等等Logger Service (
ILoggerMainService
) 日志Log Service (
ILogService
) 也是日志File Service (
IFileService
) 文件系统的二次封装State Service (
IStateService
) 状态管理服务,存储全局状态,包括工作区备份、窗口高度、主题配置信息等等。这些状态会在 debounce 之后以 JSON 格式持久化落盘。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 方式扫描依赖树,依次按需创建或者复用所依赖的服务实例。
上图描绘了依赖树构建的具体过程,其中包含若干关键原则:
- 已经完成初始化的服务不会重复实例化,而是直接复用现有实例;对于尚未初始化(标记为
SyncDescriptor
)的对象,则将其纳入到依赖图中,持续迭代直至所有依赖解析完毕。通过这种方式,就可以实现单例模式。 - DI 系统严格禁止循环依赖的发生,会使用一个 limited counter 对递归深度进行评估,若出现 A 依赖于 B、B 依赖于 C、而 C 又反过来依赖于 A 的情况,则会抛
CyclicDependencyError
异常。这个问题相当于判断无向图中是否存在回环,除了使用 counter 实现之外,也可以使用 hashMap 来实现,时间为 O(v+e),但是相比 counter 需要额外的空间。 - 依赖可以被配置成“可选项”(通过检查
dependency.optional
属性),所以即使某依赖未注册,也不会产生 fail。 - 整个过程遵循“依赖就近、延迟执行”的懒加载原则,对于注册在 DI 框架中的依赖,只有在被用到时,才会触发其实例化过程。btw,“懒加载”这一性能优化理念其实被 VSCode 玩的很深入,已经被封装成了工具类
Lazy
,详情请见这篇文章:深入剖析 vscode 工具函数(五)lazy 模块
最后需要注意的是,instantiationService
本身只是一个实例化其他对象(不限于 Service)的工具,它仅仅保证依赖的单例性,并不保证创建出的实例的单例性。
2.3.3 without DI 框架
而当内置 DI 容器还没有启动时,VSCode 的单例模式是通过ServiceAccessor
来实现的,它提供了获取服务实例的全局访问点。VSCode 启动时,需要创建ServiceAccessor
实例,并通过它来获取(如果获取不到则自动创建)其他服务的实例。下面的代码展示了ServiceAccessor
的使用方法,get
方法可以获取服务的实例:
1 |
|
这个get
方法的实现比较简单,它只是从一个 Map 中获取服务的实例,如果没有找到,则创建一个新的实例并放入 Map 中,即下面的代码:
1 |
|
使用一个 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 组件若想支持右键菜单功能,需要采取以下三步:
- 在该组件构造函数的参数列表内,使用装饰器语法注入菜单服务依赖:
@IMenuService menuService: IMenuService
- 注册一个类型为
TouchEventType.Contextmenu
的事件监听器,用来监听右键点击事件 - 在事件回调函数中调用
contextMenuService.showContextMenu
方法来显示菜单
1 |
|
在调用contextMenuService.showContextMenu
方法时,我们需要传入菜单项的配置信息,其中就包括菜单列表(getActions
方法),以及菜单的锚点(挂载点)(getAnchor
方法)。对于菜单列表,我们可以这样创建:
1 |
|
明显可以看出菜单列表是由一系列 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 的三大角色就是:
Model:
Action[]
以及 caller 组件的本地状态充当了 Model 的角色。View:
Menu
组件和ContextView
组件负责视图部分,负责显示右键菜单、处理 UI 位置等。它们直接和 DOM 打交道,能够操作 DOM,亦能够监听 DOM 事件。Presenter:
ContextMenuService
接收来自应用其他部分的请求,并协调ContextMenuHandler
进行进一步处理。此外它还承担了菜单的显示和隐藏等等事件,能够触发相应的监听器。
补充一下,”触发相应的监听器”在此处通过“发布-订阅”模式(亦称“观察者模式”)实现。“发布-订阅”模式有两大角色:ContextMenuService
作为发布者,负责发布事件;而 caller 组件作为订阅者,负责订阅事件并执行相应的逻辑。这是一种典型的低耦合设计模式:
事件发布(Emit):
在ContextMenuService
类中,可以看到使用了Emitter
来创建事件:1
2
3
4
5private 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;事件触发(Publishing Events):
当右键菜单显示或隐藏时,会触发相应的事件:1
2
3
4
5
6
7
8
9
10
11showContextMenu(delegate: IContextMenuDelegate | IContextMenuMenuDelegate): void {
// ...
ModifierKeyEmitter.getInstance().resetKeyStatus();
this._onDidShowContextMenu.fire(); // 触发显示事件
}
// 在ContextMenuHandler中的hideContextView方法里,会调用delegate的onHide方法,进而触发隐藏事件
onHide: (didCancel) => {
delegate.onHide?.(didCancel);
this._onDidHideContextMenu.fire(); // 触发隐藏事件
}事件订阅(Subscribing to Events):
其他组件通过订阅这些事件来响应右键菜单的显示和隐藏:1
2
3
4
5
6
7contextMenuService.onDidShowContextMenu(() => {
// 右键菜单显示时执行的逻辑
});
contextMenuService.onDidHideContextMenu(() => {
// 右键菜单隐藏时执行的逻辑
});
3.3 从“搜索框”看 MVVM 模式
我们可以认为 MVVM 是 MVP 模式的升级版,它在 MVP 模式的基础上,使得 UI 组件与数据模型之间可以双向绑定,从而实现了 UI 的自动更新。
我们再来看另一个例子——搜索框(FindWidget
)的实现。
FindWidget
位于 VSCode 的 editor 贡献点中,具体路径为 src/vs/editor/contrib/find/browser/findWidget.ts
。它是一个覆盖在编辑器上方的浮动窗口,用于输入查找和替换的关键词,并提供相关的操作按钮。
注意不要混淆了FindWidget
与SearchWidget
,前者是 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 |
|
当 extension 或用户贡献新的快捷键时,_asCommandRule
方法会调用上述的 bindToCurrentPlatform
方法来确定哪个平台上的快捷键应该生效。这种方式使得我们可以很容易地为不同的平台添加新的快捷键规则,或者调整现有规则,而不会影响其他部分的代码逻辑。
每个快捷键规则实际上都是一个独立的对象(即策略),包含了如何响应特定按键输入的所有信息,我们可以动态的加载或卸载这些规则。通过加断点调试,我们可以在控制台看到一部分的策略: