2.9 状态管理 状态管理回答"数据放哪里、谁改它、改完谁看得见"。Angular 生态里从轻到重有三层方案:服务字段直存、BehaviorSubject 流式状态、信号(signal)响应式原语。本节用同一个购物车案例贯穿三层方案,重点讲清每层与变更检测的接口——状态容器决定"变更如何被感知",这一步做错,后面的优化全是补丁。 HttpClient 解决了"取数据",本节解决"数据落地后的组织":跨组件共享、可预测更新、与检查机制的成本结算。 带着一个问题进入 带着一个问题进入:组件之间互不引用,状态怎么共享?本节用两条主流路线分别回答: 给状态分类:服务端数据、客户端交互状态、URL 状态——各类归属不同容器。 用服务 + BehaviorSubject 实现可订阅的状态仓库。
状态管理回答"数据放哪里、谁改它、改完谁看得见"。Angular 生态里从轻到重有三层方案:服务字段直存、BehaviorSubject 流式状态、信号(signal)响应式原语。本节用同一个购物车案例贯穿三层方案,重点讲清每层与变更检测的接口——状态容器决定"变更如何被感知",这一步做错,后面的优化全是补丁。
HttpClient 解决了"取数据",本节解决"数据落地后的组织":跨组件共享、可预测更新、与检查机制的成本结算。
带着一个问题进入:组件之间互不引用,状态怎么共享?本节用两条主流路线分别回答:
| 状态类别 | 例子 | 建议容器 |
|---|---|---|
| 服务端数据 | 订单列表、详情 | 服务中的缓存字段/信号,配 HTTP 拉取 |
| 客户端交互状态 | 选中项、展开态、购物车 | 组件内或共享服务 |
| URL 状态 | 筛选词、页码 | 路由查询参数(呼应 2.6 节) |
| 会话状态 | 令牌、当前用户 | 根级认证服务 |
分类的价值在于防"错位":把页码存进服务,刷新就丢;把令牌存进组件,路由一换就没。先归类再动手,能省掉大量补救代码。
import { Injectable, inject } from '@angular/core'; import { BehaviorSubject } from 'rxjs'; interface CartItem { sku: string; qty: number } @Injectable({ providedIn: 'root' }) export class CartStore { private _items$ = new BehaviorSubject<CartItem[]>([]); readonly items$ = this._items$.asObservable(); // 对外只读流 // 快照读取:不订阅也能拿当前值(保存、结算时用) get snapshot(): CartItem[] { return this._items$.value; } add(sku: string) { const items = [...this.snapshot]; // 不可变更新:换引用 const hit = items.find(i => i.sku === sku); hit ? (hit.qty += 1) : items.push({ sku, qty: 1 }); this._items$.next(items); // 广播新数组,订阅者全部收到 } remove(sku: string) { this._items$.next(this.snapshot.filter(i => i.sku !== sku)); } }
组件侧用 async 管道直连 items$。更新纪律只有一条:永远发射新数组,绝不原地修改。这条纪律的检查成本含义将在第 3 章 OnPush 一节兑现——引用变化是"变更可被检测"的前提。
⚠️ 状态复制事故:某同事在购物车组件里又存了一份 items 字段"方便模板用",加入商品后服务流更新了组件副本没更新,界面时对时错。铁律:一份数据一个存放点,其他地方一律订阅或读取,不抄写。
import { Injectable, signal, computed } from '@angular/core'; @Injectable({ providedIn: 'root' }) export class CartStoreV2 { private readonly _items = signal<CartItem[]>([]); readonly items = this._items.asReadonly(); // 只读信号暴露给组件 // 派生状态:依赖变化自动重算,中间结果被缓存 readonly count = computed(() => this._items().reduce((s, i) => s + i.qty, 0) ); add(sku: string) { this._items.update(list => { const hit = list.find(i => i.sku === sku); return hit ? list.map(i => i.sku === sku ? { ...i, qty: i.qty + 1 } : i) : [...list, { sku, qty: 1 }]; }); } } // 组件模板:<p>共 {{ store.count() }} 件</p>
两条路线与变更检测的接口差异是本节的主线落点:BehaviorSubject 依赖外部触发源(订阅回调多发生在异步事件里,检查随之而来);信号读取发生在模板求值时,框架直接感知到"谁读了哪个信号"——信号被更新时,读过它的组件被精准标记为待检查。前者是"检查全树,顺便看到新值",后者是"变了什么就检查谁"。完整的机制对比留到第 3 章,这里先建立感性认识。

背景:商品页与顶栏角标都订阅购物车流。加入商品后商品页正常,角标偶发不动,刷新才恢复。
操作:排查发现角标组件订阅写在 ngOnInit 且退订写在 ngOnDestroy 没问题;真正原因是另一处代码把 _items$ 的私有字段直接原地 push(没走 add 方法、没发射)——商品页更新靠的是点击事件的检查"顺便"读到数组内容(引用没变但内容变了,模板恰好重读),角标组件的 async 管道因流从未发射而不更新。
结果:改为统一走 add 的不可变更新后,两处视图稳定同步。
解读:这起事故把本节两条纪律合在一起——只有一份数据存放点;更新必须换引用并发射。破坏任何一条,界面是否更新就取决于"运气"(是否恰有异步事件触发检查)。
变式:同样的坑在 signal 版本里表现不同——原地改数组信号也不知道(需要替换数组或用 mutate 类更新),说明响应式原语不是免死金牌,不可变更新的纪律仍然成立。
下一节补上两条路线共同的底层语言:Observables 与 RxJS。