- 前端
- 状态管理
【免费下载链接】platform
Reactive State for Angular
状态追踪是 SignalStore 实现自定义扩展功能的基石,例如日志记录、状态撤销/重做(undo/redo)与存储同步(如 localStorage 持久化)。本文以 state-tracking.md 文档为主体,结合@ngrx/signals的 state-source.ts 源码与 state-source.spec.ts 测试用例,讲解两种追踪方式的差异与取舍,帮助你掌握getState与watchState的完整用法,并能基于它们实现自定义 SignalStore 特性。
为什么需要状态追踪
SignalStore 的每一个状态切片都对应一个独立的 signal,视图可以直接读取store.count()这样的状态信号。但当你需要"观察整体状态的变化"——例如把每次变更后的完整 state 快照记录下来时,就需要一种能够感知任意状态切片变化的机制。
在@ngrx/signals中,这个能力由 state-source.ts 中的getState与watchState两个函数提供,它们都从 index.ts 对外导出,统一适用于 SignalStore 与signalState两种状态源。
方式一:getState搭配effect
getState用于读取 SignalStore(或signalState)的当前状态快照。它的特殊之处在于:当在一个响应式上下文(如effect、computed)中被调用时,内部读取的每一个状态 signal 都会被自动追踪,状态变化后调用方会自动重新执行。
从 state-source.ts 的源码可以看出其实现原理:它遍历存储在STATE_SOURCE符号上的全部状态信号,逐个读取并聚合成一个新的 state 对象:
export function getState<State extends object>( stateSource: StateSource<State> ): State { const signals: Record<string | symbol, Signal<unknown>> = stateSource[STATE_SOURCE]; return Reflect.ownKeys(stateSource[STATE_SOURCE]).reduce((state, key) => { const value = signals[key](); return { ...state, [key]: value }; }, {} as State); }因此getState并不是一个"一次性快照工具",而是能够参与到响应式追踪中的读取函数。
基础示例:effect 中追踪状态变化
下面是一个计数器 Store,通过withHooks的onInit钩子在effect内调用getState,实现"状态一变就打印":
import { effect } from '@angular/core'; import { getState, patchState, signalStore, withHooks, withMethods, withState, } from '@ngrx/signals'; export const CounterStore = signalStore( withState({ count: 0 }), withMethods((store) => ({ increment(): void { patchState(store, { count: store.count() + 1 }); }, })), withHooks({ onInit(store) { effect(() => { // 👇 The effect is re-executed on state change. const state = getState(store); console.log('counter state', state); }); setInterval(() => store.increment(), 1_000); }, }) );每隔 1 秒调用一次increment,effect 就会带着最新的 state 重新执行。
effect 的"无闪烁(glitch-free)"合并行为
受effect本身 glitch-free 特性的影响:如果同一个 tick 内状态被多次修改,effect 只会以最终状态执行一次。例如连续两次调用increment,你只会看到一次日志,内容是最终值。
这种异步合并对性能是友好的,但对某些功能却是障碍。文档原文明确指出:像状态 undo/redo 这样的功能需要记录 SignalStore 的全部状态变化,而不能把同一 tick 内的多次更新合并掉。这就是watchState存在的意义。
方式二:watchState同步追踪每一次变化
watchState允许同步地追踪 SignalStore 的状态变化。它接收两个参数:
- 第一个参数:SignalStore(或
signalState)实例; - 第二个参数:watcher 回调函数,在每次状态变化后执行。
默认情况下,watchState必须在注入上下文(injection context)中调用,并绑定其生命周期——当所在 injector 被销毁时,watcher 自动清理。
基础示例:effect 与 watchState 的行为对比
import { effect } from '@angular/core'; import { getState, patchState, signalStore, watchState, withHooks, withState, } from '@ngrx/signals'; export const CounterStore = signalStore( withState({ count: 0 }), withMethods((store) => ({ increment(): void { patchState(store, { count: store.count() + 1 }); }, })), withHooks({ onInit(store) { watchState(store, (state) => { console.log('[watchState] counter state', state); }); // logs: { count: 0 }, { count: 1 }, { count: 2 } effect(() => { console.log('[effect] counter state', getState(store)); }); // logs: { count: 2 } store.increment(); store.increment(); }, }) );在这个例子中,store.increment()被连续调用两次:
watchState的 watcher 会被执行3 次:一次携带初始状态{ count: 0 },随后每次 increment 各一次({ count: 1 }、{ count: 2 });effect只会执行1 次,且携带最终状态{ count: 2 }。
也就是说,watchState有两大特点:
- 同步执行:状态一改变,watcher 立刻运行,不做同 tick 合并;
- 初始即执行:注册 watcher 后会立刻以当前状态调用一次(这正是上面出现
{ count: 0 }的原因)。
源码视角:watchState 是如何做到"逐个通知"的
从 state-source.ts 可以看到watchState的实现骨架:
export function watchState<State extends object>( stateSource: StateSource<State>, watcher: StateWatcher<State>, config?: { injector?: Injector } ): { destroy(): void } { if (typeof ngDevMode !== 'undefined' && ngDevMode && !config?.injector) { assertInInjectionContext(watchState); } const injector = config?.injector ?? inject(Injector); const destroyRef = injector.get(DestroyRef); addWatcher(stateSource, watcher); executeWatcher(stateSource, watcher); const destroy = () => removeWatcher(stateSource, watcher); destroyRef.onDestroy(destroy); return { destroy }; }关键机制对应如下:
- 注入上下文校验:当没有显式传入
injector时,开发模式下会调用assertInInjectionContext断言,脱离注入上下文调用会抛出NG0203: watchState() can only be used within an injection context错误; - 自动清理:从当前(或传入的)injector 中取得
DestroyRef,在onDestroy中自动移除 watcher; - 注册即执行:
addWatcher之后立即调用executeWatcher,所以第一次回调携带的是初始状态; - 同步通知:
patchState在更新状态信号的末尾会调用notifyWatchers,遍历并同步执行所有 watcher,因此同 tick 内的多次patchState会触发多次回调,不会被合并。
值得注意的实现细节是executeWatcher使用了untracked包裹(state-source.ts):
function executeWatcher<State extends object>( stateSource: StateSource<State>, stateWatcher: StateWatcher<State> ): void { untracked(() => { const state = getState(stateSource); stateWatcher(state); }); }这意味着 watcher 内部读取的任何 signal不会泄漏到外层响应式上下文。对应的测试用例(state-source.spec.ts)专门验证了:当patchState由某个effect触发时,watcher 中读取其他 signal 不会让该 effect 额外重跑。
手动清理:调用destroy
watchState返回一个包含destroy方法的对象。如果需要在 injector 销毁之前提前停止观察,手动调用destroy即可:
import { patchState, signalStore, watchState, withHooks, witMethods, withState, } from '@ngrx/signals'; export const CounterStore = signalStore( withState({ count: 0 }), withMethods((store) => ({ increment(): void { patchState(store, { count: store.count() + 1 }); }, })), withHooks({ onInit(store) { const { destroy } = watchState(store, console.log); setInterval(() => store.increment(), 1_000); // 👇 Stop watching after 5 seconds. setTimeout(() => destroy(), 5_000); }, }) );这里watchState(store, console.log)的返回值被解构出destroy,在 5 秒后调用以终止观察,此后状态再变化也不会触发 watcher。
从源码看,destroy的本质是调用removeWatcher,把该 watcher 从STATE_WATCHERS(一个以状态源为 key 的WeakMap)中过滤掉(state-source.ts)。
在注入上下文之外使用:传入injector
watchState也可以在注入上下文之外使用,方法是把injector作为第三个参数(config 对象)传入,此时生命周期绑定到该 injector:
import { Component, inject, Injector, OnInit } from '@angular/core'; import { watchState } from '@ngrx/signals'; import { CounterStore } from './counter-store'; @Component({ /* ... */ providers: [CounterStore], }) export class Counter implements OnInit { readonly #injector = inject(Injector); readonly store = inject(CounterStore); ngOnInit(): void { watchState(this.store, console.log, { injector: this.#injector, }); setInterval(() => this.store.increment(), 2_000); } }在这个组件示例中,watcher 的生命周期与组件的 injector 绑定——组件销毁时 watcher 自动清理,无需手动调用destroy。源码中config?.injector ?? inject(Injector)(state-source.ts)正是这条分支的实现。
测试用例佐证:三种清理路径都被覆盖
state-source.spec.ts 为watchState提供了一套完整的验证,可以直接作为你理解其行为的参考:
| 场景 | 测试要点 | 位置 |
|---|---|---|
| 初始即执行 | 注册后立即收到初始状态0,随后patchState三次各收到1、2、3 | state-source.spec.ts |
| injector 销毁自动清理 | 服务销毁后不再收到后续更新 | state-source.spec.ts |
手动destroy清理 | 调用destroy后 watcher 停止 | state-source.spec.ts |
| 传入 injector 的清理 | 多个 injector 各自独立销毁、互不影响 | state-source.spec.ts |
| 脱离注入上下文报错 | 抛出NG0203错误 | state-source.spec.ts |
| 响应式上下文隔离 | watcher 内读取 signal 不泄漏到触发方 effect | state-source.spec.ts |
| Store 内外均可使用 | 既能在withHooks.onInit中使用,也能在组件中注入后使用 | state-source.spec.ts |
另外,测试还验证了watchState对signalState同样有效,因为signalState与 SignalStore 共享同一个STATE_SOURCE机制(见 signal-state.ts)。
两种追踪方式如何选择
| 维度 | getState+effect | watchState |
|---|---|---|
| 执行时机 | 异步、延迟到变更通知后 | 同步、状态更新立即触发 |
| 同 tick 多次变更 | 合并为一次,只取最终值 | 每次都触发,不合并 |
| 初始状态 | 需要显式读取 | 注册后立即回调一次 |
| 适用场景 | 日志、派生副作用、UI 联动 | undo/redo、状态快照、存储同步 |
| 生命周期 | 由effect所在上下文管理 | 绑定 injector 的DestroyRef,或手动destroy |
文档开头即点明了状态追踪的典型应用方向:日志记录(logging)、状态撤销/重做(undo/redo)、存储同步(storage synchronization)。结合watchState的"同步、逐次、不合并"特性,你可以:
- 实现 undo/redo:watcher 中把每次状态快照 push 进历史栈,撤销时从栈中弹出上一个快照并用
patchState恢复,再配合destroy在必要时停止记录; - 实现存储同步:watcher 中把最新状态写入
localStorage/sessionStorage,Store 初始化时再从存储中读取并patchState; - 实现日志/审计:用
getState+effect做轻量级的变更日志即可满足大多数需求。
小结
状态追踪是搭建自定义 SignalStore 特性的底层能力:getState负责在响应式上下文中读取整体状态并参与自动追踪,watchState则提供同步、逐次、自动绑定生命周期的状态变更回调。两者由 state-source.ts 统一实现,并得到了 state-source.spec.ts 的完整验证。选择哪种方式,取决于你的场景能否容忍同 tick 内的状态合并——需要完整变化轨迹时,watchState是明确答案。
- 前端
- 状态管理
【免费下载链接】platform
Reactive State for Angular
相关推荐
NgRx SignalStore 完整实战指南:用 Signals 构建可扩展的 Angular 状态管理
NgRx SignalStore 完整实战指南:用 Signals 构建可扩展的 Angular 状态管理 NgRx SignalStore 是 NgRx 提供
前端状态管理NgRx SignalStore Events 插件实战:基于事件驱动的响应式状态管理指南
NgRx SignalStore Events 插件实战:基于事件驱动的响应式状态管理指南 Events 插件为 NgRx SignalStore 引入了一层基
前端状态管理NgRx ESLint 规则实战:`with-state-no-arrays-at-root-level` 与 SignalStore 状态根级约束
NgRx ESLint 规则实战: with state no arrays at root level 与 SignalStore 状态根级约束 导读 本文围
前端状态管理
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考