mcpField
W
web-state-ngrx-signalstore

web-state-ngrx-signalstore

github.com/agents-inc开发2637 次安装2 天前更新
代码仓库

关于 web-state-ngrx-signalstore

面向 Angular 开发者的 NgRx SignalStore 模式指南,涵盖特性组合、状态、实体、副作用、测试和迁移。重点说明特性顺序、类型化 Store,以及如何为不同任务选择响应式机制。

主要功能

  1. 01按顺序组合 Store 特性
  2. 02使用 patchState 更新状态
  3. 03通过 withEntities 管理键控集合
  4. 04使用 rxMethod 实现异步副作用
  5. 05创建可复用 Store 特性
  6. 06测试和迁移 Store

适用场景

  1. 01构建应用级或组件级 Store
  2. 02按 ID 管理实体集合
  3. 03取消已被新请求取代的搜索
  4. 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.yaml
reference.md
examples/
core.md
effects.md
entities.md
features.md
migration.md
testing.md

详细介绍

NgRx SignalStore 模式

快速指南: SignalStore 是一种 Angular 服务,由多个功能组成——withState、withComputed、withMethods、withHooks——并按此顺序应用,因为每个功能只能看到此前应用的内容。从 v18 开始,所有状态更新都必须通过 patchState;从 v19 开始,开发环境中的状态还会被深度冻结。因此,FormGroup 或任何其他可变对象都应放在 withProps 中,而不是状态中。

详细资源:


适用的路径

  • 作用域为整个应用的存储 — 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
rxMethodsignalMethod
需要 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>

评论

支持 Markdown