Read OSS

DI 引擎:VS Code 如何将 190+ 个服务连接在一起

高级

前置知识

  • 第一篇:架构与分层
  • 第二篇:启动流程与进程架构
  • TypeScript 装饰器与泛型
  • 依赖注入基本概念

DI 引擎:VS Code 如何将 190+ 个服务连接在一起

在第二篇文章中,我们看到 CodeMain 创建了约 15 个服务,CodeApplication 将其扩展至数十个,最终渲染侧的 Workbench 又引入了数百个已注册的单例。这一切都没有借助任何第三方 DI 框架——VS Code 拥有自己的依赖注入系统,简洁、强大,并与 TypeScript 的类型系统深度融合。理解它,是读懂整个代码库的前提。

createDecorator 与 ServiceIdentifier

VS Code 中的每个服务都通过一个 ServiceIdentifier<T> 来标识——它既是一个普通对象,也充当 TypeScript 的参数装饰器。工厂函数 createDecorator 负责创建它们:

export function createDecorator<T>(serviceId: string): ServiceIdentifier<T> {
    if (_util.serviceIds.has(serviceId)) {
        return _util.serviceIds.get(serviceId)!;
    }
    const id = function (target: Function, key: string, index: number) {
        if (arguments.length !== 3) {
            throw new Error('@IServiceName-decorator can only be used to decorate a parameter');
        }
        storeServiceDependency(id, target, index);
    } as ServiceIdentifier<T>;
    id.toString = () => serviceId;
    _util.serviceIds.set(serviceId, id);
    return id;
}

这里有个巧妙的设计:id 本身是一个函数,TypeScript 会将其作为参数装饰器调用,同时它通过 ServiceIdentifier<T> 接口携带了类型标记。这种双重特性意味着,只需一行声明,例如 export const IFileService = createDecorator<IFileService>('fileService'),你就同时获得了:

  1. 一个类型安全的 DI 容器查找键
  2. 一个用于构造函数注入的参数装饰器
classDiagram
    class ServiceIdentifier~T~ {
        +type: T
        +(target, key, index): void
        +toString(): string
    }
    
    class createDecorator {
        +createDecorator~T~(serviceId: string): ServiceIdentifier~T~
    }
    
    class _util {
        +serviceIds: Map~string, ServiceIdentifier~
        +DI_TARGET: string
        +DI_DEPENDENCIES: string
        +getServiceDependencies(ctor): Dependency[]
    }
    
    createDecorator ..> ServiceIdentifier : creates
    ServiceIdentifier ..> _util : stores dependency metadata

当你写出 constructor(@ILogService private logService: ILogService) 这样的构造函数时,@ILogService 装饰器会在类定义阶段触发,将"该构造函数在指定 index 位置的参数依赖于 ILogService"这一信息记录下来。这份元数据通过 storeServiceDependency()$di$dependencies 属性的形式存储在构造函数本身上。

InstantiationService:基于依赖图的解析

InstantiationService 是 DI 容器的核心。它持有一个 ServiceCollection(本质上是 Map<ServiceIdentifier, instance | SyncDescriptor>),并通过构建依赖图来解析依赖关系:

flowchart TD
    A["createInstance(MyClass)"] --> B["Read @decorator metadata<br/>from MyClass constructor"]
    B --> C["For each dependency,<br/>check ServiceCollection"]
    C --> D{Instance exists?}
    D -->|Yes| E["Use existing instance"]
    D -->|No, has SyncDescriptor| F["Add to dependency graph"]
    F --> G["Recursively resolve<br/>descriptor's dependencies"]
    G --> H["Topological sort the graph"]
    H --> I{Cycle detected?}
    I -->|Yes| J["Throw CyclicDependencyError"]
    I -->|No| K["Instantiate in dependency order"]
    K --> L["Return MyClass instance"]

依赖图的构建和循环检测发生在内部方法 _createAndCacheServiceInstance 中。当某个服务依赖于另一个以 SyncDescriptor 形式注册的服务(即"尚未实例化")时,解析器会在图中添加一条边。收集完所有依赖后,执行拓扑排序,并按照从叶节点开始的顺序依次完成实例化。

