第117课:Angular 服务与依赖注入——@Injectable、提供商作用域、providedIn、多级注入器
在 Angular 中,服务(Service) 是封装可复用业务逻辑、数据获取和共享状态的类。组件应专注于用户交互和视图渲染,而数据管理、HTTP 请求、日志记录等逻辑应交由服务处理。Angular 通过依赖注入(Dependency Injection,DI) 系统将这些服务自动提供给需要的组件或其它服务——你无需手动创建服务实例,Angular 的注入器(Injector) 会负责创建、缓存和管理服务的生命周期。
依赖注入是 Angular 区别于 React 和 Vue 的核心架构特性之一。它使得代码高度解耦:组件仅声明“我需要某个服务”,而不关心该服务如何创建、是否为单例、或者其内部依赖了哪些其它服务。理解 Angular 的 DI 系统——@Injectable 装饰器如何标记可注入类、providedIn 如何控制服务的作用域(单例范围)、以及多级注入器层级如何影响服务的共享与隔离——是构建可维护、可测试的大型 Angular 应用的关键。本节课将逐一拆解这些概念,并通过一个完整的用户管理模块展示服务与组件之间的协作模式。
1. 服务的基础:创建与注入
1.1 使用 Angular CLI 创建服务
1 | ng generate service services/user |
该命令会生成两个文件:user.service.ts 和 user.service.spec.ts(测试文件)。生成的 Service 类带有 @Injectable 装饰器,并默认配置 providedIn: 'root'。
1.2 @Injectable 装饰器
@Injectable 是 Angular 的装饰器,用于标记一个类可以被注入器管理。它接收一个元数据对象,其中最关键的属性是 providedIn。
1 | // user.service.ts |
关键解析:
@Injectable()告诉 Angular:“这个类可能会被注入到其他类中,它的构造函数参数需要由注入器解析”。providedIn: 'root'表示该服务在根注入器级别提供。这意味着整个应用共享同一个UserService实例(单例模式),且该服务会被 Tree Shaking 优化——如果最终没有任何组件或服务注入它,打包工具会自动移除该服务代码。- 构造函数的参数
private http: HttpClient是一种 TypeScript 简写语法:它同时声明了一个私有属性http,并将其赋值为构造函数参数。Angular 的注入器会解析HttpClient的依赖并传入该参数。
1.3 在组件中注入服务
在组件中,通过构造函数的参数声明所需的服务类型,Angular 注入器会自动提供对应的实例。
1 | // user-list.component.ts |
关键点:
- Angular 通过构造函数参数的类型来识别需要注入哪个服务。因此,构造函数的参数必须显式标注类型(TypeScript 类型信息在编译后会被保留为元数据,Angular 在运行时读取该元数据来确定注入哪个类)。
- **推荐将服务声明为
private**(如private userService: UserService),使其仅在类内部可访问,防止模板中意外调用。如果模板中需要直接访问服务,可以声明为public。 - 组件不需要手动实例化服务——注入器自动处理。
2. providedIn:服务的作用域与 Tree Shaking
providedIn 决定了服务在哪个注入器层级被创建,以及它是单例还是多实例。这是 Angular DI 系统最重要的配置之一。
2.1 providedIn: 'root'(推荐默认值)
1 | ({ |
- 服务在根注入器中注册。
- 整个应用共享同一个单例实例。
- 支持 Tree Shaking:如果没有任何代码注入该服务,构建工具会自动移除它。
- 这是最常用的配置,适合绝大多数全局服务(如认证、日志、数据获取)。
2.2 providedIn: 'platform'
服务在平台注入器中注册。一个 Angular 页面上可以同时存在多个 Angular 应用(通过 bootstrapModule 多次启动),平台注入器是比根注入器更高层级的注入器,它的服务在所有同平台的应用间共享。极少使用,通常用于 Angular Elements 等场景。
2.3 providedIn: 'any'
为每个惰性加载的模块创建一个独立的服务实例,而急切加载的模块共享同一个实例。适用于希望隔离惰性模块状态但又不想在模块级别手动注册提供者的情况。
2.4 providedIn: MyModule
服务在指定的模块注入器中注册。如果该模块是惰性加载的,服务成为该模块及其子注入器的单例。如果该模块是急切加载的,服务实际上等同于根注入器级别。**现代 Angular 推荐优先使用 'root' 而非模块级别的 providedIn**,因为后者可能无法被 Tree Shaking 优化,且显式模块依赖增加了耦合。
2.5 不设置 providedIn:手动注册提供者
如果不在 @Injectable 中设置 providedIn,服务本身不自带提供者信息。你必须在组件的 providers 数组或模块的 providers 数组中手动注册它:
1 | // 在组件级别提供(每个组件实例拥有独立的服务实例) |
这种方式的缺点是无法 Tree Shaking,且需要手动管理服务的作用域。现代 Angular 推荐使用 providedIn: 'root' + 组件级 providers 覆写的方式(见第 3 节)。
3. 多级注入器:服务的生命周期与作用域
Angular 的依赖注入系统是一个树形层级结构。每个注入器都维护着自己的服务实例缓存。当组件请求一个服务时,注入器从自身开始向上查找,直到根注入器。这意味着你可以通过在不同层级注册提供者来控制服务的共享范围和生命周期。
3.1 注入器树的结构
Angular 有两套并行的注入器树:
- Environment Injector(环境注入器):包括平台注入器、根注入器(
'root')、以及模块注入器(NgModule.providers)。这些注入器管理的服务在组件树中是全局共享的。 - Element Injector(元素注入器):每个 DOM 元素(包括组件)都有自己独立的注入器,通过
@Component.providers或@Directive.providers创建。这些注入器管理的服务在该组件及其子组件树中共享。
当组件请求一个服务时,查找顺序为:
- 当前组件的 Element Injector →
- 父组件的 Element Injector → … →
- 根组件的 Element Injector →
- 模块注入器(NgModule.providers)→
- 根注入器(
providedIn: 'root')→ - 平台注入器(
providedIn: 'platform')
如果在其中某一级找到了提供者,则使用该级别的实例;如果没找到,继续向上。如果最终未找到,Angular 抛出 NullInjectorError。
3.2 组件级提供者:隔离服务实例
通过在组件的 providers 数组中注册服务,可以为该组件及其子组件创建独立的服务实例。这即使在服务本身设置了 providedIn: 'root' 时也有效——组件级提供者会遮蔽根注入器中的提供者。
1 | // 全局共享的购物车服务(providedIn: 'root') |
典型场景:
- 表单状态隔离:同一个表单组件在页面上被复用多次,每个实例需要独立的表单状态服务。
- 局部缓存:某个功能模块需要独立的本地缓存,不影响全局状态。
- 避免状态污染:同一服务在根注入器中是单例,如果多个组件同时修改其状态会互相干扰;通过组件级提供者为特定子树创建隔离实例。
3.3 @Host、@Optional、@SkipSelf 装饰器
Angular 提供了三个修饰器来精确控制依赖查找的范围:
@Host():仅从当前宿主元素的注入器及其父级元素注入器中查找。如果宿主注入器中没有找到提供者,即使上层模块注入器中有,也会抛出错误(除非配合@Optional()使用)。- **
@Optional()**:如果找不到依赖,不抛出错误,而是将参数设为null。适用于可选的服务。 @SkipSelf():跳过当前注入器,直接从父级注入器开始查找。常用于避免循环依赖或在自己内部注入父级的服务。
1 | import { Host, Optional, SkipSelf } from '@angular/core'; |
3.4 @Self() 装饰器
@Self() 装饰器限制依赖查找仅从当前注入器开始。如果当前注入器中没有提供者,即使父级或根注入器中有,也会抛出错误(除非配合 @Optional())。
1 | // 仅查找当前组件的注入器 |
3.5 forwardRef:解决循环引用
当两个类在定义时相互引用(如父组件引用子组件作为 @ViewChild 的类型,而子组件的构造函数又注入了父组件的服务),由于 JavaScript 的执行顺序,其中一个类在定义时可能尚未被声明。使用 forwardRef(() => ClassName) 可以延迟引用的解析。
1 | import { forwardRef, Inject } from '@angular/core'; |
forwardRef 返回一个代理函数,在 DI 系统实际调用时再去查找 ParentComponent 的定义,从而打破了循环依赖的死锁。
4. 服务之间的相互注入
服务本身也可以注入其他服务。只需在服务类的构造函数中声明依赖即可。
1 | ({ providedIn: 'root' }) |
5. useClass、useValue、useFactory:自定义提供者
Angular 的提供者不仅可以是一个类,还可以通过提供者配置对象实现更灵活的注入策略。
5.1 useClass:替换服务实现
常用于接口与实现分离——在开发环境注入 Mock 服务,生产环境注入真实服务。
1 | // logger.service.ts |
在所有需要日志的地方注入 Logger,实际运行时 Angular 提供的是 ConsoleLogger 实例。切换日志策略仅需修改模块的 providers 配置,无需改动任何注入方。
5.2 useValue:注入常量或配置对象
适用于注入配置常量、第三方库实例、或预计算的值。
1 | // app.config.ts |
注意:useValue 注入的不是类,因此不能通过构造函数类型来识别。必须使用 @Inject(token) 装饰器,其中 token 是一个唯一的标识符。推荐使用 InjectionToken(见下文)而非字符串来避免命名冲突。
5.3 useFactory:工厂函数创建服务
当服务的创建需要依赖其他服务或需要执行额外的初始化逻辑时,使用 useFactory。
1 | import { HttpClient } from '@angular/common/http'; |
deps 数组列出了工厂函数所需的依赖,Angular 注入器会按顺序解析这些依赖并传递给工厂函数。
5.4 InjectionToken:类型安全的注入标识
当需要注入一个非类值(如配置对象、字符串、第三方库实例)时,使用 InjectionToken 比字符串 token 更安全,因为它提供了类型信息。
1 | import { InjectionToken } from '@angular/core'; |
InjectionToken<T> 的泛型参数 T 指定了注入值的类型,使得使用方获得完整的类型安全。
6. 服务与 RxJS 的协作模式
Angular 的服务通常返回 Observable(而非 Promise),以便利用 RxJS 的操作符进行数据转换、组合和取消。这是 Angular 与 React/Vue 在数据层上的一个重要区别——Angular 深度集成 RxJS,服务是数据流管道的一部分。
1 | ({ providedIn: 'root' }) |
组件通过 tasks$ | async 管道在模板中直接订阅,或手动订阅在 TypeScript 中处理。
7. 综合实战:带服务的用户管理模块
以下示例展示了服务、组件、依赖注入和 RxJS 如何协同工作。
1 | // user.model.ts |
1 | <!-- user-list.component.html --> |
设计要点:
UserService使用BehaviorSubject管理状态,服务是状态和逻辑的中心。- 组件通过
users$和loading$两个 Observable 订阅数据,模板中使用async管道自动订阅和取消订阅,避免手动管理订阅生命周期。 addUser和deleteUser返回 Observable,调用方(组件)可以订阅以获知操作完成,或继续链式调用。- 所有依赖注入通过
providedIn: 'root'自动注册,组件无需在providers中手动声明。
课后练习
一、概念自测(选择题 / 填空题)
(单选) Angular 中,
@Injectable({ providedIn: 'root' })的作用是?
A. 限制该服务仅能在根组件中注入。
B. 在根注入器中注册该服务,使整个应用共享同一个单例实例。
C. 为每个惰性加载模块创建独立的服务实例。
D. 禁止该服务被 Tree Shaking。(单选) 关于 Angular 的注入器层级,以下描述正确的是?
A. 组件的 Element Injector 无法遮蔽来自根注入器的服务。
B. 当组件请求一个服务时,注入器从根注入器开始向下查找。
C. 在@Component.providers中注册服务,会为该组件及其子组件创建独立的服务实例。
D. 模块注入器的优先级高于组件注入器。(填空) 要将一个非类值(如配置对象)注入到服务中,应使用
______创建一个类型安全的注入标识。(多选) 以下哪些是 Angular 依赖注入支持的自定义提供者类型?
A.useClass
B.useValue
C.useFactory
D.useExisting
二、AI 编程任务:编写面向 AI 的提示词
场景:你需要为 Angular 应用创建一个全局的通知服务和一个通知中心组件。要求如下:
NotificationService:使用providedIn: 'root'注册。内部使用BehaviorSubject管理通知列表,每条通知包含{ id: number; message: string; type: 'info' | 'success' | 'error' }。- 提供
addNotification(message, type)方法:添加通知,并 3 秒后自动移除。 - 提供
removeNotification(id)方法:手动移除。 - 暴露
notifications$Observable 供组件订阅。 NotificationCenterComponent:注入NotificationService,使用async管道订阅notifications$,渲染通知列表。每条通知根据type显示不同背景色,并有一个关闭按钮。
任务要求:请写出一段完整的中文提示词,发送给 AI,使其生成符合上述要求的 Angular 服务和组件代码。提示词中需明确指定 BehaviorSubject 的用法、自动移除逻辑、以及组件中 async 管道的使用方式。
三、Agent 模式下的提示词示例
你是一个资深前端开发 Agent。请为 Angular 应用创建全局通知服务和通知中心组件。需要创建以下文件:
src/app/models/notification.model.ts:导出Notification接口,包含id: number、message: string、type: 'info' | 'success' | 'error'。src/app/services/notification.service.ts:
@Injectable({ providedIn: 'root' })。- 内部使用
private notificationsSubject = new BehaviorSubject<Notification[]>([]),暴露notifications$ = this.notificationsSubject.asObservable()。addNotification(message: string, type: Notification['type'] = 'info'):生成id = Date.now(),将新通知 push 到当前数组,通过next发送更新。同时用setTimeout在 3 秒后调用removeNotification(id)。removeNotification(id: number):过滤掉对应通知,通过next发送更新。src/app/components/notification-center/notification-center.component.ts:
- 选择器
app-notification-center。- 构造函数注入
private notificationService: NotificationService。- 属性
notifications$ = this.notificationService.notifications$。- 模板:使用
*ngIf="notifications$ | async as notifications"判断是否有通知。用*ngFor遍历notifications。每条通知<div [class]="'notification notification-' + n.type">显示{{ n.message }}和一个关闭按钮(click)="notificationService.removeNotification(n.id)"。- 样式:
.notification { padding: 12px; border-radius: 4px; margin-bottom: 8px; display: flex; justify-content: space-between; }.notification-info { background: #e0f2fe; }.notification-success { background: #dcfce7; }.notification-error { background: #fee2e2; }。- 所有代码添加 JSDoc 注释。完成后列出所有文件内容。
四、面试真题与参考答案
题目(阿里前端面试题):
请解释 Angular 依赖注入系统的工作原理,包括注入器层级、服务的查找顺序以及
providedIn的不同取值对服务生命周期的影响。如果需要在同一个页面的两个不同区域使用同一个服务类的两个独立实例(互不干扰),应该如何配置?请给出具体的代码示例。
参考答案:
Angular DI 系统的工作原理基于注入器层级。当组件请求一个服务时,注入器从当前组件的 Element Injector 开始查找,逐级向上(父组件 → 根组件 → 模块注入器 → 根注入器 → 平台注入器)。一旦找到提供者,就返回该层级缓存的实例;如果未找到,继续向上。最终未找到则抛出错误。
providedIn 决定服务在哪个注入器层级注册:
'root':根注入器,整个应用共享同一个单例,支持 Tree Shaking。'platform':平台注入器,同页面的多个 Angular 应用共享。'any':每个惰性加载模块拥有独立实例。- 不设置
providedIn:需要手动在组件或模块的providers中注册。
实现两个独立服务实例的方法:在组件的 providers 数组中注册该服务。这会为该组件及其子组件创建一个新的 Element Injector 提供者,遮蔽上层的根注入器提供者。每个使用该配置的组件将拥有独立的服务实例。
1 | ({ providedIn: 'root' }) |
页面上每使用一个 <app-cart-widget> 标签,该组件实例及其子组件都将获得独立的 CartService 实例,互不干扰。而其他没有在 providers 中注册 CartService 的组件仍然使用根注入器中的全局单例。
课后练习答案
一、概念自测答案
B
- 解析:
providedIn: 'root'在根注入器中注册服务,整个应用共享单例。A 错误,所有组件都可注入;C 描述的是'any';D 错误,'root'反而支持 Tree Shaking。
- 解析:
C
- 解析:组件级
providers为该组件及其子组件创建独立服务实例。A 错误,组件级提供者可以遮蔽根注入器;B 错误,查找从当前组件向上;D 错误,组件注入器优先级高于模块注入器。
- 解析:组件级
InjectionToken- 解析:
InjectionToken<T>用于创建类型安全的注入标识,替代字符串 token。
- 解析:
A、B、C、D
- 解析:
useClass(替换类)、useValue(注入值)、useFactory(工厂函数)、useExisting(别名,使用已存在的服务)均为合法的提供者类型。
- 解析:
二、AI 编程任务参考答案(提示词示例)
示例提示词:
“请为 Angular 应用创建通知服务和组件。要求:
NotificationService:providedIn: 'root',BehaviorSubject管理通知列表,addNotification自动 3 秒后移除,removeNotification手动移除,暴露notifications$。NotificationCenterComponent:注入服务,async管道订阅,按类型显示不同颜色,有关闭按钮。- 输出 service.ts、model.ts、component.ts(含内联模板和样式)的完整代码。”