写 Angular 项目写了几年之后回头看,我越来越觉得**依赖注入(DI)**才是这整个框架真正让人"上瘾"的地方。组件、指令、管道这些表层语法,两三天就能上手,但一旦你开始设计一个多模块的大型应用,就会发现自己时刻都在跟"谁能拿到哪个服务"打交道。Angular 的 DI 系统配合模块化架构,提供了一整套管理依赖的规则,做到了很多框架想做但没做彻底的事。
这篇文章我想从一个实际开发的视角,把 Angular 依赖注入系统和模块化架构的核心机制拆开来讲。不追求把官方文档复述一遍,而是把"注入器怎么分级、Provider 几种写法各自用在什么场景、NgModule 在 DI 里到底起了什么作用、实际项目里最容易踩哪些坑"这些问题一次性讲清楚。适合有一定 Angular 基础、但还没完全理解 DI 底层逻辑的开发者,也适合想在项目里把依赖管理做得更干净的人。
1. 为什么 Angular 要设计依赖注入:从手动 new 服务说起
要理解 DI 的价值,最直接的办法是先回到不用 DI 的日子。
1.1 手动管理依赖的真实痛点
假设你要做一个订单列表组件,组件里需要调用订单服务获取数据。在早期框架或者不使用 DI 的写法下,你可能直接在组件里写:
export class OrderListComponent { private orderService: OrderService; constructor() { this.orderService = new OrderService(); } }这段代码初看没什么问题。但放到真实项目里,很快会有下面这些需求找上门:OrderService 的构造函数里可能需要一个 API 基础路径,这个路径在不同环境(开发、测试、生产)下不一样;可能需要替换成 Mock 服务来做单元测试;可能某些模块里要用加了缓存的订单服务,另一些模块里用不带缓存的版本。
如果是"组件内直接 new"的写法,每换一个环境,你就要打开每一个使用了 OrderService 的组件,找到 new 的那一行,改参数或者换类。组件不只是组件了,它变成了服务装配工厂,必须知道服务有多少依赖、这些依赖从哪来、要不要传参数。而且这种耦合是藏在构造函数里的——代码不会报错,但架构上已经是一团浆糊。
1.2 依赖倒置与依赖注入的原理
这里涉及的第一个原则叫依赖倒置原则:高层模块(组件)不应该依赖低层模块(服务)的实现细节,而应该依赖抽象。DI 是让这个原则落地的手段——组件不负责创建服务,而是通过构造函数声明自己需要什么服务,由框架在运行时把这些服务"注入"进来。
Angular 实现这套机制靠的是三个核心角色:
- 注入器(Injector):负责创建和维护服务的实例。整个应用有一套层级化的注入器体系。
- Provider:告诉注入器"某个令牌应该用哪种方式创建实例",比如用哪个类、用什么工厂函数。
- 注入令牌(InjectionToken):用来标识一个依赖的唯一名称。最常见的是类本身,也可以用自定义令牌。
组件声明自己的依赖,其实就是在构造函数参数里写上类型或者令牌,比如:
constructor(private orderService: OrderService) {}这里 TypeScript 的类型信息会被 Angular 编译成ctorParameters元数据,框架读取这段元数据,就知道需要给这个组件注入一个 OrderService,然后沿着注入器层级查找,找到对应的 Provider 创建实例,完成注入。
1.3 从"主动索取"到"被动交付"的思维转变
理解 DI 最需要转变的一个观念是:组件和服务的角色完全对调了。
在非 DI 模式下,组件是主动方,自己创建自己需要的服务;在 DI 模式下,组件是被动方,它只负责"描述需求",至于服务是谁创建的、生命周期怎样、是单例还是新实例,组件一概不关心。这种"被动交付"的架构带来的实际好处非常直接:
第一是可替换性。测试时可以给某个组件所在的注入器覆盖 Provider,用 Mock 服务替换真实服务,甚至不用改组件代码。模块之间集成时,某个服务在新模块里要扩展职责,也可以通过 Provider 配置重定向到包装类。
第二是可配置性。同一个服务类可以在不同模块里用不同配置实例化。比如日志服务,在用户模块里输出级别是 info,在支付模块里输出级别是 error,分别在不同的 Module 里配置 useFactory 就可以了。
第三是依赖关系清晰化。构造函数参数本身就是一份依赖清单,代码可读性大大提升,不再需要去追 new 关键字。
2. 三大核心抽象:注入器、Provider、注入令牌如何协同
这一节是整个 DI 系统的主干。很多教程会直接讲"怎么写",但如果不理解这三个抽象本身,遇到问题往往不知道怎么排查。
2.1 注入器与注入器层级
Angular 里的注入器不是只有一个,而是一棵层级树。从根到叶子大致是这样:
- 平台注入器(Platform Injector):应用启动时创建,负责一些浏览器运行时的单例服务。
- 根注入器(Root Injector):对应整个 NgModule 或应用级 Provider。
- 模块注入器(Module Injector):每个 NgModule 对应一个,懒加载模块还有自己独立的注入器。
- 组件注入器(Component Injector):每个组件实例对应一个,会随着组件销毁一起被回收。
子级注入器在查找依赖时,会先在自身级别的 Provider 里找,找不到就向父级注入器逐层查找,直到根注入器。如果层级树里完全没有这个 Provider,Angular 会抛出经典的NullInjectorError。
这套树形结构的精妙之处在于作用域控制。同样是 OrderService,如果配置在根注入器里,整个应用共享一个实例;如果配置在某一个懒加载模块的注入器里,那么这个模块内的所有组件共享一个实例,而模块外的组件拿不到;如果配置在组件级注入器里,那么这个组件及其子组件各自拿到实例,组件树之外的代码完全不受影响。
我把注入器层级理解为"服务分发中心"的仓储体系:根注入器是总仓,模块注入器是地区仓,组件注入器是门店仓。查找依赖就像系统按门店仓 → 地区仓 → 总仓的顺序找货,门店仓找不到才去上级仓调货。
2.2 Provider 配置的四种写法
Provider 是告诉注入器"怎么创建服务"的配置。Angular 提供了四种常用写法,每一种都有明确的适用场景。
2.2.1 useClass:类映射的默认方式
providers: [ { provide: OrderService, useClass: OrderService } ]这是最常见的形式,简写就是OrderService。它的真正价值在于可以把具体实现映射到另一个类。例如你有UserService和MockUserService,测试环境下可以写:
providers: [ { provide: UserService, useClass: MockUserService } ]2.2.2 useValue:注入固定值或配置对象
providers: [ { provide: APP_CONFIG, useValue: { apiBase: '/api/v2', timeout: 30000 } } ]useValue适合注入一个已经创建好的对象、常量或者第三方库实例。它的运行成本最低,因为不需要经过类的实例化过程。
2.2.3 useFactory:动态创建策略
providers: [ { provide: LoggerService, useFactory: (env: EnvConfig) => { if (env.production) { return new ProductionLogger(); } return new DevLogger(); }, deps: [EnvConfig] } ]useFactory的价值是可以在实例化之前做条件判断、读取配置、组合多个依赖。注意deps数组必须列出工厂函数所需的全部依赖,Angular 会先创建这些依赖,再调用工厂函数。
2.2.4 useExisting:把别名指向已有 Provider
providers: [ { provide: Logger, useExisting: LoggerService } ]useExisting不创建新实例,只是把旧令牌指向已注册的服务。这在"老代码用 Logger,新代码用 LoggerService"的迁移过程中特别有用,两个令牌拿到的是同一个实例。
2.3 令牌:类令牌与 InjectionToken
服务令牌最常见的是类本身,这也是 Angular 默认的做法。但要注入非类的对象,比如配置项、常量、枚举,类令牌就不合适了——JavaScript 里没有可以当作类作为常量标识符的东西。
Angular 为此提供了InjectionToken:
export const APP_CONFIG = new InjectionToken<AppConfig>('app.config'); // 使用时 @Injectable() export class ConfigService { constructor(@Inject(APP_CONFIG) private config: AppConfig) {} }InjectionToken的本质是一个带有唯一标识符的封装对象。只要provide时用的令牌对象和@Inject时用的令牌对象是同一个引用,就能正确匹配。它的额外好处是:即使两个同名字符串(比如 'config')在不同模块中定义,也不会冲突,因为引用的对象不同。
在实际项目里,我基本上遵循这条规律:服务类用类令牌,配置对象、常量、接口类型使用 InjectionToken。用类做令牌还有一个附带作用——Angular 可以自动通过 TypeScript 编译后的元数据推断依赖,省去手动@Inject的步骤,代码更简洁。
3. 模块化架构中 DI 的落地:NgModule 把"可配置性"推向全局
DI 如果只是停留在组件层面,上面这些机制就还只是"方便"层面的工具。Angular 真正的架构威力,是把 DI 跟模块化设计绑定在一起。NgModule 在这里扮演了"依赖提供的中转站"的角色。
3.1 Module 的 providers 区域与依赖可见性
每个 NgModule 都有一个providers数组,用于声明该模块向注入器注册哪些服务。模块之间的imports和exports关系,决定了这些服务在哪些范围内可见。
这里有一个初学者经常搞混的点:模块的exports只导出组件、指令、管道,不导出服务。服务不属于模板编译的范围,它只属于注入器。也就是说,你在 A 模块的 providers 里配置了某个服务,B 模块即使 import 了 A 模块,B 里的组件也不一定拿得到这个服务——这取决于 A 模块是否被恰好加载到同一个根注入器的关联范围内。
具体规则是这样的:
- 根模块(AppModule)里配置的 Provider 是全局可见的。
- 被根模块直接或者间接 import 的普通模块,它们的 Provider 会合并到根注入器,全局可见。
- 懒加载模块里配置的 Provider,只会注册到该懒加载模块自己的注入器中,根模块及其他模块的组件拿不到。
- 多个普通模块同时提供同一个令牌时,后 import 的模块会覆盖先 import 的配置。
这个特性一半是便利一半是陷阱。便利在于你可以在根模块里集中配置全局服务;陷阱在于懒加载模块的隔离性如果没理解透,就会出现"模块 A 里能注入 UserService,模块 B 里却报 NullInjectorError"的诡异现象。
3.2 懒加载与新注入器
Angular 的路由懒加载机制和 DI 注入配合得特别紧密。当某个路由对应的模块被异步加载进来时,Angular 会为这个模块创建一套新的注入器,挂到平台注入器下,作为根注入器的子级。
这意味着懒加载模块里配置的服务不会污染根注入器。对于那种"只有进入支付模块才需要创建支付网关服务"的场景,把服务配置在支付模块的 providers 里,可以让这个服务只在进入支付模块时才被创建,其他时候不占内存。
反面教训是:如果你在根模块里 import 了一个 Provider 放在自己的 providers 里的模块,而不是走懒加载,那么这些服务会被提升到根注入器,变成全局单例。这个行为常常违背直觉——你以为自己是在模块范围内做了隔离,实际上变成了全局资源共享。排查线上内存问题时,这是一个很常见的原因。
// 路由懒加载的正确写法 const routes: Routes = [ { path: 'payment', loadChildren: () => import('./payment/payment.module').then(m => m.PaymentModule) } ];3.3 providedIn: 'root' 与摇树优化
前面讲的都是通过模块的providers数组来注册服务。Angular 还提供了另一种更现代化的注册方式——在服务类上声明providedIn:
@Injectable({ providedIn: 'root' }) export class UserService {}这种方式的核心优势是摇树优化(tree-shaking)。当服务不在任何地方被注入时,构建工具可以直接把这个类的代码从最终 bundle 中删除,从而缩小打包体积。
对照来看,模块providers数组里的服务默认是"无条件注册"的,只要模块被打包进去,服务就会跟着存在;而providedIn: 'root'是"按需注册"——只有真正有组件或者其他服务注入它时,代码才会被保留。这是现代 Angular 应用推荐使用providedIn: 'root'的一个关键原因。
当然,providedIn不只支持'root',还可以写成具体的模块名:
@Injectable({ providedIn: OrderModule }) export class OrderService {}这样表示服务只注册到指定模块的注入器,作用域就限定在该模块内。如果模块是懒加载的,服务也只在懒加载模块内生效。
上面这些机制合在一起,构成了 Angular 模块化架构下 DI 系统的完整闭环。搭建大型项目时,我的基本策略是:核心业务服务用providedIn: 'root',模块内部专用服务放在该模块的 providers 里,涉及跨模块共享的服务在根模块统一提供,这样既保持了隔离,又避免了全局膨胀。
4. 实际踩坑记录:DI 排查的两条硬经验
前面讲了原理和机制,这一节我想分享两个在实际项目中反复踩到的坑。这两个坑在网上讨论很多,但很多人都是踩了之后才真正理解 DI 的边界。
4.1 坑一:providedIn: 'root' 无法注入某些特定于渲染上下文的依赖
有一次我在一个组件里这样写:
@Injectable({ providedIn: 'root' }) export class OverlayService { constructor(private elementRef: ElementRef) {} }结果运行时报错说找不到 ElementRef 的 Provider。当时我第一反应是"ElementRef 是 Angular 内置的服务,怎么会找不到",然后去查了源码,才意识到问题的本质:ElementRef、Renderer2、ChangeDetectorRef 这类依赖是绑定到具体组件实例的,它们的生命周期跟组件注入器绑定。根注入器根本没有这些服务的 Provider,所以用providedIn: 'root'注册的全局服务自然注入不到。
解决办法很简单:把 OverlayService 放到组件级 providers 里注册。这样它就在组件注入器的上下文中创建,可以顺利拿到 ElementRef 等渲染相关依赖。后来我总结出的规则是:凡是需要用到组件上下文相关的依赖(ElementRef、TemplateRef、ViewContainerRef)的服务,永远不要放在根注入器里,放到使用它的组件 providers 中。
4.2 坑二:懒加载模块的双实例陷阱
还有一个更隐蔽的问题:如果一个服务既在根模块的 providers 里注册了,又在懒加载模块的 providers 里注册了,那么会出现两个实例。
具体场景是这样的:AppModule 里配置了CartStoreService,某懒加载模块里也配置了CartStoreService。表面上看 CartStoreService 应该全局唯一,但实际运行中,进入懒加载模块后,组件拿到的服务实例和普通模块组件拿到的不是同一个。因为懒加载模块有自己独立的注入器,子级注入器找不到依赖时向上查找,但它在自己这一级就找到了,所以不会继续向上。
问题就出现在这里:如果两个模块对 CartStoreService 的共享数据有依赖,购物车的数量不一致,状态撕裂。排查这类问题的方法很简单——先在根模块里全局提供,懒加载模块里不要重复注册;或者统一用providedIn: 'root'。使用providedIn: 'root'的好处是 Angular 会自动保证整个应用只有一个实例,不会因为模块加载顺序产生双实例。
我整理了一个排查 DI 相关 NullInjectorError 的基本思路,供参考:
- 确认服务是否在模块 providers 中注册;
- 确认服务是否有
providedIn属性; - 如果服务在懒加载模块中使用,检查它是否被全局的
providedIn: 'root'覆盖,还是在模块内注册了两次; - 检查服务本身的构造函数的依赖项是否都可解析,尤其注意 ElementRef 这类渲染上下文依赖;
- 去浏览器里的开发工具中查看注入器树,确认服务到底注册在哪一个注入器级别。
4.3 组件级 providers 与 viewProviders 的边界
组件装饰器里有两个配置项,providers 和 viewProviders,它们都作用于组件注入器,但有个关键差别:providers 注入的服务对子组件和投影组件可见;viewProviders 注入的服务只对本组件模板中出现的组件(不含投影内容)可见。
投影(ng-content)在这种情况下有很多人踩坑。你在某个容器组件里用了viewProviders注册服务,然后通过ng-content投影进来一个子组件,这个子组件是拿不到 viewProviders 里的服务的,但是能拿到 providers 里的服务。
从封装角度想,这其实是合理的——viewProviders 强调的是"模板内部封装",投影内容是宿主页面传入的,不应该自动获得内部实现的服务。但如果你没意识到这个边界,在做一个弹窗组件时把内部服务放在 viewProviders 里,投影出来的内容需要调用这个服务,就会报错。
5. 从"会用"到"会设计":DI 的架构思维
最后想聊一个超越语法层面的问题。DI 的机制本身学起来并不难,难的是你把它当成架构设计工具来用。
5.1 面向接口设计,用抽象令牌预留扩展
在看很多开源组件库源码时,我会注意到一个模式:核心逻辑不直接依赖具体实现,而是依赖一个InjectionToken之类的抽象。
举个例子,假设你做了一个消息提示组件库,里面的消息服务需要把消息渲染到屏幕上。如果组件库内部直接写死ToastRendererService,使用方想换成自己的渲染逻辑就必须侵入源码。但如果你在组件库里定义了一个MESSAGE_RENDERER令牌,并提供默认渲染器,使用方只要在应用层覆盖这个 token 的实现,就能无缝替换渲染策略,核心组件代码完全不用动。
这种设计模式让库的扩展性和被集成性大幅提升。日常业务代码中,如果你预判某个服务未来可能有多种实现,也可以提前用InjectionToken+useExisting或者接口 + 多个实现类的方式留好扩展位。
5.2 用工厂函数处理环境差异
实际项目里,配置服务的注入往往涉及环境差异。比如 API 地址、日志开关、功能开关。用useFactory处理这类问题,比在服务内部自己判断环境变量要干净得多:
providers: [ { provide: API_BASE_URL, useFactory: (env: Environment) => { return env.production ? 'https://api.example.com' : 'http://localhost:3000/api'; }, deps: [EnvironmentToken] } ]通过deps声明依赖,工厂函数可以依赖其他服务,在注入之前完成初始化逻辑。这让配置逻辑集中、可测试,而且不污染业务服务本身的代码。
5.3 注入器的分层控制在大型应用中的实际价值
在一个大团队协作的 Angular 项目里,依赖作用域的控制往往比"聪明地使用 DI"更关键。团队里每个成员都应该明确知道哪些服务是全局的,哪些是模块私有的,哪些是组件作用的。
我的习惯是:全局鉴权、请求、日志、路由服务用 providedIn: 'root';业务领域服务(例如订单、支付、库存)在对应的懒加载模块的 providers 中提供;临时状态(如某个表单步骤进度)放在组件级 providers,组件销毁即回收。
这个分层模型带来几个直接好处:内存回收更自然(组件销毁时组件级服务跟着销毁);按模块懒加载时服务创建成本被分摊;不同团队的模块之间的服务依赖关系变得清晰,不会出现"我依赖了某个全局服务但不知道哪里提供的"这种问题。
从架构角度看,DI 系统的真正价值不在于"少写几行 new 代码",而在于它把服务的生命周期、作用域、创建方式、可替换性全部抽象成了一个可配置的体系。你把依赖交给框架,框架就帮你承担起来创建、复用、销毁的复杂度,而你只需要专注于组件和服务本身。
Angular 的依赖注入与模块化架构,表面上是两个独立的知识点,实际上是一条线的两端——DI 提供"如何提供依赖"的机制,NgModule 提供"在哪一级提供依赖"的框架,组件注入器则负责细化到运行时最小作用域。能把这条线理顺,之后写起 Angular 项目会顺手很多。很多"看似神秘"的报错,追到根源,都是对注入器层级或者模块可见性理解不到位。
说实话,我最早学 Angular 的时候也恨不得把所有服务都扔进providedIn: 'root',图省事。直到被懒加载双实例和 viewProviders 边界问题折磨了几轮,才慢慢学会针对不同场景精细控制作用域。如果你现在也在被 DI 相关的问题困扰,建议别急着硬调代码,先把注入器树画一遍,标注每个服务应该挂在哪个节点下。想清楚作用域,问题自然就破解了。