news 2026/9/27 1:35:12

NgRx SignalStore 状态追踪实战:从 `getState` + `effect` 到同步的 `watchState`

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
NgRx SignalStore 状态追踪实战:从 `getState` + `effect` 到同步的 `watchState`
  • 前端
  • 状态管理

【免费下载链接】platform

Reactive State for Angular

项目地址:https://gitcode.com/gh_mirrors/pl/platform
点击查看免费下载

状态追踪是 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有两大特点:

  1. 同步执行:状态一改变,watcher 立刻运行,不做同 tick 合并;
  2. 初始即执行:注册 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、3state-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 不泄漏到触发方 effectstate-source.spec.ts
Store 内外均可使用既能在withHooks.onInit中使用,也能在组件中注入后使用state-source.spec.ts

另外,测试还验证了watchState对signalState同样有效,因为signalState与 SignalStore 共享同一个STATE_SOURCE机制(见 signal-state.ts)。

两种追踪方式如何选择

维度getState+effectwatchState
执行时机异步、延迟到变更通知后同步、状态更新立即触发
同 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

项目地址:https://gitcode.com/gh_mirrors/pl/platform
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/27 1:34:15

魔百盒M401A刷机后必装应用与ADB调试全攻略

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/27 1:34:07

Mac系统内录终极方案:Blackhole音频路由原理与实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/27 1:33:38

烽火HG680-KB刷机全记录:安卓9.0固件与当贝桌面调校指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/27 1:33:35

PyTorch离线安装实战:版本选择、依赖打包到完整部署

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/27 1:33:28

车载TBOX功能测试全流程:从单元测试到整车验证的避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/27 1:32:48

UR5与D435i手眼标定实战:从ROS环境搭建到精度验证完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华