几种依赖注入的使用场景 - InversifyJS为例
依赖注入不仅仅是一个让代码看起来“高级”的工具,它的核心价值在于解耦。通过将对象的“创建权”从业务逻辑中剥离并交给容器,我们能获得极高的灵活性。
关于依赖注入相关概念可参考依赖注入的艺术:编写可扩展 JavaScript 代码的秘密
以下是依赖注入最具代表性的五个使用场景。
1. 单元测试 (Unit Testing)
痛点: 当业务类直接 new 依赖时,测试该类就必须执行依赖的真实逻辑(如真实扣款、真实写库),导致测试缓慢且危险。
❌ 方式 A:不使用 InversifyJS (强耦合)
OrderService 内部强行依赖了 RealPayment。要测试 checkout 方法,你必须真的发起支付,无法轻松 Mock。
TypeScript
// 具体的支付实现
class RealPayment {
pay(amount: number) {
console.log(`$$$ 调用银行接口扣款: ${amount}`); // 真实副作用
}
}
class OrderService {
private payment: RealPayment;
constructor() {
// 😱 致命缺陷:硬编码依赖,测试时无法替换!
this.payment = new RealPayment();
}
checkout(amount: number) {
this.payment.pay(amount);
}
}
✅ 方式 B:使用 InversifyJS (依赖抽象)
业务类只依赖接口 IPayment。在单元测试中,我们可以通过容器绑定一个 MockPayment,轻松隔离副作用。
TypeScript
// 1. 定义接口
interface IPayment { pay(amount: number): void; }
// 2. 业务逻辑 (只依赖接口)
@injectable()
class OrderService {
constructor(
@inject(TYPES.Payment) private payment: IPayment // 注入接口
) {}
checkout(amount: number) {
this.payment.pay(amount);
}
}
// --- 单元测试文件 spec.ts ---
const testContainer = new Container();
// 🧪 测试时:绑定 Mock 实现
const mockPayment = {
pay: jest.fn() // 使用 Jest 等测试库的 Mock 函数
};
testContainer.bind(TYPES.Payment).toConstantValue(mockPayment);
testContainer.bind(OrderService).toSelf();
const service = testContainer.get(OrderService);
service.checkout(100);
// 断言:验证是否调用了 mock 方法,而不是真的扣款
expect(mockPayment.pay).toHaveBeenCalledWith(100);
2. 可替换的组件 (Swappable Components)
痛点: 同一个接口有多种实现(例如:存储策略既有本地存储,又有云存储)。传统写法通常伴随着大量的 if-else 或工厂模式代码。
❌ 方式 A:不使用 InversifyJS (工厂模式/条件判断)
调用者需要知道具体的实现类,且扩展新策略时需要修改工厂代码。
TypeScript
class LocalStorage { save() { console.log("存硬盘"); } }
class CloudStorage { save() { console.log("存 AWS S3"); } }
class FileManager {
private storage: any;
constructor(type: string) {
// 😱 违反开闭原则:每次加新策略都要改这里
if (type === 'local') {
this.storage = new LocalStorage();
} else {
this.storage = new CloudStorage();
}
}
}
✅ 方式 B:使用 InversifyJS (命名绑定)
使用 @named 标签,可以在不修改业务逻辑代码的情况下,灵活注入不同的策略。
TypeScript
@injectable()
class FileManager {
constructor(
// ✨ 优雅:同时注入两种策略,按需使用
@inject(TYPES.Storage) @named("local") private local: IStorage,
@inject(TYPES.Storage) @named("cloud") private cloud: IStorage
) {}
backup() {
this.local.save(); // 先存本地
this.cloud.save(); // 再存云端
}
}
// --- 容器配置 ---
container.bind<IStorage>(TYPES.Storage).to(LocalStorage).whenTargetNamed("local");
container.bind<IStorage>(TYPES.Storage).to(CloudStorage).whenTargetNamed("cloud");
3. 跨环境运行 (Cross-Environment Execution)
痛点: 开发环境用 SQLite,生产环境用 PostgreSQL。如果不使用 DI,代码中会充斥着 process.env.NODE_ENV 的判断,导致代码混乱。
❌ 方式 A:不使用 InversifyJS (环境判断污染逻辑)
TypeScript
class DatabaseService {
constructor() {
// 😱 环境配置逻辑泄漏到了业务类中
if (process.env.NODE_ENV === 'production') {
this.connection = new PostgresConnection();
} else {
this.connection = new SqliteConnection();
}
}
query() {
return this.connection.exec("SELECT * FROM users");
}
}
✅ 方式 B:使用 InversifyJS (容器模块化配置)
业务代码完全干净,环境切换的逻辑被移到了容器配置层(Composition Root)。
TypeScript
// 1. 业务代码 (完全不知道当前是什么环境)
@injectable()
class DatabaseService {
constructor(@inject(TYPES.DbConnection) private conn: IDbConnection) {}
}
// 2. 环境配置模块 (config.ts)
const devModule = new ContainerModule((bind) => {
bind<IDbConnection>(TYPES.DbConnection).to(SqliteConnection);
});
const prodModule = new ContainerModule((bind) => {
bind<IDbConnection>(TYPES.DbConnection).to(PostgresConnection);
});
// 3. 入口文件 (index.ts)
const container = new Container();
if (process.env.NODE_ENV === 'production') {
container.load(prodModule); // 🏭 加载生产模块
} else {
container.load(devModule); // 🛠️ 加载开发模块
}
4. 插件式架构 (Plugin Architecture)
痛点: 系统核心需要加载第三方插件。如果不使用 DI,核心系统必须手动 import 并实例化插件,这使得动态扩展变得极其困难。
❌ 方式 A:不使用 InversifyJS (手动列表)
核心代码必须“认识”每一个插件。
TypeScript
import { GitPlugin } from "./plugins/git";
import { DockerPlugin } from "./plugins/docker";
class App {
private plugins: any[] = [];
constructor() {
// 😱 扩展性差:想加个插件,还得改核心代码的构造函数
this.plugins.push(new GitPlugin());
this.plugins.push(new DockerPlugin());
}
run() {
this.plugins.forEach(p => p.exec());
}
}
✅ 方式 B:使用 InversifyJS (多重注入 Multi-Injection)
核心系统定义接口,插件自行注册到容器。核心系统自动获取所有符合接口的插件。
TypeScript
// 核心系统
@injectable()
class App {
private plugins: IPlugin[];
constructor(
// ✨ 魔法:自动把容器里所有绑定为 TYPES.Plugin 的实例都注入进来,形成数组
@multiInject(TYPES.Plugin) plugins: IPlugin[]
) {
this.plugins = plugins;
}
}
// 插件 A (独立文件)
bind<IPlugin>(TYPES.Plugin).to(GitPlugin);
// 插件 B (独立文件)
bind<IPlugin>(TYPES.Plugin).to(DockerPlugin);
// 这种模式下,新增插件只需要 bind 一下,不需要修改 App 类的任何代码。
5. 复杂的生命周期管理 (Singleton vs Transient)
痛点: 某些对象(如缓存、数据库连接池)必须是全局单例,而某些对象(如 HTTP 请求上下文)必须每次新建。手动管理这些单例模式非常容易出错。
❌ 方式 A:不使用 InversifyJS (手动单例模式)
开发者必须手动实现 Singleton 模式,代码啰嗦且难以维护。
TypeScript
class CacheService {
private static instance: CacheService;
// 😱 样板代码:每个单例类都要写这一坨逻辑
private constructor() {}
public static getInstance(): CacheService {
if (!CacheService.instance) {
CacheService.instance = new CacheService();
}
return CacheService.instance;
}
}
// 使用时必须小心
const cache = CacheService.getInstance();
✅ 方式 B:使用 InversifyJS (声明式生命周期)
类本身不需要知道自己是不是单例,全靠容器配置。
TypeScript
@injectable()
class CacheService {
constructor() { console.log("CacheService Created"); }
}
@injectable()
class RequestHandler {
constructor() { console.log("RequestHandler Created"); }
}
// --- 容器配置 ---
// 1. 单例:整个应用只创建一次
container.bind(CacheService).toSelf().inSingletonScope();
// 2. 瞬态:每次请求都创建新的
container.bind(RequestHandler).toSelf().inTransientScope();
// --- 运行结果 ---
const cache1 = container.get(CacheService);
const cache2 = container.get(CacheService);
// 输出: "CacheService Created" (只输出一次,cache1 === cache2)
const handler1 = container.get(RequestHandler);
const handler2 = container.get(RequestHandler);
// 输出: "RequestHandler Created" (输出两次,handler1 !== handler2)