依赖注入(DI)是 Angular 的骨架:组件、指令、管道、路由、HttpClient 全部构建在注入器之上。本文讲清楚两件事:注入器是一棵什么样的树,以及 inject() 为什么必须在”注入上下文”里调用。下一篇讲 @Service()、injectAsync() 等进阶话题。
DI 解决什么问题
不依赖注入时,一个订单服务需要 HTTP 客户端就得自己 new,测试时无法替换、单例无法共享、层级作用域无从谈起。DI 把”我需要什么”(声明)与”谁来提供”(注册)分离:类只声明依赖,注入器负责构造与装配,测试时整棵依赖树可以按 token 替换。
注入器是一棵树
Angular 的注入器不是单例容器,而是两族交织的树:
graph TD
P["Platform Injector(平台级,浏览器实例一个)"] --> R["Root EnvironmentInjector<br/>providedIn: 'root' / app.config providers"]
R --> RA["Route EnvironmentInjector<br/>(懒加载路由 providers,按路由隔离)"]
R --> RB["Root Component NodeInjector"]
RB --> C1["PageComponent NodeInjector<br/>providers: [SelectionService]"]
C1 --> C2["ChildComponent NodeInjector"]
RA --> RP["LazyPage NodeInjector"]
- EnvironmentInjector(环境注入器):
platform→root→ 各路由(懒加载路由拥有独立环境注入器)。provideRouter()、provideHttpClient()、providedIn: 'root'的服务都注册在环境注入器层。 - NodeInjector(节点注入器):每个组件实例对应一个节点注入器,承载组件
providers里声明的依赖。
查找规则:从当前组件的 NodeInjector 沿组件树向上,途中每层先查本层再向上;到顶后转入 EnvironmentInjector 链继续向上,直到 platform;仍找不到则抛 NullInjectorError(NG0201)。
历史上还有 NgModule 注入器(module injector),随 standalone 全面普及已被环境注入器体系取代,读旧资料时把它近似理解为 root 环境注入器即可。
注册服务的几种方式
// 1. root 单例(v22 推荐 @Service(),等价 @Injectable({ providedIn: 'root' }))
@Service()
export class CartService {
items = signal<CartItem[]>([]);
}
// 2. 组件级:每个组件实例一份
@Component({
selector: 'app-data-table',
providers: [SelectionService],
})
export class DataTableComponent {}
// 3. 路由级:路由片段内单例(懒加载路由天然隔离)
export const routes: Routes = [
{
path: 'admin',
loadComponent: () => import('./admin/admin.component'),
providers: [AdminGuardService],
},
];
// 4. 应用级:app.config.ts 的 providers 数组
export const appConfig: ApplicationConfig = {
providers: [provideRouter(routes), provideHttpClient()],
};
| 注册位置 | 作用域 | 生命周期 | 典型用途 |
|---|---|---|---|
@Service() / providedIn: 'root' | 全应用单例 | 应用级 | 无状态服务、共享状态 |
组件 providers | 该组件子树 | 随组件销毁 | 每实例状态(选中集、草稿) |
路由 providers | 路由子树内 | 路由配置级 | 路由私有服务 |
app.config providers | 全应用 | 应用级 | 框架能力配置 |
组件上还有 viewProviders:与 providers 的区别是它对投影内容不可见——投影进来的子组件注入时跳过这一层。默认用 providers,想对投影内容隐藏实现时用 viewProviders。
inject() vs 构造器注入
同一个依赖的两种写法:
// 构造器注入(历史写法,完全可用)
export class OrderComponent {
constructor(
private cart: CartService,
@Inject(API_BASE_URL) private baseUrl: string,
@Optional() private logger?: Logger,
) {}
}
// inject() 写法(v22 推荐)
export class OrderComponent {
private cart = inject(CartService);
private baseUrl = inject(API_BASE_URL);
private logger = inject(Logger, { optional: true });
}
| 维度 | inject() | 构造器注入 |
|---|---|---|
| 书写位置 | 字段初始化器、构造器、provider 工厂 | 仅构造器参数 |
| 修饰符 | 选项对象 { optional, self, skipSelf, host } | @Optional / @Self / @SkipSelf / @Host 装饰器 |
| InjectionToken | 泛型直接推断类型 | 需要 @Inject 装饰器 |
| TS 依赖 | 不依赖 emitDecoratorMetadata | 依赖装饰器元数据 |
| 可读性 | 类字段即依赖清单 | 参数集中但装饰器易堆积 |
| 限制 | 必须在注入上下文调用 | 无注入上下文限制 |
v22 的新 API(@Service() 等)在设计上只与 inject() 配合,新代码统一用 inject() 即可。
注入上下文(Injection Context)
inject() 能工作的位置称为注入上下文:
- 组件/指令/管道/服务的构造函数;
- 上述类的字段初始化器;
InjectionToken的factory;- provider 的
useFactory工厂函数; runInInjectionContext(injector, fn)的回调。
以下位置调用会抛 NG0203(inject() must be called from an injection context):
export class SaveComponent {
save() {
setTimeout(() => {
const toast = inject(ToastService); // NG0203:回调不在注入上下文
toast.show('已保存');
}, 0);
}
}
修复方式两种:把依赖提升为字段;或在字段保存 Injector 后显式切换上下文:
export class SaveComponent {
private injector = inject(Injector);
save() {
setTimeout(() => {
const toast = runInInjectionContext(this.injector, () => inject(ToastService));
toast.show('已保存');
}, 0);
}
}
顺带一提:effect() 的回调运行在注入上下文中,可以直接 inject()——这是信号体系带来的便利。
InjectionToken:注入非类值
服务是类,可以直接当 token;配置常量、接口实现需要 InjectionToken:
import { InjectionToken, inject } from '@angular/core';
export const API_BASE_URL = new InjectionToken<string>('API_BASE_URL', {
providedIn: 'root',
factory: () => 'https://api.example.com',
});
- 泛型参数让
inject(API_BASE_URL)直接得到string类型。 factory提供默认值,注入方可在任意层覆盖:
providers: [{ provide: API_BASE_URL, useValue: 'https://api.staging.example.com' }]
- token 让实现与消费解耦:注入方依赖抽象 token,不必 import 具体实现类,这也让测试替身与多实现替换成为可能。
修饰符一览
| 修饰符 | 语义 | inject() 等价 |
|---|---|---|
@Optional() | 找不到不报错,注入 null | inject(X, { optional: true }) |
@Self() | 只查当前注入器,不向上 | inject(X, { self: true }) |
@SkipSelf() | 跳过当前注入器,从父级开始查 | inject(X, { skipSelf: true }) |
@Host() | 向上查找不超过宿主组件边界(内容投影场景) | inject(X, { host: true }) |
skipSelf 最常见的用途是”子组件覆盖父级服务并叠加原实现”:
@Injectable()
export class AuditLogger extends BaseLogger {
private parent = inject(BaseLogger, { skipSelf: true });
log(msg: string) {
this.parent.log(msg);
this.auditTrail.push(msg);
}
}
注意这里的继承只复用类型,实例链路靠 skipSelf 拿到父级注入的同 token 实例——比 super() 更符合 DI 的组合思路。
实战:组件级服务的价值
@Injectable()
export class SelectionService {
selected = signal<Set<number>>(new Set());
toggle(id: number) {
this.selected.update(set => {
const next = new Set(set);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
}
@Component({
selector: 'app-data-table',
providers: [SelectionService], // 每个表格实例私有
template: `...`,
})
export class DataTableComponent {
selection = inject(SelectionService); // 本组件子树内同一实例
}
@Component({ selector: 'app-table-toolbar' })
export class TableToolbarComponent {
// 子组件注入的是宿主表格的那份实例,而非全局单例
selection = inject(SelectionService);
}
两个表格实例互不干扰——这就是层级注入器提供的”作用域”能力,是全局单例方案(手动传 props 或 context)给不了的。