instantiationService.ts#L21-L26 中的 CyclicDependencyError 提供了实用的诊断信息——它会调用 findCycleSlow() 来定位精确的循环路径,在开发阶段极为有用。

此外,容器还支持通过 createChild() 创建子作用域。子容器继承父容器的所有服务,同时可以覆盖其中特定的服务。Workbench 正是利用这一机制为特定功能创建独立的作用域容器。

通过 SyncDescriptor 和 Proxy 实现懒加载

SyncDescriptor 是一个简单的包装类,持有构造函数引用、静态参数,以及一个关键的 supportsDelayedInstantiation 标志。

export class SyncDescriptor<T> {
    readonly ctor: any;
    readonly staticArguments: unknown[];
    readonly supportsDelayedInstantiation: boolean;

    constructor(ctor: new (...args: any[]) => T, 
                staticArguments: unknown[] = [], 
                supportsDelayedInstantiation: boolean = false) { ... }
}

supportsDelayedInstantiationtrue 时,InstantiationService 不会立即创建真正的服务实例,而是创建一个 JavaScript Proxy 作为占位符。只有当 Proxy 上的某个方法被首次调用时,才会触发真正的实例化。

sequenceDiagram
    participant Consumer
    participant Proxy as JS Proxy
    participant DI as InstantiationService
    participant Service as Real Service

    Consumer->>Proxy: accessor.get(IFooService)
    Note over Proxy: Proxy returned, not real instance
    Consumer->>Proxy: fooService.doSomething()
    Proxy->>DI: First access! Instantiate now.
    DI->>Service: new FooService(deps...)
    Proxy->>Service: Forward doSomething()
    Service-->>Consumer: Result
    Note over Proxy: Subsequent calls go<br/>directly to Service

这是一项显著的启动性能优化。许多服务在启动时就已注册,但只有在用户触发特定操作后才真正需要。如果没有懒加载 Proxy,Workbench 就必须在启动时同步实例化数百个服务,严重阻塞首次渲染。

提示: 注册新服务时,建议默认使用 InstantiationType.Delayed(对应 supportsDelayedInstantiation: true),除非该服务有必须在启动时执行的副作用。

registerSingleton:全局服务注册表

registerSingleton 函数是第一篇文章中提到的 barrel 文件与 DI 容器之间的桥梁:

const _registry: [ServiceIdentifier<any>, SyncDescriptor<any>][] = [];

export function registerSingleton<T>(id: ServiceIdentifier<T>, 
    ctorOrDescriptor: ..., 
    supportsDelayedInstantiation?: InstantiationType): void {
    if (!(ctorOrDescriptor instanceof SyncDescriptor)) {
        ctorOrDescriptor = new SyncDescriptor(ctorOrDescriptor, [], Boolean(supportsDelayedInstantiation));
    }
    _registry.push([id, ctorOrDescriptor]);
}

逻辑非常直接:将 [ServiceIdentifier, SyncDescriptor] 元组推入一个模块级数组。在 Workbench 启动阶段,Workbench.initServices() 会调用 getSingletonServiceDescriptors() 将这个数组中的内容全部转移到 ServiceCollection 中:

const contributedServices = getSingletonServiceDescriptors();
for (const [id, descriptor] of contributedServices) {
    serviceCollection.set(id, descriptor);
}

当你导入 workbench.desktop.main.ts 这样的 barrel 文件时,它所引入的每个模块都有机会在模块求值阶段调用 registerSingleton()。等到 initServices() 执行时,完整的服务依赖图已经声明完毕。InstantiationType 枚举控制服务是 Eager(在此循环中立即实例化)还是 Delayed(通过 Proxy 懒加载实例化)。

Disposable 模式与生命周期管理

IDisposable 接口只有一个 dispose() 方法,却是整个代码库中资源管理的基石。

src/vs/base/common/lifecycle.ts 定义了核心构建块:

