关于 web-state-ngrx-signalstore
面向 Angular 开发者的 NgRx SignalStore 模式指南,涵盖特性组合、状态、实体、副作用、测试和迁移。重点说明特性顺序、类型化 Store,以及如何为不同任务选择响应式机制。
主要功能
- 01按顺序组合 Store 特性
- 02使用 patchState 更新状态
- 03通过 withEntities 管理键控集合
- 04使用 rxMethod 实现异步副作用
- 05创建可复用 Store 特性
- 06测试和迁移 Store
适用场景
- 01构建应用级或组件级 Store
- 02按 ID 管理实体集合
- 03取消已被新请求取代的搜索
- 04从 actions 和 reducers 迁移
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ agents-inc/ skills --skill web-state-ngrx-signalstore安装后在 Claude Code 中输入/web-state-ngrx-signalstore确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
metadata.yamlreference.mdexamples/core.mdeffects.mdentities.mdfeatures.mdmigration.mdtesting.md详细介绍
NgRx SignalStore 模式
快速指南: SignalStore 是一种 Angular 服务,由多个功能组成——
withState、withComputed、withMethods、withHooks——并按此顺序应用,因为每个功能只能看到此前应用的内容。从 v18 开始,所有状态更新都必须通过patchState;从 v19 开始,开发环境中的状态还会被深度冻结。因此,FormGroup或任何其他可变对象都应放在withProps中,而不是状态中。
详细资源:
- examples/core.md —
signalStore、withState、withComputed、withMethods、withProps、DI 和组件级存储 - examples/entities.md —
withEntities、CRUD 更新器、prependEntity/upsertEntity(v20+) - examples/effects.md —
rxMethod、signalMethod(v19+)、副作用 - examples/features.md —
signalStoreFeature、自定义功能、调用状态、DevTools - examples/testing.md — 单元测试、
unprotected()、模拟策略 - examples/migration.md — 从 actions、reducers 和 effects 迁移
- reference.md — 对比表、反模式代码、TypeScript 配方、迁移步骤
适用的路径
- 作用域为整个应用的存储 —
signalStore({ providedIn: "root" });请参阅 examples/core.md。 - 作用域为组件的存储 — 将其列在组件的
providers中,这样组件销毁时它也会被销毁,其rxMethod订阅也会随之销毁;请参阅 examples/core.md。 - 按 id 索引的集合 — 使用
withEntities,而不是将数组存入状态;请参阅 examples/entities.md。
<critical_requirements>
编写 SignalStore 代码前
通过 patchState() 更新状态。 它会生成一个新的状态对象并通知信号;直接赋值给现有对象只会改变一个无人监听的值,而且从 v18 开始,状态受到保护,因此赋值会被直接拒绝。
按顺序排列功能,确保每个功能都能获取所需内容。 一个功能只能访问在它之前声明的成员,因此 withState 要放在 withComputed 之前,而 withComputed 又要放在读取它的 withMethods 之前。
使用 withEntities() 保存集合。 它会维护一个 entityMap 和一个 ids 数组,支持常数时间的 ID 查找和 CRUD 更新器;将数组放在状态中会导致每次查找都要遍历,而且更新逻辑也得手动编写。
将可变对象放在 withProps() 中,而不是状态中。 从 v19 开始,开发构建会递归冻结状态,因此存储在其中的 FormGroup 或类实例在首次内部写入时就会抛出异常。
通过 rxMethod() 处理可取消的异步操作。 它会将订阅与存储的生命周期绑定,并提供一个可以放置 switchMap 的位置,从而在请求被取代时取消请求,而不是让结果延迟返回。
</critical_requirements>
自动检测: signalStore、signalStoreFeature、withState、withComputed、withMethods、withHooks、withProps、withEntities、withFeature、withLinkedState、patchState、rxMethod、signalMethod、entityConfig、unprotected、@ngrx/signals
适用场景:
- 由多个功能组合存储,以及功能的排列顺序
- 状态更新、计算值和实体集合
- 副作用,无论是否需要 RxJS 操作符
- 可复用功能、生命周期钩子和调用状态跟踪
- 测试存储,以及从 actions 和 reducers 迁移
其他部分会介绍:
- 网络请求——请求本身应由服务处理;本技能介绍如何保存、推导与取消相关状态
- 单个组件读取的值——在组件中使用普通信号更简单
- 筛选、搜索和分页——这些内容应放在路由的查询参数中,以便在重新加载后保留,并可供他人共享
存储是一组从左到右应用的功能,每个功能都会向下一个功能接收的存储中添加成员。整体模型就是如此:没有 actions、reducers、selectors 或注册表——存储的类型是所有功能的累积结果,因此访问不存在的成员会导致编译错误,而不是得到 undefined。
由此可得两个结论。组合是线性的,因此顺序具有语义意义,而非风格选择——放在 withState 之前的 withComputed 无法访问它所读取的状态。存储也是 Angular 服务,因此其生命周期由 DI 决定:使用 providedIn: "root" 可创建一个实例,使用组件的 providers 可为每个组件创建一个实例,并在组件销毁时一同销毁。
<decision_framework>
使用 withState 还是 withEntities
Is it a collection of items with unique ids?
├─ NO → withState
└─ YES → Are items looked up or updated individually?
├─ YES → withEntities — entityMap for O(1) reads, updaters for writes
└─ NO → withState with an array, if it is only ever read in bulk
使用 withState 还是 withProps
Does the value participate in reactivity?
├─ YES → withState — signals, patchState, and the dev-mode freeze
└─ NO → withProps — services, subjects, form groups, anything that mutates itself
rxMethod 或 signalMethod
rxMethod | signalMethod | |
|---|---|---|
| 需要 RxJS | 是 | 否 |
操作符 — debounceTime、switchMap | 是 | 否 |
| 被取代的调用会取消 | 内置 | 需要自行处理 |
| 接受 | T、Signal<T>、Observable<T> | T、Signal<T> |
| 注入上下文 | 必需 | 不需要 |
任何可能发生竞争的场景——例如边输入边搜索,或根据不断变化的 id 发起请求——都适合使用带 switchMap 的 rxMethod。只对信号作出响应的副作用适合使用 signalMethod,且无需依赖 RxJS。
withMethods 中的普通 async 方法是第三种选择,对于由明确的用户操作触发的一次性获取,它是合理的方案;这类操作没有需要取消的请求,也没有需要响应的流。它带来了 try/catch,代价是无法取消——所以首先要确定的是:这类操作是否可能同时有两个请求在进行。
</decision_framework>
核心模式
模式 1:Store 组合
按顺序应用各项功能:先是状态,再是从状态派生的值,然后是修改状态的方法,最后是钩子。
export const CounterStore = signalStore(
{ providedIn: "root" },
withState({ count: 0 }),
withComputed(({ count }) => ({ doubled: computed(() => count() * 2) })),
withMethods((store) => ({
increment: () => patchState(store, { count: store.count() + 1 }),
})),
);
完整代码:examples/core.md
模式 2:使用 patchState 更新状态
可传入部分对象、更新函数,或在一次调用中传入多个此类参数——它们会作为单次通知应用。
patchState(store, { isLoading: true });
patchState(store, (state) => ({ count: state.count + 1 }));
patchState(store, setAllEntities(users), { isLoading: false });
完整代码:examples/core.md
模式 3:使用 withEntities 管理实体集合
规范化存储加上更新器:setAllEntities、addEntity/addEntities、setEntity/setEntities、updateEntity/updateEntities、removeEntity/removeEntities。
export const UserStore = signalStore(
withEntities<User>(),
withMethods((store) => ({
add: (user: User) => patchState(store, addEntity(user)),
})),
);
当 id 字段不是 id 时,使用 entityConfig() 指定字段名;命名集合则需要在每个更新器中传入其名称。
完整代码:examples/entities.md
模式 4:使用 rxMethod 实现 RxJS 副作用
可接受值、信号或可观察对象;工厂函数会对流进行管道处理,订阅会在 Store 生命周期结束时终止。
const search = rxMethod<string>(
pipe(
debounceTime(SEARCH_DEBOUNCE_MS),
switchMap((term) => api.search(term)),
tap((results) => patchState(store, { results })),
),
);
如果错误传递到外层流,它就会永久终止,因此 catchError 应放在 switchMap 内部。
完整代码:examples/effects.md
模式 5:使用 signalMethod 实现仅依赖信号的副作用(v19+)
对于不会发生竞争的副作用,可使用相同的响应式触发机制,无需 RxJS,也无需注入上下文。
const logSelection = signalMethod<string>((id) => {
analytics.track("selected", id);
});
只有参数信号会被跟踪;处理器内部读取的信号不会被跟踪。
完整代码:examples/effects.md
模式 6:使用 signalStoreFeature 创建可复用功能
一组命名功能,可应用于任何满足其声明结构的 Store。
export function withLoading() {
return signalStoreFeature(
withState({ isLoading: false }),
withMethods((store) => ({
setLoading: (isLoading: boolean) => patchState(store, { isLoading }),
})),
);
}
type<...>() 辅助函数声明宿主 Store 必须已提供的内容,这样才能解析该功能自身的类型。
完整代码:examples/features.md
模式 7:使用 withHooks 管理生命周期
Store 构造时会运行 onInit,其注入器销毁时会运行 onDestroy——因此,组件作用域的 Store 会按照组件自身的生命周期进行清理。
完整代码:examples/core.md
模式 8:调用状态
一个功能可为 Store 或命名集合维护 loading / loaded / error 状态,让每个异步操作都报告相同的三种状态。社区功能库提供了一个实现;其结构足够简单,也可以在项目中自行编写。
完整代码:examples/features.md
模式 9:使用 withFeature 实现上下文感知功能(v20+)
signalStoreFeature 组合功能时不会感知上下文,而 withFeature 会接收 store,因此通用功能可以调用 store 已有的方法。
// withEntityLoader here is a feature you wrote, not an NgRx export:
// it takes a fetch function and returns signalStoreFeature(...)
export const ProductStore = signalStore(
{ providedIn: "root" },
withMethods((store) => ({ load: rxMethod<string>(/* ... */) })),
withFeature((store) =>
withEntityLoader((id) => firstValueFrom(store.load(id))),
),
);
withFeature 正是实现这一点的关键:fetch 函数闭包引用了 store.load,而这个方法只有在应用其上方的 withMethods 后才会存在。
完整代码:examples/features.md
模式 10:withLinkedState(v20+)
从源信号派生但仍可写入的状态——默认跟随数据,并在用户选择某项后保持该选择。
export const FilterStore = signalStore(
withState({ items: [] as Item[] }),
withLinkedState(({ items }) => ({
selectedId: () => items()[0]?.id ?? null,
})),
);
withComputed 会提供相同的默认值,但不允许写入。
完整代码:examples/features.md
<red_flags>
风险信号
运行时会出错:
- 直接赋值到 state,而不是调用
patchState— v18 起会被保护机制拒绝;在此之前,赋值只会更改一个没有任何信号监听的值。 - 在
withState中保存会自行变更的FormGroup、类实例或其他对象 — v19 的开发模式冻结会递归进行,因此对象第一次尝试自行更新时就会抛出异常。此类对象应放在withProps中。 - 功能读取在它之后声明的成员 — 组合顺序是从左到右,因此该成员尚不存在,store 无法通过编译。
- 在
rxMethod的内部switchMap之外使用catchError— 错误会传到外部流,导致该流结束,而此方法在 store 的剩余生命周期中会静默停止工作。 - 在针对具名集合使用实体更新器时未设置
collection选项 — 更新器会改为操作默认集合,因此写入的内容不会被任何地方读取。 - 没有返回组合功能的
signalStoreFeature— 它虽然能通过类型检查成为一个函数,却不会添加任何内容,因此原本要添加的成员都不会出现。 - 两个 store 互相注入 — 这会形成构造循环,最终表现为 store 的成员未定义,而不是清晰的 DI 错误。
容易令人意外的行为:
rxMethod会在 store 销毁时自动取消订阅;额外手动取消订阅充其量是多余的。withHooks.onInit会在构造期间运行,因此在 init 时获取数据的 store 会在每个注入它的测试中都发起获取。signalMethod只会跟踪作为参数传入的信号——处理器内部读取的信号不会被跟踪,这是有意为之。signalMethod没有取消机制,因此两个正在进行的异步调用会按完成顺序解析;无论最后请求的是哪个,后完成的调用都会覆盖此前的写入。- 组件级 store 在每个组件实例中都会创建一个新实例,这正是其设计目的,也是状态在离开页面再返回后无法保留的原因。
- 深层嵌套的状态会让每次
patchState都重建整条路径;多个扁平切片则可以独立更新。 - 原地变更实体对象会跳过
updateEntity,因此entityMap引用不会改变,读取它的内容也不会重新渲染。 - 当 id 字段不叫
id时,必须用entityConfig()告知withEntities使用哪个字段;否则更新器会以undefined作为键。
</red_flags>