1. 前言
上一节介绍了 VSCode 启动流程,这一节主要介绍 VSCode 的依赖注入架构以及组件实现。
2. 依赖注入
2.1 什么是依赖注入
这部分主要讲解 VSCode DI 的实现,在开始之前,需要介绍一下什么是依赖注入。
前面讲到,VSCode 里面有很多服务,这些服务是以 class 的形式声明的。那服务之间也可能会互相调用,比如我有个 EditorService,他是负责编辑器的服务类,需要调用 FileService,用来做文件的存取。
如果服务类比较多,就会出现 A 依赖 B,B 依赖 C,C 依赖 D 和 E 等情况,我们就需要先将依赖的服务类实例化,当做参数传给依赖方。
javascript
class ServiceA {
constructor(serviceB: ServiceB) {}
}
class Service B {
constructor(serviceC: ServiceC) {}
}
class ServiceC {
constructor(serviceD: ServiceD, serviceE: ServiceE) {}
}
const serviceD = new ServiceD();
const serviceE = new ServiceE();
const serviceC = new ServiceC(serviceD, serviceE);
const serviceB = new ServiceB(serviceC);
随着项目越来越复杂,Service 和 Manager 类也会越来越多,手动管理这些模块之间的依赖和实例化顺序心智负担会变得很重。
为了解决对象间耦合度过高的问题,软件专家 Michael Mattson提出了 IOC 理论,用来实现对象之间的"解耦"。
控制反转(英语:Inversion of Control,缩写为IoC),是面向对象编程中的一种设计原则,可以用来减低计算机代码之间的耦合度。其中最常见的方式叫做依赖注入(Dependency Injection,简称DI)
采用依赖注入技术之后,ServiceA 的代码只需要定义一个 private 的 ServiceB 对象,不需要直接 new 来获得这个对象,而是通过相关的容器控制程序来将 ServiceB 对象在外部 new 出来并注入到 ServiceA 类里的引用中。
less
class ServiceA {
constructor(@IServiceB private _serviceB: ServiceB) {}
}
class Service B {
constructor(@IServiceC serviceC: ServiceC) {}
}
2.2 概念介绍
在 VSCode 里面存在很多概念,Registry、Service、Contribution、Model 等等,下面会进行一一介绍。
2.3 Contribution
Contribution 一般是业务模块,它作为最上层的业务模块,一般不会被其他模块依赖,在 VSCode 里面一个 Contribution 就对应一个模块,Contribution 内部还会包含 UI 模块、Model 模块等。
举个例子,我们在编辑器里面常用的查找替换,它就是一个 Contribution。
2.4 Registry
Registry 一般是业务模块的集合,随着项目越来越复杂,Contribution 也会越来越多。
比如左侧菜单包括 Explore、Search、debug、Settings 等等,这里的每个模块都是一个 Contribution,Registry 就是将这些 Contribution 归类的一个集合。
2.5 Service
Service 一般是基础服务,提供一系列的基础能力,可以被多个 Contribution 共享。
一句话:Service 用于解决某个领域下的问题。 举几个例子:
- ReportService,上报时都用它,其他的不用操心。
- StorageService,存储时都用它,其他的不用操心。
- AccountService,负责账号等状态维护,有需要都找它。
我们写一个 Service 的时候,需要写哪些东西呢?下面是一个 Service 的例子:
typescript
// 先实现一个接口
interface ITestService {
readonly _serviceBrand: undefined;
test: () => void;
}
// 再创建一个 service id
const ITestService = createDecorator<ITestService>('test-service');
// 再创建 Service
class TestService implements ITestService {
public readonly _serviceBrand: undefined;
test() {
// ...
}
}
2.5.1 interface
为什么要实现一个接口呢?我们希望 Service 之间可以不互相依赖具体的实现,不产生任何耦合,Service 应该只依赖其接口,做到面向接口编程。
以负责用户账号的 AccountService 为例,如果一个产品支持谷歌登录、Github 登录等等,这些登录的实现并不一样。
对于依赖用户登录信息的组件来说,应该依赖的是什么呢?GoogleAccountService?GithubAccoutService?我不想关心到底是什么账号,可能只是想调用 hasLogin 判断是否登录,我要依赖的应该只是 interface,不需要关心到底是什么账号体系。
在 VSCode 里面也有类似的例子,在 Electron 和 Web 环境注册的 Service 实现可能不一样,但 interface 是一样的。
2.5.2 createDecorator
我们先思考一个问题,createDecorator 做了哪些事情?用法是什么呢?假设有个 Test2Service 依赖了 TestService。
less
class Test2Service {
constructor(
@ITestService private readonly _testService: ITestService,
) {
}
}
为什么我们不需要将 testService 实例化后传给 test2Service 呢?他们是怎么建立关联关系的呢?带着疑问看一下 createDecorator 的实现。
typescript
function setServiceDependency(id: ServiceIdentifier<any>, ctor: any, index: number): void {
if (ctor[DI_TARGET] === ctor) {
ctor[DI_DEPENDENCIES].push({ id, index });
} else {
ctor[DI_DEPENDENCIES] = [{ id, index }];
ctor[DI_TARGET] = ctor;
}
}
function createDecorator<T>(serviceId: string): ServiceIdentifier<T> {
if (serviceIds.has(serviceId)) {
return serviceIds.get(serviceId)!;
}
const id = function (target: any, key: string, index: number): any {
if (arguments.length !== 3) {
throw new Error('@IServiceName-decorator can only be used to decorate a parameter');
}
setServiceDependency(id, target, index);
} as any;
id.toString = () => serviceId;
serviceIds.set(serviceId, id);
return id;
}
createDecorator 主要就是创建了一个装饰器,这个装饰器会调用 setServiceDependency,将 serviceId 设置到被装饰类的 DI_DEPENDENCIES 属性上面。
这样上面的例子中,我们就可以通过 @ITestService 建立 ITestService 和 Test2Service 的关联关系,指定 Test2Service 依赖了 ITestService。
2.5.3 InstantiationService
VSCode 里面 Service 有两种方式可以访问到:
- 通过 DI 的方式,在构造函数里面可以引入
- 通过 instantiationService.invokeFunction 的形式拿到 accessors 进行访问
第一种比较容易理解,就是实例化的时候将它依赖的 Service 实例自动传入。
那么先来分析第二种方式,在建立了依赖关系之后,究竟 Service 是怎么实例化,并且将依赖项自动传入的?我们来初始化一下 Service:
ini
const services = new ServiceCollection();
// 注册 Service
services.set(ITestService, TestService);
services.set(ITest2Service, new SyncDescriptor(Test2Service, []));
// 实例化容器 Service
const instantiationService = new InstantiationService(services);
// 获取 testService 实例
const testService = instantiationService.invokeFunction(accessors => accessors.get(ITestService));
// 实例化一个 testManager
const testManager = instantiationService.createInstance(TestManager);
对于 ServiceCollection,可以简单理解为使用一个 Map 将 ITestService 和 TestService 做了一次关联,后续可以通过 ITestService 查询到 TestService 实例。
最终将存有关联信息的这个 Map 传给了 InstantiationService,这个 InstantiationService 是负责实例化的容器 Service,它提供了 invokeFunction 和 createChild、createInstance 方法。
InstantiationService 在实例化的时候,将传入 services 挂载到 this 上,并且会建立 IInstantiationService 到自身实例的关系。
2.5.4 invokeFunction
Service 只有在被访问的时候才会实例化,也就是在 invokeFunction 的 accessors.get 的时候开始实例化。
如果已经实例化过,就直接返回实例,否则就会创建一个实例。
javascript
class InstantiationService {
constructor(
services: ServiceCollection = new ServiceCollection(),
parent?: InstantiationService,
) {
this._services = services;
}
invokeFunction(fn) {
const accessor: ServicesAccessor = {
const _trace = Trace.traceInvocation(this._enableTracing, fn);
let _done = false;
try {
const accessor: ServicesAccessor = {
get: <T>(id: ServiceIdentifier<T>) => {
if (_done) {
throw new Error('service accessor is only valid during the invocation of its target method');
}
const result = this._getOrCreateServiceInstance(id, _trace);
if (!result) {
this._handleError({
errorType: InstantiationErrorType.UnknownDependency,
issuer: 'service-accessor',
dependencyId: `${id}`,
message: `[invokeFunction] unknown service '${id}'`,
});
}
return result;
},
};
return fn(accessor, ...args);
} finally {
_done = true;
_trace.stop();
}
};
return fn(accessor, ...args);
}
}
PS:在 invokeFunction 中如果存在异步,那就需要在异步之后新开一个 invokeFunction 来访问 Service,不然访问就会报错。
_getOrCreateServiceInstance 会根据 serviceId 来获取到对应的 Service 类,如果在当前 instantiationService 的 _services 上找不到,那么就从他的 parent 上继续查找。
这里抛出一个问题,instantiationService 的 parent 是什么呢?一般来说还是一个 instantiationService,项目中可以不只有一个容器服务,容器服务内部还可以再创建容器服务。
以飞书文档为例,在全局创建 instantiationService,用于承载日志服务、上报服务等等。
在 instantiationService 下面还可以再创建一个 instantiationService,用于存放草稿相关的服务。
上一篇也有讲过容器化的概念。比如飞书文档中从文档 A 需要无刷新切换到文档 B。对于日志服务、配置服务这类基础服务是不需要销毁的,可以继续复用。
但是原本在文档 A 里面初始化的模块、快捷键、绑定的事件都需要销毁,在文档 B 中重新创建。
如果代码实现的没有那么安全,很容易就有一些模块的副作用没有被清理干净,就会影响到文档 B 的正常使用。
kotlin
// 创建一个服务集合
const collection = new ServiceCollection();
// 注册服务进去
this._registerCommonService(ctx, collection);
// 基于全局容器服务创建一个属于编辑器的容器服务,将 collection 里面的 service 都注册进去
this._editorContainerService = this._containerService.createChild(collection);
所以如果是通过 editorContainerService 来查找 environmentService,直接找不到,它就会从 parent 上面找。
如果从 _services 找到了,还需要判断是不是一个 SyncDescriptor,如果不是 SyncDescriptor,说明已经被实例化过了,就直接返回。如果是,那就走实例化的逻辑。
实例化的过程在 _createAndCacheServiceInstance 中,他会先创建一个依赖图,将当前的 serviceId 和 syncDescriptor 信息当做图的一个节点存入。
arduino
for (const dependency of getServiceDependencies(item.desc.ctor)) {
const instanceOrDesc = this._getServiceInstanceOrDescriptor(dependency.id);
if (instanceOrDesc instanceof SyncDescriptor) {
const d = {
id: dependency.id,
desc: instanceOrDesc,
_trace: item._trace.branch(dependency.id, true),
};
// 当依赖没有初始化为实例,仍然是描述符式,添加到临时依赖图
// 创建从依赖 service 到当前 service 的一条边
graph.insertEdge(item, d);
stack.push(d);
}
}
接着会从 graph 里面获取叶子节点,如果没有叶子节点,但 graph 又不为空,说明发生了循环依赖,会抛出错误。
遍历叶子节点,从叶子节点开始调用 _createServiceInstanceWithOwner 进行实例化,因为叶子节点一定是不会再依赖其他 Service 的。
less
class Service4 {
constructor(
@IService1 private readonly _service1: IService1,
@IService2 private readonly _service1: IService2,
) {}
}
class Service5 {
constructor(
@IService3 private readonly _service3: IService3,
@IService2 private readonly _service1: IService2,
) {}
}
class Service6 {
constructor(
@IService4 private readonly _service4: IService4,
@IService5 private readonly _service5: IService5,
) {}
}
如果注册的时候传入 supportsDelayedInstantiation,就会进行延迟初始化,延迟初始化会返回一个 Proxy,只有触发了 get,才会对 Service 进行实例化,可以减轻首屏的负担。
如果没有延迟初始化,就会调用 _createInstance 进行创建。实例化的时候会将通过 new SyncDescriptor 创建的参数带进去。
如果不是叶子节点,那就会将依赖的 Service 实例 + SyncDescriptor 的参数一起传进去。
至此,Service 的实例化就完成了。
2.5.5 createInstance
除了 Service,VSCode 里面还存在很多业务模块,为了方便理解,我们可以统一称之为 Manager。这些 Manager 有的是用 createInstance 实例化,有的是用 new 实例化。
用 createInstance 实例化的类拥有 DI 的能力,也可以通过依赖注入的方式获取依赖。和上述的 Service 创建最终走了相同的流程,这里不过多阐述。
还有个问题,我们在写 Service 的时候为什么要写一个 _serviceBrand 呢?这个到底有什么用?那你会不会好奇,为什么我们使用 DI 注入构造参数,TS 却不会报错呢?
看一下 createInstance 方法的签名就理解了,GetLeadingNonServiceArgs 会从构造函数参数类型里面剔除带 _serviceBrand 的参数,所以我们在 createInstance 的时候可以不传依赖的 Service。
typescript
export type BrandedService = { _serviceBrand: undefined };
export type GetLeadingNonServiceArgs<TArgs extends any[]> =
TArgs extends [] ? []
: TArgs extends [...infer TFirst, BrandedService] ? GetLeadingNonServiceArgs<TFirst>
: TArgs;
createInstance<Ctor extends new (...args: any[]) => any, R extends InstanceType<Ctor>>(ctor: Ctor, ...args: GetLeadingNonServiceArgs<ConstructorParameters<Ctor>>): R;
如果不写 _serviceBrand, 那这个 Service 参数不会被剔除,就会要求我们手动传入。
如果我们想将某个 Service 当做参数传下去,因为 TS 会剔除这个参数,createInstance 反而会提示你少了一个参数报错。
3. 组件化
Vscode 没有使用 React/Vue 技术栈来编写 UI,而是选择使用纯原生来编写,那么他的 UI 是怎么渲染出来的呢?组件是怎么通信的呢?
与大多数以 React 作为 View 层,Redux/Mobx 处理数据和状态的形式不一样,VSCode 组件也都是 class 的形式。就以我们最熟悉的编辑器内 FindReplace 模块展开说说组件化是如何实现的。
3.1 Controller
VSCode 的复杂 UI 模块是 MVC 的形式来组织,划分成 Controller、View、Model 三层。
查找替换功能的入口在 FindController 里面,VSCode 里面的 UI 模块设计是以 Controller 为入口,创建对应的 Model 层和 View 层,其中 Model 层就是管理数据和状态的。
FindController 被当做 contribution 通过 registerEditorContribution 挂载到编辑器实例上面。
同时,VSCode 会将用户的操作作为 Action 注册到 EditorContributionRegistry,将快捷键作为 EditorCommand 也注册到 EditorContributionRegistry,Controller 也提供了一系列 public 方法供给 Action 和 Command 调用。
less
registerEditorContribution(CommonFindController.ID, FindController, EditorContributionInstantiation.Eager); // eager because it uses `saveViewState`/`restoreViewState`
registerEditorAction(StartFindWithArgsAction);
const FindCommand = EditorCommand.bindToContribution<CommonFindController>(CommonFindController.get);
registerEditorCommand(new FindCommand({
id: FIND_IDS.CloseFindWidgetCommand,
precondition: CONTEXT_FIND_WIDGET_VISIBLE,
handler: x => x.closeFindWidget(),
kbOpts: {
weight: KeybindingWeight.EditorContrib + 5,
kbExpr: ContextKeyExpr.and(EditorContextKeys.focus, ContextKeyExpr.not('isComposing')),
primary: KeyCode.Escape,
secondary: [KeyMod.Shift | KeyCode.Escape]
}
}));
在 FindController 中会创建 FindWidget、FindReplaceState、FindModel 等实例,作为 View 层和 Model 层的桥梁,
kotlin
class FindController {
constructor() {
// 持有 editor 引用
this._editor = editor;
// 实例化状态
this._state = this._register(new FindReplaceState());
// 初始化查询状态
this.loadQueryState();
// 监听状态变更
this._register(this._state.onFindReplaceStateChange((e) => this._onStateChanged(e)));
// 创建 Model
this._model = new FindModelBoundToEditorModel(this._editor, this._state);
// 创建 widget
this._widget = this._register(new FindWidget(this._editor, this, this._state));
// 监听 editor 内容变更
this._register(this._editor.onDidChangeModel(() => {}));
}
}
3.2 Model 和 State
FindReplaceState 负责维护 searchString、replaceString、isRegex、matchesCount 等查找状态和匹配结果,它本身没有什么业务逻辑,可以理解为纯粹的 Store,而且 State 这一层不是必要的。
Model 层包含了 State,主要是做查找替换的业务逻辑,他会监听 State 的状态变更,从 Editor 进行搜索,将结果更新到 FindReplaceState。
kotlin
class FindController {
constructor() {
this._editor = editor;
this._findWidgetVisible = CONTEXT_FIND_WIDGET_VISIBLE.bindTo(contextKeyService);
this._contextKeyService = contextKeyService;
this._storageService = storageService;
this._clipboardService = clipboardService;
this._notificationService = notificationService;
this._hoverService = hoverService;
this._updateHistoryDelayer = new Delayer<void>(500);
this._state = this._register(new FindReplaceState());
this.loadQueryState();
this._register(this._state.onFindReplaceStateChange((e) => this._onStateChanged(e)));
this._model = null;
this._register(this._editor.onDidChangeModel(() => {
}
}
在 Controller 上持有 Editor 实例, 它可以监听到 onDidChangeModel(编辑器内容变化),触发 Model 的搜索,更新搜索结果。
3.3 Widget
在开始之前,我们先看一个 VSCode 里面最简单的 Toggle 组件实现。
在 vs/base/browser/ui 目录下面都是 VSCode 的一些基础组件,每个组件包括了一个 JS 文件和一个 CSS 文件。
kotlin
export class Toggle extends Widget {
private readonly _onChange = this._register(new Emitter<boolean>());
readonly onChange: Event<boolean /* via keyboard */> = this._onChange.event;
private readonly _onKeyDown = this._register(new Emitter<IKeyboardEvent>());
readonly onKeyDown: Event<IKeyboardEvent> = this._onKeyDown.event;
private readonly _opts: IToggleOpts;
private _icon: ThemeIcon | undefined;
readonly domNode: HTMLElement;
private _checked: boolean;
private _hover: IManagedHover;
constructor(opts: IToggleOpts) {
super();
this._opts = opts;
this._checked = this._opts.isChecked;
const classes = ['monaco-custom-toggle'];
if (this._opts.icon) {
this._icon = this._opts.icon;
classes.push(...ThemeIcon.asClassNameArray(this._icon));
}
if (this._opts.actionClassName) {
classes.push(...this._opts.actionClassName.split(' '));
}
if (this._checked) {
classes.push('checked');
}
this.domNode = document.createElement('div');
this._hover = this._register(getBaseLayerHoverDelegate().setupManagedHover(opts.hoverDelegate ?? getDefaultHoverDelegate('mouse'), this.domNode, this._opts.title));
this.domNode.classList.add(...classes);
if (!this._opts.notFocusable) {
this.domNode.tabIndex = 0;
}
this.domNode.setAttribute('role', 'checkbox');
this.domNode.setAttribute('aria-checked', String(this._checked));
this.domNode.setAttribute('aria-label', this._opts.title);
this.applyStyles();
this.onclick(this.domNode, (ev) => {
if (this.enabled) {
this.checked = !this._checked;
this._onChange.fire(false);
ev.preventDefault();
}
});
this._register(this.ignoreGesture(this.domNode));
this.onkeydown(this.domNode, (keyboardEvent) => {
if (keyboardEvent.keyCode === KeyCode.Space || keyboardEvent.keyCode === KeyCode.Enter) {
this.checked = !this._checked;
this._onChange.fire(true);
keyboardEvent.preventDefault();
keyboardEvent.stopPropagation();
return;
}
this._onKeyDown.fire(keyboardEvent);
});
}
get enabled(): boolean {
return this.domNode.getAttribute('aria-disabled') !== 'true';
}
focus(): void {
this.domNode.focus();
}
get checked(): boolean {
return this._checked;
}
set checked(newIsChecked: boolean) {
this._checked = newIsChecked;
this.domNode.setAttribute('aria-checked', String(this._checked));
this.domNode.classList.toggle('checked', this._checked);
this.applyStyles();
}
setIcon(icon: ThemeIcon | undefined): void {
if (this._icon) {
this.domNode.classList.remove(...ThemeIcon.asClassNameArray(this._icon));
}
this._icon = icon;
if (this._icon) {
this.domNode.classList.add(...ThemeIcon.asClassNameArray(this._icon));
}
}
width(): number {
return 2 /*margin left*/ + 2 /*border*/ + 2 /*padding*/ + 16 /* icon width */;
}
protected applyStyles(): void {
if (this.domNode) {
this.domNode.style.borderColor = (this._checked && this._opts.inputActiveOptionBorder) || '';
this.domNode.style.color = (this._checked && this._opts.inputActiveOptionForeground) || 'inherit';
this.domNode.style.backgroundColor = (this._checked && this._opts.inputActiveOptionBackground) || '';
}
}
enable(): void {
this.domNode.setAttribute('aria-disabled', String(false));
}
disable(): void {
this.domNode.setAttribute('aria-disabled', String(true));
}
setTitle(newTitle: string): void {
this._hover.update(newTitle);
this.domNode.setAttribute('aria-label', newTitle);
}
set visible(visible: boolean) {
this.domNode.style.display = visible ? '' : 'none';
}
get visible() {
return this.domNode.style.display !== 'none';
}
}
可以看到,Toggle 组件继承了 Widget 类,Widget 类是所有 UI 组件的基类,它会监听所有的 DOM 的事件,将其通过事件分发出去。
Toggle 支持传入 options 作为初始值,内部创建了 DOM 节点,所有的 UI 更新都是直接操作 DOM,并且将 get/set 方法暴露出去,这样调用方式也很简单,不再需要通过更新 state 来间接更新 UI。
通过这种对属性精细化的控制,可以将渲染性能优化到极致,这种做法 Canvas/WebGL 渲染层也可以参考。
接着说 FindWidget,它也继承了 Widget 类,初始化的时候内部会构建 DOM,其中查找输入框和替换输入框都是通过 Widget 来创建的,所以 Widget 具有组合的能力。
FindWidget 也监听了 State 的状态变更事件,在状态变更之后,就会根据变更原因来更新对应的 Widget 的 UI。比如 Command + D 引起搜索值变化了,就需要调用 findInputWidget.setValue 来更新搜索框的 UI。
3.4 组件通信
从上面可以看到每个 Widget 的职责都比较清晰,除了维护自身的功能,它还将细粒度的 get/set 方法暴露出去,方便外部更新。
对于复杂组件通信的情况,一般是通过事件 + set 来实现的,组件通信就下面两种:
- 父子组件通信:父组件持有子组件,可以直接调子组件的 set 方法更新子组件。子组件内部变更也可以通过抛事件通知父组件更新。
- 兄弟组件通信:一般需要有个父组件或者 Controller 来持有两个组件,组件 A 内部变化的时候抛事件出去,父组件监听到之后,直接调用组件 B 的 set 方法来更新。
比如查找替换这个组件,我们修改了搜索值,右侧的匹配结果就会更新,主要步骤可以简化为:
- 用户输入修改 findInputWidget 的值,findInputWidget 发送 onDidChange 通知出去,findWidget 更新 findState。
- Model 监听到 state 变更之后重新搜索,搜索之后再更新 findState 的匹配结果。
- findWidget 监听到状态变更之后,主动调用 matchesCount 去更新 DOM。
3.5 总结
为什么在 React/Vue 出现之前,大家都觉得原生JS、jQuery 这种开发模式不适合大型项目呢?为什么在 VSCode 上又可以呢?
原因是 jQuery 时期几乎没有模块化和组件化的概念,即使可以用 AMD/CMD 来做模块化、jQuery 插件来做组件化,但 jQuery 的组件化的不够彻底,上手成本也高一些。
我们用 jQuery 开发项目的时候,很容易出现一个 DOM 节点被到处绑事件,最后事件满天飞,调试起来很困难的情况。
如果使用模板引擎,更新效率比较低,DOM 重绘开销大,远远比不上 React/Vue 但在 VSCode 里面,每个组件只暴露自己的 getter/setter,内部变更通过事件通知,组件之间通信都是用事件的形式,组件和模块的划分也非常清晰。
通过对 DOM 属性细粒度更新,VSCode 性能也是比 React/Vue 更高的。