2.3 服务与依赖注入 依赖注入(DI) 是 Angular 把"需要什么"与"谁来提供"解耦的机制:组件声明依赖,注入器负责实例化与递送。本节聚焦服务的编写、三种注入方式、注入令牌,以及 DI 如何影响变更检测——服务里改数据,界面凭什么会动? 前两节讲了树的组织与节点。这一节讲数据的"供应商":服务如何编写、如何被递送,以及最关键的一点——服务中数据变化如何传到组件、进而进入变更检测。 上一节留下的钩子 2.2 留下一个问题:组件不该自己造数据,数据从哪来?本节交给注入体系里的"服务"来回答: 编写 Injectable 服务,说明 providedIn 各取值对应的注册层级。 掌握三种注入写法(构造器、inject 函数、InjectionToken)及适用边界。
依赖注入(DI) 是 Angular 把"需要什么"与"谁来提供"解耦的机制:组件声明依赖,注入器负责实例化与递送。本节聚焦服务的编写、三种注入方式、注入令牌,以及 DI 如何影响变更检测——服务里改数据,界面凭什么会动?
前两节讲了树的组织与节点。这一节讲数据的"供应商":服务如何编写、如何被递送,以及最关键的一点——服务中数据变化如何传到组件、进而进入变更检测。
2.2 留下一个问题:组件不该自己造数据,数据从哪来?本节交给注入体系里的"服务"来回答:
服务就是带 Injectable 装饰器的类,存放与视图无关的逻辑与数据:
import { Injectable } from '@angular/core'; @Injectable({ providedIn: 'root' }) // 注册在根注入器:全应用单例 export class InventoryService { private stock = new Map<string, number>([ ['SKU-1', 42], ['SKU-2', 7] ]); get(qty sku: string): number { return this.stock.get(sku) ?? 0; } deduct(sku: string, n: number): boolean { const left = this.stock.get(sku) ?? 0; if (left < n) return false; this.stock.set(sku, left - n); return true; } }
providedIn 的取值决定注册位置:root 挂根注入器;'platform' 更高一层,微前端多应用共享时用;'any' 每个注入惰性模块各一份;也可以写具体模块类,等价于旧式模块 providers 但可摇树。选择原则回到上一节的结论:状态共享半径。
import { Injectable, inject, InjectionToken } from '@angular/core'; // 写法一:构造器注入(传统,利于显式看到依赖列表) export class CheckoutComponent { constructor(private inventory: InventoryService) {} } // 写法二:inject 函数(现代,简洁且在函数上下文也可用) export class CheckoutComponent2 { inventory = inject(InventoryService); } // 写法三:InjectionToken——注入非类的值(配置、接口实现) export const API_BASE = new InjectionToken<string>('API 基地址'); export const ORDER_REPOSITORY = new InjectionToken<{ findAll(): unknown[] }>('订单仓储'); // 使用处:工厂函数按环境组装实现 export const orderRepositoryProvider = { provide: ORDER_REPOSITORY, useFactory: () => ({ findAll: () => [{ orderNo: 'A-1' }] }) };
三种写法的选择我的习惯是:组件内一律 inject 函数(字段初始化顺序直观、继承不踩坑);跨模块的接口依赖一律 InjectionToken(接口编译后不存在,必须用令牌);教学或老代码兼容场景保留构造器写法。
⚠️ 坑:inject 只能在构造上下文调用(构造器或字段初始化器),在 setTimeout 回调里调用会抛错。运行期按需取依赖要用 InjectFlags 或改为构造期保存引用。
先给结论再验证:服务里的普通字段变化本身不会触发任何界面更新;必须有一次异步事件(或其他检查触发源)唤醒变更检测,且组件模板恰好重新读取了该字段,界面才会更新。 看两组对照:
@Injectable({ providedIn: 'root' }) export class TickerService { plainCount = 0; // 普通字段:改了界面不一定知道 listeners: (() => void)[] = []; // 手动回调:土办法但能解释机制 tickPlain() { this.plainCount++; } tickAndNotify() { this.plainCount++; this.listeners.forEach(fn => fn()); // 回调里若发生用户事件,检查随之而来 } } @Component({ selector: 'app-ticker', template: ` <p>普通计数:{{ svc.plainCount }}</p> <button (click)="svc.tickPlain()">加一</button> <!-- 点击本身是异步事件:回调结束后一轮检查重算插值,界面更新 --> ` }) export class TickerComponent { svc = inject(TickerService); }
上述代码里点击按钮界面确实会更新——但功劳不在服务,而在 click 事件唤醒了检查。换一个情形:服务内部用 setInterval 每秒 tickPlain 一次(Zone 拦截了计时器,检查每秒照跑),界面同样更新。再换:把 Zone 换成无 Zone 的信号模式且字段不是信号——界面就不动了。三种情形拼出完整因果链:更新 = 有触发源 + 绑定重读到新值,二者缺一不可。

背景:下单页点击"扣库存",要求库存条数字即时反映扣减结果,且低于 10 时变红。
操作:InventoryService 提供 deduct 方法(上文代码);组件模板绑定库存数并加条件类;点击按钮调用服务。
结果:每次点击后数字减少;低于 10 后样式变红。
解读:click 事件负责第二段(触发检查),插值重算负责第三段,服务只负责第一段。三者职责分明,任何一段缺失都会导致"数据变了界面没动"。
变式:若库存由轮询定时器更新而非用户点击,界面依旧会动(计时器也是触发源);若把更新逻辑放到 Web Worker 的消息回调里(旧版本 Zone 不拦截),界面就冻结了——这是排查"偶发不刷新"的重要线索。
下一节进入表达层:模板与四种数据绑定。