Angular 依赖注入:DI 系统与服务
依赖注入(Dependency Injection,简称 DI)是 Angular 框架最核心的概念之一。它不是 Angular 独有的技术,但 Angular 将其发挥到了极致,成为构建可维护、可测试应用的基石。本文将深入解析 Angular 的 DI 系统,帮你彻底掌握这一关键机制。
一、为什么需要依赖注入
1.1 传统模式的困境
在没有依赖注入的情况下,组件之间需要相互协作时,通常会在组件内部直接创建依赖对象的实例。考虑以下场景:一个 UserService 需要调用 HTTP 接口获取用户数据,而 UserComponent 需要使用这个服务。
传统写法是这样的:
// user.service.ts
export class UserService {
constructor() {
// 直接在内部创建 HttpClient 实例
this.http = new HttpClient();
}
getUsers() {
return this.http.get('/api/users');
}
}
// user.component.ts
export class UserComponent {
private service: UserService;
constructor() {
// 组件内部直接创建服务实例
this.service = new UserService();
}
}
这种写法存在几个严重问题。首先是紧耦合问题,UserService 内部直接依赖 HttpClient 的具体实现,如果想换一种 HTTP 实现方式,必须修改 UserService 的源代码。其次是测试困难,在单元测试时,无法轻易替换 HttpClient 为 mock 对象,导致测试用例难以编写。最后是职责混乱,类同时承担了业务逻辑和对象创建的职责,违背了单一职责原则。
1.2 依赖注入的解决思路
依赖注入的核心思想是:类不应该自己创建依赖对象,而应该从外部接收依赖对象。这个"外部"就是注入器(Injector)。
采用依赖注入后,代码变成这样:
// user.service.ts
export class UserService {
constructor(private http: HttpClient) {}
getUsers() {
return this.http.get('/api/users');
}
}
// user.component.ts
export class UserComponent {
constructor(private userService: UserService) {}
}
现在,UserService 不再关心 HttpClient 是如何创建的,UserComponent 也不关心 UserService 是如何创建的。所有对象的创建和组装工作交给 DI 系统处理。
二、Angular DI 系统的核心概念
Angular 的 DI 系统由三个核心角色组成:注入器(Injector)、提供者(Provider)和依赖(Dependency)。
2.1 注入器:对象的制造工厂
注入器是 Angular DI 系统的核心,相当于一个对象工厂。当某个类需要依赖时,向注入器发出请求,注入器负责创建并返回所需的实例。
Angular 应用启动时,会自动创建一个根注入器。这个注入器负责管理整个应用中的依赖关系。随着组件和模块的创建,还会形成注入器的层次结构,这在后续章节会详细讲解。
2.2 提供者:告诉注入器如何创建对象
提供者描述了注入器应该如何创建某个依赖。它告诉注入器:"当有人请求这个服务时,请按照这种方式给我一个实例"。
Angular 提供了多种定义提供者的方式,最常用的是 useClass 和 useValue。useClass 指定用哪个类来创建实例,useValue 则直接提供一个具体的值。
// 使用 useClass 创建提供者
{ provide: UserService, useClass: UserServiceImpl }
// 使用 useValue 提供值
{ provide: API_URL, useValue: 'https://api.example.com' }
2.3 依赖:类之间的关联关系
依赖是指一个类在构造函数中声明需要使用的其他类或值。当 Angular 实例化某个类时,会检查它的构造函数,识别出所有依赖需求,然后从注入器中获取这些依赖并注入进去。
三、服务的创建与注册
3.1 创建服务
在 Angular 中,服务就是一个普通的 TypeScript 类,只是通常不包含模板相关的逻辑。创建一个服务需要使用 @Injectable() 装饰器,这个装饰器至关重要,它标记该类可以被注入器管理。
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root' // 这个配置很重要,下文会解释
})
export class LoggingService {
log(message: string): void {
console.log(`[LOG]: ${message}`);
}
}
```
`@Injectable()` 装饰器有一个 `providedIn` 属性,它决定了服务由哪个注入器提供。`providedIn: 'root'` 表示该服务是应用级别的单例,由根注入器管理。这是最推荐的配置方式,因为它支持树摇优化(Tree Shaking),未被使用的服务不会被包含在最终打包中。
### 3.2 注册服务的方式
**方式一:使用 `providedIn`(推荐)**
这种方式直接在服务类上声明,不需要在模块或组件中显式注册:
```typescript
@Injectable({
providedIn: 'root' // 根注入器,整个应用单例
// providedIn: 'platform' // 平台注入器,多个应用共享
// providedIn: 'any' // 每个懒加载模块都有自己的实例
})
export class DataService {}
```
**方式二:在模块中注册**
在模块的 `providers` 数组中注册服务:
```typescript
@NgModule({
providers: [
UserService,
{ provide: ConfigService, useClass: ConfigServiceImpl }
]
})
export class AppModule {}
```
**方式三:在组件中注册**
在组件的 `providers` 数组中注册服务,该服务只在该组件及其子组件中可见:
```typescript
@Component({
selector: 'app-user',
template: `<user-list></user-list>`,
providers: [UserService] // 这个组件及其子组件共享这个服务实例
})
export class UserComponent {}
```
### 3.3 服务的作用域与生命周期
服务实例的生命周期与注册它的注入器紧密相关。理解这一点对于避免常见的 bug 至关重要。
当服务注册在根注入器(`providedIn: 'root'`)时,该服务是应用级别的单例,整个应用共享同一个实例,从应用启动到关闭始终存在。
当服务注册在组件的 `providers` 数组中时,该服务是组件级别的实例。每次创建该组件的新实例时,都会创建一个新的服务实例。这个组件的所有子组件共享这个服务实例,但兄弟组件之间的实例是独立的。
---
## 四、依赖注入的完整流程
让我们通过一个完整的例子,梳理 Angular DI 的工作流程。
```typescript
// 1. 定义日志服务接口
export interface ILogger {
log(message: string): void;
}
// 2. 实现日志服务
@Injectable()
export class ConsoleLogger implements ILogger {
log(message: string): void {
console.log(`[Console]: ${message}`);
}
}
// 3. 使用日志服务的业务服务
@Injectable({
providedIn: 'root'
})
export class OrderService {
constructor(private logger: ILogger) {}
createOrder(order: Order): void {
this.logger.log(`Creating order: ${order.id}`);
// 业务逻辑...
}
}
// 4. 使用业务服务的组件
@Component({
selector: 'app-order',
template: `<button (click)="addOrder()">Add Order</button>`
})
export class OrderComponent {
constructor(private orderService: OrderService) {}
addOrder(): void {
this.orderService.createOrder({ id: 1 });
}
}
上述代码的执行流程如下:
- 应用启动:Angular 创建根注入器。
- 扫描依赖:Angular 发现
OrderService依赖ILogger接口。 - 查找提供者:Angular 在注入器中查找
ILogger的提供者。 - 创建实例:Angular 创建
ConsoleLogger实例,并将其注入到OrderService的构造函数中。 - 组件注入:当创建
OrderComponent时,Angular 找到已有的OrderService单例(根注入器中),将其注入到组件中。
整个过程中,组件不需要知道服务是如何创建的,也不需要知道依赖是如何解析的,只需要声明自己需要什么,Angular 的 DI 系统会自动完成剩余工作。
五、注入器的层次结构
Angular 的注入器不是扁平的,而是形成了一个树形层次结构。这个特性是理解 Angular DI 的关键,也是避免"为什么我的服务不共享"这类问题的钥匙。
5.1 注入器树的结构
Angular 应用中存在三种类型的注入器,它们形成如下的层次关系:
根注入器 (root injector)
├── 根组件注入器 (root component injector)
│ ├── 子组件A注入器
│ │ └── 深层子组件注入器
│ └── 子组件B注入器
└── 懒加载模块注入器
└── 该模块内的组件注入器
当 Angular 需要解析某个依赖时,会按照从下到上的顺序在注入器链中查找:首先检查当前组件的注入器,然后是父组件的注入器,直到根注入器。一旦找到匹配的提供者,就停止搜索并返回实例。
5.2 实际应用场景
假设我们有这样一个组件树结构:AppComponent → ParentComponent → ChildComponent。我们在不同层级注册了同一个服务:
// 根组件中注册服务
@Component({
selector: 'app-root',
template: `<app-parent></app-parent>`,
providers: [SharedService] // 根组件级别的实例
})
export class AppComponent {}
// 父组件中注册同名服务
@Component({
selector: 'app-parent',
template: `<app-child></app-child>`,
providers: [SharedService] // 父组件级别的实例,会"遮蔽"根组件的实例
})
export class ParentComponent {}
// 子组件中不注册,使用最近的提供者的实例
@Component({
selector: 'app-child',
template: `<div>{{ sharedData }}</div>`
})
export class ChildComponent {
constructor(public sharedService: SharedService) {
// 这里得到的是 ParentComponent 注入器中的实例
// 而不是 AppComponent 注入器中的实例
}
}
在这个例子中,ChildComponent 获取到的是 ParentComponent 注入器中注册的 SharedService 实例,而不是 AppComponent 注入器中的实例。这就是注入器的查找机制。
5.3 何时使用组件级别注入器
组件级别注入器适用于以下场景:需要为组件及其子组件提供独立的服务实例,常见于以下情况:
多实例组件时,每个标签页、每个列表项需要独立的状态管理。例如,一个标签页组件管理自己的标签列表,多个标签页之间不应该共享同一个标签列表数据。
隔离第三方服务时,当使用第三方库时,将其服务注册在组件级别可以避免对其他组件造成意外影响,便于隔离测试和替换。
六、进阶技巧与最佳实践
6.1 使用 useFactory 创建动态依赖
有时候服务的创建逻辑比较复杂,无法用简单的类来描述。这时可以使用工厂提供者:
// 根据环境决定使用哪个日志服务
export function loggerFactory(isDebug: boolean): ILogger {
return isDebug ? new DebugLogger() : new ProductionLogger();
}
// 在模块中注册工厂提供者
providers: [
{
provide: ILogger,
useFactory: () => loggerFactory(environment.debugMode)
}
]
工厂函数还可以接收参数,通过 deps 属性声明依赖:
{
provide: LoggerService,
useFactory: (config: ConfigService, http: HttpClient) => {
return new LoggerServiceImpl(config, http);
},
deps: [ConfigService, HttpClient]
}
6.2 使用 useExisting 实现别名
useExisting 可以将一个服务别名到另一个服务,这在重构或者接口适配时非常有用:
providers: [
NewLoggerService,
{
provide: OldLoggerService,
useExisting: NewLoggerService // 两者指向同一个实例
}
]
6.3 注入抽象类或接口
TypeScript 的类型信息在运行时会被擦除,所以不能直接注入接口。Angular 提供了 InjectionToken 来解决这个问题:
import { InjectionToken } from '@angular/core';
// 定义 token
export const LOGGER_TOKEN = new InjectionToken<ILogger>('Logger');
// 在模块中注册
providers: [
{
provide: LOGGER_TOKEN,
useClass: ConsoleLogger
}
];
// 在组件中注入
constructor(@Inject(LOGGER_TOKEN) private logger: ILogger) {}
6.4 可选依赖
如果某个依赖不是必需的,可以使用 @Optional() 装饰器。当找不到对应的提供者时,注入器会返回 null 而不是报错:
constructor(
@Optional() private logger: ILogger
) {
if (this.logger) {
this.logger.log('Service initialized');
}
}
6.5 自定义注入器装饰器
除了使用 @Optional(),Angular 还提供了其他注入装饰器:
@Self() 表示只从当前组件的注入器中查找依赖,不向上查找父组件的注入器。
@SkipSelf() 表示跳过当前组件的注入器,从父组件的注入器开始查找。
constructor(
@Self() private localService: LocalService, // 只在当前组件查找
@SkipSelf() private parentService: ParentService // 从父组件开始查找
) {}
七、常见问题与解决方案
7.1 服务不共享的问题
症状:同一个服务在不同的组件中表现不一致,像是不同的实例。
原因:服务被注册在组件级别的注入器中,而不是根注入器中。
解决:将服务的 providedIn 设置为 'root',或者将服务移到模块的 providers 数组中:
// 错误写法
@Component({ providers: [DataService] })
export class MyComponent {}
// 正确写法
@Injectable({ providedIn: 'root' })
export class DataService {}
7.2 循环依赖问题
症状:RangeError: Maximum call stack size exceeded 或类似错误。
原因:两个服务相互依赖,形成循环引用。
解决:使用 forwardRef 延迟引用:
@Injectable({ providedIn: 'root' })
export class ServiceA {
constructor(@Optional() private serviceB: ServiceB) {}
}
@Injectable({ providedIn: 'root' })
export class ServiceB {
constructor(@Optional() private serviceA: ServiceA) {}
}
如果循环依赖无法避免,可以将其中一个服务改为工厂注入,或者提取公共部分到第三个服务。
7.3 找不到提供者错误
症状:NullInjectorError: No provider for XXX!
原因:需要的服务没有在任何注入器中注册。
解决:确认服务已正确注册。如果使用 providedIn: 'root',检查 @Injectable() 装饰器是否正确应用。
八、总结
Angular 的依赖注入系统是一个强大而灵活的工具。掌握它需要理解三个核心概念:注入器负责创建对象,提供者描述创建方式,依赖声明需要什么。服务是 DI 的主要应用载体,通过 @Injectable() 装饰器和 providedIn 属性管理其作用域。
注入器的层次结构是 Angular DI 的高级特性,理解这一机制可以帮你更好地控制服务实例的生命周期。进阶技巧如 useFactory、InjectionToken 和各种注入装饰器,为复杂场景提供了灵活的解决方案。
最佳实践是:优先使用 providedIn: 'root' 注册服务,保持服务的单例性;使用接口和 InjectionToken 抽象依赖,提高代码灵活性;合理使用组件级别注入器,避免不必要的复杂性。

暂无评论,快来抢沙发吧!