classDiagram
    class IDisposable {
        <<interface>>
        +dispose(): void
    }
    
    class Disposable {
        #_register(disposable): T
        +dispose(): void
    }
    
    class DisposableStore {
        +add(disposable): T
        +clear(): void
        +dispose(): void
    }
    
    class DisposableMap {
        +set(key, disposable): void
        +deleteAndDispose(key): void
        +dispose(): void
    }
    
    class GCBasedDisposableTracker {
        -_registry: FinalizationRegistry
        +trackDisposable(d): void
        +markAsDisposed(d): void
    }
    
    IDisposable <|.. Disposable
    IDisposable <|.. DisposableStore
    Disposable *-- DisposableStore : uses internally
    IDisposable <|.. DisposableMap

Disposable 基类提供了 _register() 方法,用于将子 disposable 添加到内部的 DisposableStore。当父对象被 dispose 时,所有已注册的子对象也会随之被 dispose,从而形成一棵自然的所有权树。

GCBasedDisposableTracker 的设计尤为巧妙:它利用 FinalizationRegistry API 来检测那些在没有调用 dispose() 的情况下就被垃圾回收的 disposable——这是资源泄漏的强烈信号。在开发阶段,它会输出类似 [LEAKED DISPOSABLE] CREATED via: ... 的警告,并附带创建时的调用栈。

Event/Emitter 系统

VS Code 的自定义 Event<T> 类型是一个接受监听器并返回 IDisposable 的函数——即订阅本身。Emitter<T> 类为其提供底层支持,通过 fire() 方法触发事件。

这套系统的独特之处在于 Event 命名空间提供的一组可组合算子:

  • Event.map(event, fn) — 转换事件载荷
  • Event.filter(event, predicate) — 仅对满足条件的事件触发
  • Event.debounce(event, merge, delay) — 合并高频触发的事件
  • Event.buffer(event) — 在监听器挂载前缓冲事件
  • Event.once(event) — 首次触发后自动取消订阅

所有算子都返回新的 Event 实例,并与 disposable 模式深度集成。这是 VS Code 响应式机制的核心骨架——各个服务暴露 onDidChange* 事件,消费者将其组合成派生信号,无需手动管理原始监听器的生命周期。

Registry 与 Contribution 模式

Registry 是一个以字符串为键的简单 Map,用于代码库内部的扩展点管理。通过 Registry.add(id, data) 注册 contribution registry,再通过 Registry.as<T>(id) 取回。

这一模式最高层次的应用是 registerWorkbenchContribution2,它将 workbench contribution 与特定的生命周期阶段绑定注册:

export const enum WorkbenchPhase {
    BlockStartup = LifecyclePhase.Starting,    // Blocks editor from showing
    BlockRestore = LifecyclePhase.Ready,       // Blocks UI state restore
    AfterRestored = LifecyclePhase.Restored,   // Views and editors restored
    Eventually = LifecyclePhase.Eventually     // 2-5 seconds after restore
}

src/vs/workbench/common/contributions.ts#L31-L62

flowchart LR
    A["BlockStartup"] --> B["BlockRestore"]
    B --> C["AfterRestored"]
    C --> D["Eventually"]
    
    A -.->|"Essential init<br/>(blocks first paint)"| A1["Keybindings,<br/>Theming"]
    C -.->|"Non-critical<br/>(after UI visible)"| C1["Terminal restore,<br/>Extension recommendations"]
    D -.->|"Background<br/>(2-5s delay)"| D1["Telemetry,<br/>Update checker"]

大多数 contribution 应使用 WorkbenchPhase.AfterRestoredEventuallyBlockStartup 会延迟首次渲染,应仅保留给那些必须在用户看到任何内容之前执行的 contribution。Contribution 系统会追踪每个 contribution 的创建耗时,并对超过 2ms 的情况输出警告日志。

提示: { lazy: true } 实例化选项可将 contribution 的初始化推迟到通过 getWorkbenchContribution() 显式请求时才执行,非常适合那些在某些会话中可能完全不会被激活的功能。

下一篇

DI 系统、disposable、事件和 contribution 构成了 VS Code 一切功能的基础模式。下一篇文章将带我们走进 Extension Host——这是一个独立进程,第三方代码在其中运行,并通过一套拥有 140 余个代理接口的 RPC 协议与 Workbench 保持通信。