了解依赖注入如何让代码更易测试、重构和扩展。探索实用模式、示例以及常见陷阱和规避方法。

依赖注入(DI)是一个简单的想法:不要让一段代码去创建它需要的东西,而是从外面把这些东西给它。
这些“它需要的东西”就是它的依赖——例如数据库连接、支付服务、时钟、日志器或邮件发送器。如果你的代码自己去构建这些依赖,就会悄悄锁定这些依赖的实现方式。
想象办公室里的咖啡机。它依赖水、咖啡豆和电力。
DI 就是第二种方式:“咖啡机”(你的类/函数)只专注于做咖啡(它的职责),而“耗材”(依赖)由设置它的人来提供。
DI 不是必须使用某个特定框架,也不是等同于 DI 容器。你可以手动通过参数(或构造函数)传入依赖来实现 DI。
DI 也不是“mock(模拟)”。模拟只是测试中利用 DI 的一种方式,但 DI 本身只是关于在哪里创建依赖的设计选择。
当依赖从外部提供时,你的代码在不同上下文下运行变得容易:生产环境、单元测试、演示或未来功能。
这种灵活性也让模块更干净:可以在不重写整个系统的情况下替换某个部分。结果是测试更快、更清晰(因为可以替换为简单的替身),代码库也更容易更改(因为各部分耦合更少)。
紧耦合发生在代码的一部分直接决定它必须使用哪些其他部分时。最常见的形式很简单:在业务逻辑里直接调用 new。
想象一个结账函数内部做了 new StripeClient() 和 new SmtpEmailSender()。一开始看起来方便——所需的一切都在这里。但这也把结账流程锁定到这些具体实现、它们的配置细节,甚至创建规则(API 密钥、超时、网络行为)。
这种耦合是“隐藏的”,因为它不在方法签名里显现。函数看起来只是处理订单,但它实际上依赖支付网关、邮件提供商,可能还有数据库连接。
当依赖被硬编码时,小改动也会引起连锁反应:
硬编码的依赖会让单元测试执行真实工作:网络调用、文件 I/O、时钟、随机 ID 或共享资源。测试变慢因为不隔离,变得不稳定因为结果依赖时序、外部服务或执行顺序。
如果你看到这些模式,紧耦合可能已经在消耗你的时间:
new依赖注入通过让依赖显式且可替换来解决这些问题——不需要每次世界变化都重写业务规则。
控制反转(IoC)是一个职责的简单转移:类应该关注它需要做什么,而不是如何获得它需要的东西。
当一个类自行创建依赖(例如 new EmailService() 或直接打开数据库连接)时,它无形中承担了两项任务:业务逻辑和构建/设置。这让类更难更改、更难重用、更难测试。
有了 IoC,你的代码依赖于抽象——像接口或小的“契约”类型——而不是具体实现。
例如,CheckoutService 不需要知道支付是通过 Stripe、PayPal 还是一个测试假实现处理的。它只需要“能扣款的东西”。如果 CheckoutService 接受一个 IPaymentProcessor,只要实现遵循该契约,它就能工作。
这样即便底层工具改变,你的核心逻辑也保持稳定。
IoC 的实际做法是把依赖的创建移出类并把它们传入(通常通过构造函数)。这正是依赖注入(DI)常见的实现方式。
替代:
结果是灵活性:行为的替换成为配置决策,而不是重写。
如果类不创建依赖,必须有别处来做这件事。这个“别处”就是组合根:应用被组装的地方——通常在启动代码中。
组合根决定:“生产环境使用 RealPaymentProcessor;测试使用 FakePaymentProcessor。”把装配集中在一处可以减少意外,让代码库的其余部分专注于行为。
IoC 让单元测试更简单,因为你可以提供小而快速的测试替身,而不是调用真实网络或数据库。
它也让重构更安全:当责任分离时,只要抽象不变,改变实现往往不需要改动使用它的类。
依赖注入并不是一种技术——它是一组把依赖“喂给”类的方法(比如日志、数据库客户端或支付网关)。你选的风格影响代码的清晰度、可测试性和被滥用的风险。
通过构造函数注入,依赖在创建对象时就是必须的。大优点是:你不可能忘记它们。
适用于依赖:
构造函数注入往往产生最清晰的代码和最直接的单元测试,因为测试可以在创建时传入假对象或 mock。
有时依赖只在某个操作中被需要——例如临时格式化器、特殊策略或请求作用域的值。
这种情况下把它作为方法参数传入。这样可以让对象更小,避免把一次性需求提升为永久字段。
当确实无法在构造时提供依赖(某些框架或遗留代码路径)时,setter 注入很方便。但其代价是它会隐藏要求:类看起来可用,即便没有完全配置好。
这常常导致运行时惊讶(“为什么这是 undefined?”)并使测试更脆弱,因为容易漏掉设置步骤。
当单元测试依赖真实数据库、网络调用、文件系统或时钟时,它们往往变慢并变得不稳定。更糟的是,失败信息不再直接:是代码坏了,还是环境出现问题?
DI 通过让代码接受外部依赖(数据库访问、HTTP 客户端、时间提供者)来解决这一点。在测试中,你可以把这些依赖替换为轻量的替身。
真实的 DB 或 API 调用会增加设置时间与延迟。有了 DI,你可以注入一个内存仓库或一个返回预设响应的假客户端。那意味着:
没有 DI 的代码常常“new()”它自己的依赖,迫使测试去执行整个栈。有了 DI,你可以注入:
无需黑魔法或全局开关——只需传入不同实现。
DI 让设置变得显式。你不必去翻配置、连接字符串或测试专用环境变量;直接读一个测试就能看到什么是真实的、什么是替换的。
一个典型的 DI 友好测试读起来像:
Arrange:使用假仓库和存根时钟创建服务
Act:调用方法
Assert:检查返回值和/或验证 mock 的交互
这种直接性减少了噪音,让失败更容易诊断——正是单元测试所要的。
测试缝是你在代码中刻意保留的“开口”,用来交换行为。在生产中插入真实实现;在测试中插入更安全、更快的替代。
缝对难以在测试中控制的系统部分最有用:
如果业务逻辑直接调用这些东西,测试就会脆弱:失败常常由与业务逻辑无关的原因引起(网络抖动、时区差、缺文件),并且难以快速运行。
缝常以接口形式存在——在动态语言中可能只是一个“契约”,比如“这个对象必须有一个 now() 方法”。关键是依赖于所需的行为,而不是它来自哪里。
例如,不在订单服务中直接调用系统时钟,而是依赖一个 Clock:
SystemClock.now()FakeClock.now() 返回固定时间同样模式也适用于文件读取(FileStore)、发送邮件(Mailer)或扣款(PaymentGateway)。核心逻辑保持不变,只有插入的实现不同。
当你可以有意替换行为时:
放好缝后你不必到处 mock;只需少数几个干净的替换点,就能保持单元测试快速、聚焦、可预测。
模块化意味着软件由独立部分(模块)组成,边界清晰:每个模块有明确职责和与外界交互的方式。
DI 通过让这些边界显式化来支持模块化。模块不去“构造”或“查找”它所需的一切,而是从外部接收依赖。这个小改变减少了模块之间的认知耦合。
当代码内部构造依赖(例如在服务中 new 一个数据库客户端),调用者与依赖紧密耦合。DI 鼓励你依赖接口(或简单契约),而非具体实现。
这意味着模块通常只需要知道:
PaymentGateway.charge())因此模块一起改变的频率降低,因为内部细节不再泄漏到边界之外。
模块化的代码库应该允许你替换某个组件而不重写所有使用它的代码。DI 让这变得可行:
在每种情况下,调用方继续使用相同契约,只有在组合根的装配发生变化。
清晰的依赖边界使团队并行工作更容易。一个团队可以在约定的接口后面构建新实现,而另一个团队继续开发依赖该接口的功能。
DI 还支持增量重构:你可以抽取模块、注入它并逐步替换——无需一次性大范围重写。
看到代码中的依赖注入比任何定义都更直观。下面用通知功能展示一个小的“前后对比”。
当类在内部调用 new 时,它决定了要使用哪个实现以及如何构建它。
class EmailService {
send(to, message) {
// talks to real SMTP provider
}
}
class WelcomeNotifier {
notify(user) {
const email = new EmailService();
email.send(user.email, "Welcome!");
}
}
测试痛点: 单元测试可能触发真实的邮件行为(或需要尴尬的全局补丁)。
test("sends welcome email", () => {
const notifier = new WelcomeNotifier();
notifier.notify({ email: "[email protected]" });
// Hard to assert without patching EmailService globally
});
现在 WelcomeNotifier 接受任何符合所需行为的对象。
class WelcomeNotifier {
constructor(emailService) {
this.emailService = emailService;
}
notify(user) {
this.emailService.send(user.email, "Welcome!");
}
}
测试变得小、快且显式。
test("sends welcome email", () => {
const fakeEmail = { send: vi.fn() };
const notifier = new WelcomeNotifier(fakeEmail);
notifier.notify({ email: "[email protected]" });
expect(fakeEmail.send).toHaveBeenCalledWith("[email protected]", "Welcome!");
});
想要之后支持 SMS?你不用改 WelcomeNotifier。只需传入不同的实现:
const smsService = { send: (to, msg) => {/* SMS provider */} };
const notifier = new WelcomeNotifier(smsService);
实际收益就是:测试不再与构建细节作斗争,新增行为通过替换依赖而不是重写现有代码来完成。
依赖注入可以简单到“把你需要的东西传入使用它的东西”。那是手动 DI。DI 容器是一种自动化布线的工具。两者都有用——关键是选择与应用相匹配的自动化水平。
使用手动 DI,你自己创建对象并通过构造函数(或参数)传递依赖。优点:
手动布线还会强迫良好设计:如果一个对象需要七个依赖,你会立刻感到痛——这通常提示需要拆分责任。
随着组件数量增多,手动布线会变成重复的“管道”工作。DI 容器可以通过以下方式帮助:
容器在边界和生命周期清晰的应用中表现优秀——比如 Web 应用、长运行服务或许多功能依赖共享基础设施的系统。
容器可能让高度耦合的设计看起来很整洁,因为布线消失了,但潜在问题仍存在:
如果引入容器让代码可读性下降,或者开发者不再清楚谁依赖谁,那就可能过度使用了。
刚开始用手动 DI 来保持显式性,在你划分模块时加深理解。当布线变得重复或生命周期管理变得棘手时,再引入容器。
一个实用规则:在核心/业务代码中使用手动 DI,在应用边界(组合根)可选地使用容器来组装一切。这样既保持设计清晰,又在项目增长时减少样板。
DI 可以让代码更易测试和更易变更——但前提是有纪律地使用。以下是常见的失败方式和可行的习惯。
如果一个类需要很长的依赖列表,往往说明它做了太多。这不是 DI 的失败,而是 DI 暴露了设计异味。
经验法:如果不能用一句话描述类的职责,或者构造函数不断长大,考虑拆分类、抽取更小的协作者,或将相关操作封装到一个接口后面(谨慎——别造“上帝服务”)。
服务定位器模式看起来像在业务代码里调用 container.get(Foo)。表面上便利,但它让依赖不可见:无法通过构造函数判断类需要什么。
测试变得更难,因为你必须配置全局状态(定位器)而不是显式地传入一组替身。优先使用显式传参(构造函数注入最直接)让测试能有意地构建对象图。
DI 容器在运行时可能出错:
这些问题令人沮丧,因为它们只在装配时出现。
保持构造函数短而聚焦。如果依赖列表增长,把它视为重构提示。
为装配添加集成测试。即使是一个轻量的“组合根”测试,能在早期发现缺失注册和循环依赖。
最后,把对象创建集中在一个地方(通常是应用启动/组合根),并把 DI 容器调用从业务逻辑中剥离。这样就能保留 DI 的主要收益:清晰地知道谁依赖谁。
把 DI 当成一系列小的、低风险的重构来采用。从测试慢或不稳定、变更经常波及无关代码的地方开始。
寻找让代码难测或难以理解的依赖:
如果一个函数不能在进程外部依赖下运行,通常就是好候选。
new 或直接调用的外部依赖。该方法使每次变更都可审查,并允许你在任一步骤停止而不破坏系统。
DI 可能无意中把代码变成“所有东西都相互依赖”。
一个好规则:注入能力,而不是细节。例如注入 Clock,而不是注入“SystemTime + TimeZoneResolver + NtpClient”。如果一个类需要五个彼此无关的服务,可能职责划分错了——考虑拆分。
此外,避免把依赖“传来传去”地穿透多层,只在实际使用处注入;把装配集中在一处。
如果使用代码生成器或“快速上手”工作流来快速生成特性,DI 会更有价值,因为它能在项目增长时保持结构。例如,当团队用 Koder.ai 从聊天驱动的规范创建 React 前端、Go 服务和 PostgreSQL 后端时,保持清晰的组合根和 DI 友好的接口有助于确保生成的代码继续易于测试、重构并能替换集成(邮件、支付、存储),而无需重写核心业务逻辑。
原则依旧:把对象创建和环境相关的装配放在边界处,让业务代码专注于行为。
你应该能指出具体的改善:
下一步建议:记录你的“组合根”,并保持它简单:一个文件负责编排依赖,而其余代码专注行为。
依赖注入(DI)意味着你的代码从外部接收它需要的东西(数据库、日志、时钟、支付客户端),而不是在内部创建它们。
在实践中,这通常表现为把依赖通过构造函数或函数参数传入,这样它们就是显性的且可替换的。
控制反转(IoC)是一个更广泛的概念:类应该专注于它要做什么,而不是如何获取它的协作者。
依赖注入是实现 IoC 的常用技术,通过把依赖的创建移到外部并传入来达成这一点。
如果在业务逻辑里用 new 创建依赖,就很难替换该依赖。
这会导致:
DI 让测试保持快速且确定性强,因为可以注入测试替身而不是使用真实的外部系统。
常见的替换有:
DI 容器是可选的。开始时使用手动 DI(显式传依赖)适合以下情况:
当布线变得重复或需要生命周期管理(单例/每次请求/瞬态)时,可以考虑引入容器。
当依赖是对象能正常工作的必须条件且跨多个方法使用时,优先使用构造函数注入。
当依赖只用于一次调用或请求域值时,使用方法/参数注入。
除非确实需要晚期装配,否则尽量避免setter/属性注入;如果必须使用,记得加上快速失败的校验。
组合根是你组装应用的地方:在这里创建具体实现并把它们传给需要的服务。
把它放在应用启动点附近(入口文件),这样其余代码只关注行为,不关心装配细节。
测试缝(test seam)是一个刻意设计的点,用来交换行为。
适合放缝的地方通常是难以在测试中控制的部分:
Clock.now())DI 通过允许在测试中注入替代实现来创建这些缝。
常见错误包括:
container.get() 会隐藏真实依赖,优先使用显式参数。可以按小步骤安全引入 DI:
这个过程可重复对下一个缝点做同样改造,中途暂停不会破坏系统。