JavaScript 依赖注入
1. 依赖注入的引入
在开发中,一个类或模块通常需要依赖其他类或模块来完成工作。最直接的方式是在其内部直接创建依赖的实例。
// 一个用于记录日志的服务
class Logger {
log(message) {
console.log(`[LOG]: ${message}`);
}
}
// 用户服务,它需要使用 Logger
class UserService {
constructor() {
// UserService 内部直接创建了 Logger 实例
// 这就是紧耦合!
this.logger = new Logger();
}
create(user) {
// ...创建用户的逻辑...
this.logger.log(`User ${user.name} created.`);
}
}
const userService = new UserService();
userService.create({ name: 'Alice' });
上面的代码看起来很简单,但存在几个严重的问题:
- 难以测试:在对
UserService
进行单元测试时,我们无法轻易地替换掉真实的Logger
。测试UserService
的同时,也把Logger
的功能一起测试了,这违背了单元测试的“隔离”原则。如果我们想使用一个“模拟 Logger”(Mock Logger)来验证log
方法是否被调用,将非常困难。 - 灵活性差:如果有一天,我们想把日志记录从控制台输出改为写入文件,就需要一个新的
FileLogger
类。届时,我们必须修改UserService
的内部代码,将new Logger()
改为new FileLogger()
。如果项目中有几十个地方都依赖Logger
,就需要修改几十个文件,这简直是场灾难。 - 复用性低:
UserService
和Logger
被“锁死”在了一起,降低了它们在不同环境下的复用可能性。
依赖注入 (Dependency Injection) 正是为了解决这种紧耦合问题而生的设计模式。
2. 依赖注入概述
控制反转
在理解 DI 之前,需要先了解一个更宏观的概念:控制反转 (Inversion of Control, IoC)。
- 常规控制流程:
UserService
主动去创建和控制它所依赖的Logger
对象。 - 控制反转:将创建和控制
Logger
的权力从UserService
手中“反转”出去,交给外部的第三方(注入器或容器)。UserService
不再主动创建依赖,而是被动地接收它所需要的依赖。
IoC 的好莱坞原则:“Don’t call us, we’ll call you.”(不要给我们打电话,我们会打给你。)
对应到我们的例子中就是:
UserService
不再主动new Logger()
,而是等待外部环境把一个可用的logger
实例“送”给它。
依赖注入 (DI) 是实现控制反转最主要、最常见的一种方式。
定义
DI 的核心原则是:一个类不应该在内部创建它所依赖的对象,而应该由外部来提供这些依赖。
这个过程涉及三个角色:
- 消费者 (Consumer):需要依赖的类(例如
UserService
)。 - 服务 (Service):被依赖的类(例如
Logger
)。 - 注入器 (Injector):负责创建“服务”实例,并将其“注入”到“消费者”中。
3. 依赖注入实现
在 JavaScript 中,主要有以下两种实现 DI 的方式:
构造函数注入
这是最常用、也是最推荐的一种方式。依赖通过构造函数参数传入。
class Logger {
log(message) {
console.log(`[LOG]: ${message}`);
}
}
class UserService {
// 依赖通过构造函数参数传入
constructor(logger) {
this.logger = logger;
}
create(user) {
// ...创建用户的逻辑...
this.logger.log(`User ${user.name} created.`);
}
}
// --- 注入器 (Injector) 的角色 ---
// 在应用的入口处或高层模块中
const logger = new Logger(); // 1. 创建服务实例
const userService = new UserService(logger); // 2. 将服务注入消费者
userService.create({ name: 'Alice' });
优点:
- 依赖清晰:构造函数签名直接表明了该类需要哪些依赖。
- 状态稳定:一旦对象被创建,其依赖就已完备且不可变,保证了对象始终处于一个有效的状态。
缺点:
- 如果依赖过多,构造函数参数列表会变得很长。
Setter 注入
通过一个公开的 setter
方法来注入依赖。
class Logger {
log(message) {
console.log(`[LOG]: ${message}`);
}
}
class UserService {
// 构造函数不再需要依赖
constructor() {}
// 提供一个 setter 方法来接收依赖
setLogger(logger) {
this.logger = logger;
}
create(user) {
if (!this.logger) {
console.error("Logger not set!");
return;
}
// ...创建用户的逻辑...
this.logger.log(`User ${user.name} created.`);
}
}
// --- 注入器 (Injector) 的角色 ---
const logger = new Logger();
const userService = new UserService();
userService.setLogger(logger); // 在对象创建后,通过 setter 方法注入
userService.create({ name: 'Alice' });
优点:
- 灵活性高:可以随时更换依赖,适用于可选的或可变的依赖。
- 避免了构造函数参数过多的问题。
缺点:
- 状态不确定:在调用
setter
方法之前,对象可能处于一个不完整的状态。 - 可能会忘记注入依赖,导致运行时错误。
4. 依赖注入容器 (DI Container)
在大型应用中,依赖关系可能非常复杂。手动创建和注入每一个依赖会变得非常繁琐和容易出错。这时,DI 容器就派上了用场。
DI 容器是一个管理和自动化依赖注入过程的工具。它的工作方式通常如下:
- 注册 (Register):你告诉容器如何创建各种服务(例如,
Logger
应该是一个单例)。 - 解析 (Resolve):当你需要一个服务的实例(例如
UserService
)时,你向容器请求它。 - 注入 (Inject):容器会自动创建
UserService
,并查找其构造函数需要的依赖(Logger
),然后创建Logger
实例并注入到UserService
中,最后返回一个完全就绪的UserService
实例。
可以把 DI 容器想象成一个智能的“装配工厂”。我们把所有零件的设计图(类)都交给工厂(注册),并告诉它零件之间的组装关系(依赖)。当你需要一个成品(如一辆汽车)时,工厂会自动找到所有需要的零件(发动机、轮胎等),把它们组装好,然后把一辆完整的汽车交给你。
下面是一个极简的 DI 容器概念实现:
class DiContainer {
constructor() {
this.dependencies = {};
}
// 注册一个依赖
register(name, dependency) {
this.dependencies[name] = dependency;
}
// 获取一个依赖
get(name) {
if (!this.dependencies[name]) {
throw new Error(`Dependency not found: ${name}`);
}
return this.dependencies[name];
}
}
// --- 使用容器 ---
const container = new DiContainer();
// 注册服务
container.register('logger', new Logger());
container.register('userService', new UserService(container.get('logger')));
// 从容器中解析出最终需要的服务
const userService = container.get('userService');
userService.create({ name: 'Bob' });
在实际项目中,我们通常会使用成熟的 DI 框架,如 InversifyJS
、TSyringe
(主要用于 TypeScript) 或 Awilix
。这些框架提供了更强大的功能,如自动装配、生命周期管理(单例、瞬态)等。