2.9 状态管理


文档摘要

2.9 状态管理 状态管理回答"数据放哪里、谁改它、改完谁看得见"。Angular 生态里从轻到重有三层方案:服务字段直存、BehaviorSubject 流式状态、信号(signal)响应式原语。本节用同一个购物车案例贯穿三层方案,重点讲清每层与变更检测的接口——状态容器决定"变更如何被感知",这一步做错,后面的优化全是补丁。 HttpClient 解决了"取数据",本节解决"数据落地后的组织":跨组件共享、可预测更新、与检查机制的成本结算。 带着一个问题进入 带着一个问题进入:组件之间互不引用,状态怎么共享?本节用两条主流路线分别回答: 给状态分类:服务端数据、客户端交互状态、URL 状态——各类归属不同容器。 用服务 + BehaviorSubject 实现可订阅的状态仓库。

2.9 状态管理

状态管理回答"数据放哪里、谁改它、改完谁看得见"。Angular 生态里从轻到重有三层方案:服务字段直存、BehaviorSubject 流式状态、信号(signal)响应式原语。本节用同一个购物车案例贯穿三层方案,重点讲清每层与变更检测的接口——状态容器决定"变更如何被感知",这一步做错,后面的优化全是补丁

HttpClient 解决了"取数据",本节解决"数据落地后的组织":跨组件共享、可预测更新、与检查机制的成本结算。

带着一个问题进入

带着一个问题进入:组件之间互不引用,状态怎么共享?本节用两条主流路线分别回答:

  1. 给状态分类:服务端数据、客户端交互状态、URL 状态——各类归属不同容器。
  2. 用服务 + BehaviorSubject 实现可订阅的状态仓库。
  3. 用 signal 重写同一仓库,对比两条路线在触发检查上的差别。
  4. 识别"状态复制"事故:同一份数据存两处导致的界面不一致。

一、先分类,再选容器

状态类别 例子 建议容器
服务端数据 订单列表、详情 服务中的缓存字段/信号,配 HTTP 拉取
客户端交互状态 选中项、展开态、购物车 组件内或共享服务
URL 状态 筛选词、页码 路由查询参数(呼应 2.6 节)
会话状态 令牌、当前用户 根级认证服务

分类的价值在于防"错位":把页码存进服务,刷新就丢;把令牌存进组件,路由一换就没。先归类再动手,能省掉大量补救代码。

二、路线一:服务字段 + BehaviorSubject

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 类更新),说明响应式原语不是免死金牌,不可变更新的纪律仍然成立。

本节要点回顾

  • 先分类后选容器:URL 状态归路由、会话归根服务、服务端数据归缓存仓库。
  • BehaviorSubject 仓库:对外只读流 + 快照读取;更新永远发射新引用。
  • signal 仓库:computed 缓存派生值;读取即登记依赖,更新精准标脏。
  • 两条铁律:单一存放点、不可变更新——违反则更新是否生效取决于运气。
  • 主线上的一环:状态容器决定了变更如何被框架感知,是第 3 章两种检查策略的分水岭。

下一节补上两条路线共同的底层语言:Observables 与 RxJS。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U