一直以来对 依赖注入 (Dependency Inject) 的理解较浅,在前端开发中,往往一些全局单例就能较好解决依赖问题,引入 IOC 框架反而使代码变得复杂无比。近期读 VSCode 源码,看到依赖注入大面积使用,细读之下,才发现这个模式的精巧之处,也解决了许多疑惑。
VSCode 是自己实现的 IOC 模块,代码在 src/vs/platform/instantiation/common 中,6个文件,短小精悍。为了实现 IOC,需要有:
一、依赖声明
也就是一个类需要依赖哪些其他的类,参考 Java 用配置文件是一个方式,但在 TypeScript 中有更好的做法,即利用 参数装饰器 (Parameter Decorators) :
function d(target: Object, propertyKey: any, parameterIndex: number) {}
class A {
constructor(@d a: any) {}
}
参数装饰器的运行时机是 类定义时(非实例化时) ,上例中,d 就已经执行了,且 target === A,可以拿到类,parameterIndex === 0,可以获知是哪个参数。
VSCode 通过 createDecorator 方法统一创建参数装饰器,该方法做了如下事情:
- 将
target['$di$target']设置成target自身; - 将装饰器作为 serviceId,连同
parameterIndex添加到target['$di$dependencies']数组里。
function storeServiceDependency(id: Function, target: Function, index: number): void {
if ((target as any)[_util.DI_TARGET] === target) {
(target as any)[_util.DI_DEPENDENCIES].push({ id, index });
} else {
(target as any)[_util.DI_DEPENDENCIES] = [{ id, index }];
(target as any)[_util.DI_TARGET] = target;
}
}
如此,拿到一个类后,通过它的 $di$dependencies属性,就能知道所有依赖的 serviceId,也能每个serviceId 位于第几个参数。
二、依赖创建和注册
依赖声明中只有 serviceId,如何由 serviceId 查到对应 service,也是要做的事情。一个想法是使用一个 Map,每个实例创建时,就创建 serviceId 到实例的映射。VSCode 使用 ServiceCollection 来做这件事,例如,InstantiationService 的构造函数中有这样一句:
this._services.set(IInstantiationService, this);
其中 IInstantiationService 就是 serviceId。
三、依赖分析
由于存在多级依赖,比如 A -> B -> C,假如要实例化 A,则必须先找到 B、C,然后按 C B A 的次序依次实例化,这个过程比较繁琐,因此实例创建一般需要一个专门的方法,VSCode 中是 createInstance 。
正确的依赖关系是一个树,但复杂依赖时往往不小心成环,这种需要检查出来。VSCode 通过计数来简单判断,如果循环超过1000则认为成环,再用 graph 模块寻找环,通过异常抛出来:
let cycleCount = 0;
const stack = [{ id, desc, _trace }];
while (stack.length) {
// a weak but working heuristic for cycle checks
if (cycleCount++ > 1000) {
throw new CyclicDependencyError(graph);
}
}
性能考虑,VSCode 支持异步实例化,根据环境不同,通过 requestIdleCallback等方式异步创建实例。
四、疑惑解答
通过源码了解了实现细节,一些疑惑也就迎刃而解:
- 为什么不是参数传入?
写起来啰嗦是一方面,更重要的是传参依然需要手动实例化,当一个类发生改变时,所有实例化的地方都要改。
- 为什么不是全局变量?
没法支持多级依赖。
3. 依赖只能是单例吗?
一般情况下是单例,但不意味着只能单例,核心是 serviceId 和实例的一一对应关系,使用多例时,对每个实例注册唯一的 serviceId 即可。
4. 只能通过 createIntance 实例化吗?
不是,由于参数装饰器的执行时机是定义时,实例化及以后都不运行,因此可以等同于普通类,仍然可以用 new 手动实例化,只不过, 被注入的参数需要手动填入,该实例也和 IOC 无关。
5. 控制反转 (IOC) 和依赖注入 (DI) 什么关系?
前者是一个思想,后者是一种实现。值得一提的是,“反转”这个词很有误导性,一般 A 控制 B(的实例化过程),那反转应该是 B 控制 A,但事实却是控制转移到 "C"(即 IOC 容器上),所以更准确的说法应该是“控制转移”。