什么是依赖注入
# 什么是依赖注入
# 简介
依赖注入的概念可以参考 Angular 的文档,Angular 中的依赖注入 (opens new window)。
关于什么是依赖注入,我在网上搜索了很多文章,专业术语也有很多,看的我迷迷糊糊的。我尝试记录一下我自己的理解。
首先从名字入手,依赖注入可以分成依赖和注入。
# 什么是依赖
在讨论依赖之前,必须先了解什么是服务,不考虑边界情况下,可以把服务理解为数据和方法的集合。
一般我们会通过实例化一个类来得到这个服务对象。我们可以想象这个类的某个实例属性有可能又是另一个类的实例对象。这个过程可以一直进行下去。比如这样的依赖关系图:
A --> B、C、D
B --> C、D
C --> D、E
D --> E、F
E --> F
F 没有依赖
2
3
4
5
6
上面的依赖图代表 A 这个类有三个实例属性b、c、d
分别是B、C、D
这三个类的实例对象。意味着A
类是依赖B、C、D
这 3 个类的。
而B
类又是依赖C、D
这两个类的。依次类推我们可以知道C、D、E
这三个类的依赖,注意到F
类是没有依赖的,所以这个例子中不存在循环依赖。
当我们说到依赖这两个字时,其实依赖既可以是动词又可以是名词,作动词时可以说类 A 依赖类 B,C,D。作名词时类 B,C,D 就是类 A 的依赖。
提示
这里的例子是用的类作为服务工厂,抽象的说应该是从服务工厂生产一个服务 A 时,必须先生产另一个服务 B,此时服务 B 就是服务 A 的依赖。
# 什么是注入
在了解了什么是依赖之后,注入就非常简单了,比如 A 依赖 B,那么通过某种手段
把 B 注入到 A 的过程就是注入。
常用的注入手段有 3 种,构造函数注入,属性注入,setter 注入。
先看看在没有依赖注入框架的帮助下,我们怎么手动实现注入。
class B {
name = "B";
}
class A {
name = "A";
b: B;
constructor(b: B) {
this.b = b;
}
}
const b = new B();
// 手动通过构造函数注入
const a = new A(b);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class B {
name = "B";
}
class A {
name = "A";
b: B;
}
const b = new B();
const a = new A();
// 手动通过属性注入
a.b = b;
2
3
4
5
6
7
8
9
10
11
12
class B {
name = "B";
}
class A {
name = "A";
b: B;
setB(b: B) {
this.b = b;
}
}
const b = new B();
const a = new A();
// 手动通过setter注入
a.setB(b);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
以上代码展示了 3 种手动注入的过程,下面介绍依赖注入框架怎么实现类似的功能。
import { Injectable, Injector } from "@kaokei/di";
@Injectable()
class B {
name = "B";
}
@Injectable()
class A {
name = "A";
constructor(public b: B) {}
}
const injector = new Injector();
// 依赖注入框架会自动把B注入到A中-通过构造函数的方式
const a = injector.get(A);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { Injectable, Injector, Inject } from "@kaokei/di";
@Injectable()
class B {
name = "B";
}
@Injectable()
class A {
name = "A";
@Inject()
b: B;
}
const injector = new Injector();
// 依赖注入框架会自动把B注入到A中-通过属性注入的方式
const a = injector.get(A);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
提示
虽然有些框架(Spring)实现了 setter 注入,不过本库并没有支持 setter 注入,个人觉得在前端中没有必要。
和上面的手动的实例化过程对比,是不是发现这样写的代码就简单多了,这就是依赖注入框架的魅力,实际上依赖关系越复杂,依赖注入框架的优势就越明显。
# 依赖注入的简单实现
我的总结是依赖注入实在没有什么技术含量,也没有什么高大上的地方。不要被陌生的技术名词给吓到了。本质上就是 Key-Value 的魔法。比如:
// 内部使用map来记录对应关系
defineKeyValue("tokenA", "valueA");
// 再通过map.get(key)获取数据即可
const value = getValueByKey("tokenA");
2
3
4
可以说这就是最简单的依赖注入的简单实现。但是它实在是太简单了,处理的场景有限,所以价值不大。至少要再加上类的实例化能力。
// 内部使用map来记录对应关系
defineKeyValue("tokenA", ClassA);
// 再通过map.get(key)获取到ClassA,这里判断是一个类,则实例化后返回一个对象,否则直接返回
const instanceOfClassA = getValueByKey("tokenA");
2
3
4
现在我们这个简易的依赖注入库实现了两种能力,如果判断是类,则去实例化;否值直接返回。我们可以沿着这个思路继续添加新能力。比如如果是普通函数,那么就当作普通函数来执行,然后把这个函数的返回值当作服务返回,这样我们就有三种能力了。
主要是介绍这种扩展的思路,只要有这种扩展的思路,我们就可以继续扩展更多的能力,无非就是添加一个if-else
分支的事情。
排除掉这种扩展思路本身,我们的依赖注入框架还有什么局限性吗?
其实还有命名空间单一的问题。显然上面所有的数据都处于同一个全局命名空间下。因为defineKeyValue
和getValueByKey
是一个全局函数。那么所有的配置信息就只有一份。这种状况在大多数场景应该也没有什么问题。但是确实还可以继续提升一下。
我们需要继续引入一个新的概念,就是Injector
。通过下面的伪代码我们可以快速了解为什么需要 Injector。参考这里可以了解什么是 Injector。
// 注意这里只是伪代码,只是用于演示什么是分级注入特性
// 注意@kaokie/di中的Injector的实际用法和下面的Injector并不一致
const parentInjector = new Injector();
const childInjector = new Injector();
childInjector.parent = parentInjector;
parentInjector.defineKeyValue("tokenA", ClassA);
childInjector.defineKeyValue("tokenB", ClassB);
// 注意到childInjector中并没有定义tokenA,但是仍然可以获取到服务实例
const serviceA = childInjector.getValueByKey("tokenA");
const serviceB = childInjector.getValueByKey("tokenB");
2
3
4
5
6
7
8
9
10
11
12
以上伪代码展示了分层注入的特性,之所以引入 Injector 这个概念主要是为了避免只有全局一份配置信息。我们可以做到每次实例化一个 Injector 对象,这个 Injector 对象就具有依赖注入的能力;除此之外我们还可以给 Injector 对象增加一个 parent 属性,从而可以把 Injector 对象关联起来,如果当前 Injector 对象中找不到某个服务,就会从其 parent Injector 对象中寻找服务,直到根 Injector 为空。
提示
以上是从服务配置和获取服务这两个角度来剖析了如何简单实现一个依赖注入框架。当然如果要处理依赖的依赖,甚至循环依赖等复杂场景,还需要其他方面的支持。比如 typescript 以及 decorator。不过这属于技术细节,不影响理解整体概念,这里不再细述。有兴趣可以直接参考源代码即可。
# 在 vue 中使用依赖注入
以上是从依赖注入本身的角度来思考的,和具体业务是无关的。考虑到在具体前端的场景下,比如在 vue 中,应该怎样去结合使用呢?
这里提供一个适用于 vue 的依赖注入框架@kaokei/use-vue-service
,该库参考 angular 把 Injector 绑定在组件上,默认根 Injector 是对应的根组件上,可以理解为公共的全局的命名空间。
如果在业务上认为某个服务和某个组件是绑定的,就需要用到 declareProviders([CountService])
。这行代码意味着当前组件会关联一个 Injector,并且配置了一个 CountService 服务。同时也意味着,在当前组件及其子孙组件中调用useService
时,一定会从这个 Injector 中获取到 CountService 服务实例(前提是子孙组件不再定义同样的服务declareProviders([CountService])
)。这就是上面提到的分层注入的特性。
这种寻找机制和原型链寻找属性的机制非常相似,也就是底层的命名空间中的同名属性会覆盖上层命名空间的同名属性。
具体可以参考文档
# 依赖注入 vs import/export
- import/export 适合单例,单例可以是服务工厂本身,也可以是已经实例化的服务对象。
- import/export 导致业务强制依赖某个服务,不存在干预服务创建过程的可能性。因为我们一般会直接 import 一个服务本身,然后在业务代码中使用这个服务,这样就导致业务直接依赖这个服务对象。就算我们 import 的是服务工厂,如果我们还是在业务代码中手动通过服务工厂去创建服务,那么仍然是耦合的,而且不同业务代码会得到不同的服务对象。
- 依赖注入功能是离不开 import/export 的,比如在依赖注入场景中类 A 依赖类 B,显然是需要在类 A 中 import 类 B 的。
- 依赖注入使业务解藕了依赖声明和依赖的实例化。比如业务代码声明依赖 LoggerService,但是可以通过配置修改为 OtherLoggerService 的实例。
- Vue3 跨组件共享数据,为何要用 provide/inject?直接 export/import 数据行吗? (opens new window)
- 前端什么时候用 import 什么时候用依赖注入? (opens new window)