文章参考:https://zhuanlan.zhihu.com/p/311184005
项目参考:darukjs InversifyJS
依赖注入是 IoC(控制反转) 的一种实现方式,通过依赖注入可以动态的将某种依赖关系注入到对象中,而不用手动一个个实例化。
在依赖注入中,将实例化对象这个步骤交给外部(IoC 容器),即为控制反转。
一. 为什么需要依赖注入
传统面向对象开发中,当两个类之间存在依赖关系时会直接在类的内部创建另一个的实例,导致两个类之间形成耦合关系。
实际情况往往多个类之间会存在交叉依赖关系,一个类会被多个其它类依赖。这时如果类的功能有修改,则可能需要把所有依赖该类的地方统统改一遍。
如同上面解释的那样,A
和 B
两个类可能存在一种关系为 A
依赖于 B
,例如:
1 | // b.ts |
此时如果如果有一个新需求,需要 B 初始化时传入一个新的参数 p
,此时 B 为:
1 | // b.ts |
B
修改后那么所有实例化 B
的地方都需要添加参数。 A
构造函数中实例化 B
时也需要传入参数 p
;A
中的 p
并非凭空生成,也需要实例化 A
时传入参数,再将参数传给 B
的构造函数。此时 A
为:
1 | // a.ts |
由上面的例子我们知道,修改 B
就需要修改 A
,如果依赖层数很深则可能行程一条 修改链,由修改的那一层一直到表层。
二. 怎么实现 IoC
怎么解耦
上诉情况产生的原因是由于A
需要B
的实例,实现时选择直接在A
中实例化B
,那么A
实际上依赖的是B
这个类(构造器)而非B
的实例。A
并不关心B
的实例在何时何地以何种方式构造出来。基于这个原因我们其实可以做以下改变:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19// b.ts
class B {
p: number;
constructor(p: number) {
this.p = p;
}
}
// a.ts
class A {
private b:B;
constructor(b: B) {
this.b = b;
}
}
// main.ts
const b = new B(10);
const a = new A(b);这个例子中,我们将
A
、B
各自独立的实例化,在A
中直接传入B
的实例,而非B
的参数,这样当依赖项B
有任何改变时,A
不需要做任何变化。容器
上面的例子里我们实现了将A
和B
解耦,A
不再因为B
改变而改变了。但如果还有一个类需要B
的话仍然需要再次实例化B
,这样的地方越多,那么我们维护B
的成本也就越高。我们很容易可以想到,要是将所有的依赖都统一管理,使用的时候不去new
实例,而是直接拿到已经new
好的实例,就解决了这个问题。先新建一个容器类:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19class Container {
instanceMap = new Map()
bind(id: string, clazz: any, constructorArgs: Array<any>) {
this.instanceMap.set(id, {
clazz,
constructorArgs
})
}
get<T>(id: string): T {
const target = this.instanceMap.get(id);
const { clazz, constructorArgs } = target;
const inst = Reflect.construct(clazz, constructorArgs); // Reflect.construct 类似 new 操作符
return inst
}
}
export default new Container()原来
A
、B
的代码即可改为:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21// b.ts
class B {
p: number;
constructor(p: number) {
this.p = p;
}
}
// a.ts
class A {
private b:B;
constructor() {
this.b = container.get('b'); // 此处通过容器注入
}
}
// main.ts
import container from './container.ts'
// 绑定到容器
container.bind('a', A);
container.bind('b', B, [10]);到此我们实现了一个容器,通过容器实现类和类之间的解耦。从功能上说,上面的实现就是一种 IoC,
将实例化的过程交给容器实现,而非类内部。但整个实现过程还是略显复杂,类需要手动绑定到容器,
依赖需要手动到容器上获取;如果这些步骤能自动实现,就能减少重复的代码,解放我们的双手。
实际上大部分框架中的 DI(依赖注入)就是为了完成这件事。
三. DI
DI 即依赖注入,是 IoC 的一种实现方式,通过 DI,我们可以将依赖注入给调用方,不需要调用方主动去获取依赖。结合上文,为实现 DI,我们还需要完成以下两件事:
- 需要将类自动绑定到容器。
- 需自动将依赖注入到属性上(如例子中
A
需要关联B
的实例)。
针对问题 1,一般由两个方法,一是通过一个清单文件,将需要注入的类列举出来,然后框架统一注入到容器中。二是通过注解(装饰器)直接在类中标识,然后逐一注入到容器中——本例采用的方式。
针对问题 2,可以通过装饰器将容器收集到的类实例化到对应的对象属性上
这里使用装饰器进行依赖注册及注入,这种方式在使用时更为方便,当增加新的类文件时完全不用做任何操作,可以自动完成类注册到容器。下面我们来完成一个 DI 实例:
Reflect Metadata
上面我们说 “针对问题一可以通过在类中标识,从而完成注入类到容器”,Reflect Metadata
即是用来标识类的。不止如此,我们将依赖注入到实例中时仍然需要它。
简单点理解,Reflect Metadata
就是用来在某一对象上写入一些数据,但是这些数据不能直接读取出来,也不影响对象的正常使用,等到我们需要的时候又可以通过Reflect Metadata
提供的一些方法读出来。想了解更多可以参考 Reflect Metadata
Provide
Provide
用来解决上面提到的问题一。这是一个装饰器,用它标识的 class 将会自动绑定到容器中,下面是 Provide 的实现参考
inversify-binding-decorators
中Provide
的简化实现1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29import interfaces from "./interfaces";
import { METADATA_KEY } from "./constants";
import "reflect-metadata";
export default function Provide(serviceIdentifier: string, args?: Array<any>) {
return function (target: any) {
// 当前 class 的信息
const currentMetadata: interfaces.ProvideSyntax = {
id: serviceIdentifier,
args: args || [],
clazz: target
};
// 已经收集的 class
const previousMetadata: interfaces.ProvideSyntax[] = Reflect.getMetadata(
METADATA_KEY.provide,
Reflect
) || [];
const newMetadata = [currentMetadata, ...previousMetadata];
// 将所有使用 Provide 装饰器标识的 class 都作为 Reflect 的元数据暂存
Reflect.defineMetadata(
METADATA_KEY.provide,
newMetadata,
Reflect
);
return target;
};
}我们将所有需要绑定到容器的类都用
@Provide
去标记,收集并记录到 Reflect 对象中。如:1
2
3
4
5
6'b') (
export class B {
constructor(p: number) {
this.p = p;
}
}但到这里收集依赖的部分还没完成,因为类都定义在文件内的。我们需要一个方法将需要的文件一次性都
require
进来,让依赖注册生效(@Provide
)。下面的binding
方法就是完成这件事,我们需要在应用的生命周期初始化阶段去手动调用它:参考 darukjs 项目实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19// ./container.ts
export async function binding() {
// 对目录没有限制,这里只是将常规目录作为示例
await _loadFile(join(__dirname, './middlewares'));
await _loadFile(join(__dirname, './controllers'));
await _loadFile(join(__dirname, './services'));
container.load(buildProviderModule());
}
export async function _loadFile(path: string) {
return recursive(path).then((files: Array<any>) => {
return files
.filter((file) => isJsTsFile(file))
.map((file) => file.replace(JsTsReg, ''))
.forEach((path: string) => {
require(path);
});
}).catch(() => {});
}调用 binding 函数就会将
middlewares
、controllers
、services
目录中所有@Provide
装饰的 class 都绑定到容器。
这里还缺两块,Container 的 load 方法,还有buildProviderModule
函数。buildProviderModule
函数就是为了从 Reflect 上拿到所有依赖项目。buildProviderModule
参考inversify-binding-decorators
的实现1
2
3
4
5
6
7
8
9
10
11
12
13
14// ./container.ts
/**
* 省略已实现的代码
*/
function buildProviderModule() {
return (bind: Function) => {
let provideMetadata: interfaces.ProvideSyntax[] = Reflect.getMetadata(METADATA_KEY.provide, Reflect) || [];
provideMetadata.map(metadata => bind(
metadata.id,
metadata.clazz,
metadata.args
));
};
}上面代码中的核心就是
Reflect.getMetadata(METADATA_KEY.provide, Reflect)
,对比Provide
中的这部分1
2
3
4
5Reflect.defineMetadata(
METADATA_KEY.provide,
newMetadata,
Reflect
)我们大致就能摸清依赖注册的原理,
@Provide
通过Reflect.defineMetadata
完成收集。buildProviderModule
方法中通过Reflect.getMetadata
拿到收集到的类交给container.load
完成依赖注册。Container.load
如下:1
2
3
4
5
6
7export class Container {
// ...省略前面已实现的代码
public load(register) {
// 注意,这里只是简化的例子
register(this.bind)
}
}Inject
前一步我们完成了依赖注册,将所有待依赖
的 class 都在 container 中保存起来。接下来就是最后一步:在需要依赖的地方注入依赖项。
我们要做到的就是在类初始化时能自动拿到依赖对象的实例,不需要在依赖对象初始化时传参。我们已经将所有相关类都注册到 container 中,要使用某个类时,只要将 container 中对应的类实例化给相应属性即可。有了上面 Provide 的经验我们很容易就可以想到,通过装饰器很容易办到。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35// ./inject.ts
import container from './container'
const INJECTION = Symbol.for("INJECTION");
export default function Inject(serviceIdentifier: string) {
return function (proto: any, key: string) {
let resolve = () => {
// 从 container 中取值
return container.get(serviceIdentifier);
};
function getter() {
// 缓存值
if (!Reflect.hasMetadata(INJECTION, this, key)) {
Reflect.defineMetadata(INJECTION, resolve(), this, key);
}
if (Reflect.hasMetadata(INJECTION, this, key)) {
return Reflect.getMetadata(INJECTION, this, key);
} else {
return resolve();
}
}
function setter(newVal: any) {
Reflect.defineMetadata(INJECTION, newVal, this, key);
}
Object.defineProperty(proto, key, {
configurable: true,
enumerable: true,
get: getter,
set: setter
});
};
}Inject
通过设置属性的 getter 和 setter 拦截属性的获取和设置。获取时从 container 中拿到依赖对应依赖,设置时则替换当前依赖的缓存值,等到下次获取时使用这个设置的新值。经过上面的设置,原来
A
、B
的例子即可改为:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20// 全局共用
const container = new Container();
// ./services/b.ts
'b', [10]) (
class B {
constructor(p: number) {
this.p = p;
}
}
// ./services/a.ts
'a') (
class A {
'b') (
private b:B;
}
// ./main.ts
binding()
console.log(container.get('a')); // => A { b: B { p: 10 } }
尾巴
IoC 最早运用在后端服务上,随着时间的推移,像 Angular 这样的优秀框架实现了前端的 IoC,也给前端提出了新要求。技术思想是不分前后端的,不管是 MVC、IoC、AOP 抑或是别的,这些业界的经典设计,是一个在前后端都通用的思想或范式。我们不能给自己的技术设限,优秀的思想永远都是学习的目标。
前端在近些年日新月异,我们需要拥抱变化,融入变化;在未知的技术面前虚心学习是技术进阶的必经之路。