关于 ngrx-signals
ngrx-signals 是用于采用 `@ngrx/signals` 编写符合惯例、类型安全的 Angular 状态管理代码的技能,涵盖 Signal Store 组合、异步模式、实体集合、架构和测试。
主要功能
- 01类型化的 Signal Store 状态与更新
- 02可组合的 Store 功能
- 03异步方法模式指导
- 04实体集合模式
- 05通过公开 API 进行测试
适用场景
- 01构建 Angular 功能或页面状态
- 02重构基于 BehaviorSubject 的服务
- 03实现实体 CRUD 与乐观更新
- 04创建可复用的 Signal Store 功能
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ danielsogl/ skills --skill ngrx-signals安装后在 Claude Code 中输入/ngrx-signals确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
README.mdevals/evals.jsonevals/checks/tsc.shreferences/api-reference.mdcustom-features.mdentities.mdpatterns.mdtesting.md详细介绍
NgRx Signal Store
面向 Angular 的现代、轻量级、基于信号的状态管理方案。此技能可生成符合惯用写法、类型安全、架构良好的 @ngrx/signals 代码。
何时使用此技能
出现以下任一情况时触发:
- 用户提到这些 API:
signalStore、signalStoreFeature、withState、withMethods、withComputed、withLinkedState、withHooks、withProps、withFeature、withEntities、rxMethod、signalMethod、patchState、getState、watchState、signalState或@ngrx/signals/events/@ngrx/signals/testing。 - 用户希望将使用
BehaviorSubject/Subject的服务或带有可变字段的类重构为 Signal Store。 - 用户在 Angular 中询问功能/页面状态,并且提到 Angular Signals,或询问“现代”的实现方式。
- 用户想要自定义 Store 功能(如
withRequestStatus、withSelectedEntity、withLogger等),或询问如何组合功能。 - 用户在 Angular 功能中询问乐观更新、请求状态、实体集合,或如何通过 HTTP API 执行 CRUD。
不要用于传统的 @ngrx/store(actions、reducers、effects、单独文件中的 selectors)、不涉及 Signal Store 的 Redux DevTools 配置,或纯 RxJS 管道问题。
心智模型
Signal Store 是由多个功能组合而成的树。每个功能都可以提供状态、计算信号、方法、属性和生命周期钩子。组合发生在定义时:
export const FooStore = signalStore(
{ providedIn: 'root' }, // optional Angular provider config
withState(initialState), // declares state slices (signals)
withComputed(...), // derived signals
withProps(...), // non-reactive deps & values (often via inject())
withMethods(...), // actions / mutations
withHooks({ onInit, onDestroy })
);
每位 Signal Store 编写者都需要牢记以下三条规则:
- 状态更新必须通过
patchState(store, updater)完成。 不存在直接变更。Updater 可以是部分对象、函数(state) => Partial<State>,或返回上述任一种结果的自定义 updater。 - Store 是 Angular 提供者。 使用
inject(Store)或TestBed.inject(Store)注入。可在根级别、路由上或组件上提供(providers: [Store]),以管理组件级状态。 - 优先组合,而非继承。 通过
signalStoreFeature(...)构建自定义功能以实现复用,而不是扩展类。
标准脚手架(可作为起点)
对于 80% 的功能型 Store,都可以采用以下结构。复制并调整,然后查阅参考资料了解不明显之处。
import { computed, inject } from '@angular/core';
import {
patchState, signalStore, withComputed, withMethods, withState,
} from '@ngrx/signals';
import { Foo, FooApi } from './foo.api';
import {
setError, setFulfilled, setPending, withRequestStatus,
} from './with-request-status';
type FooState = {
items: Foo[];
filter: string;
};
const initialState: FooState = {
items: [],
filter: '',
};
export const FooStore = signalStore(
{ providedIn: 'root' },
withState<FooState>(initialState),
withRequestStatus(),
withComputed(({ items, filter }) => ({
filteredItems: computed(() =>
items().filter((i) => i.name.includes(filter()))
),
count: computed(() => items().length),
})),
withMethods((store, api = inject(FooApi)) => ({
setFilter(filter: string): void {
patchState(store, { filter });
},
async loadAll(): Promise<void> {
patchState(store, setPending());
try {
const items = await api.getAll();
patchState(store, { items }, setFulfilled());
} catch (e) {
patchState(store, setError(e instanceof Error ? e.message : 'Failed'));
}
},
})),
);
对于 ID 稳定的集合,将状态中的 items: T[] 替换为 withEntities<T>()。参见 references/entities.md。
工作流
- 确定作用范围。 仅限组件?使用
providers: [Store]。全应用?使用{ providedIn: 'root' }。按路由提供?在路由上提供。不要将路由级状态放在root中。 - 确定 Store/服务的职责划分。 状态、计算和流程编排属于 Store。HTTP / 持久化 / WebSocket / IndexedDB 属于单独的服务,由 Store 调用
inject()注入。两部分都可以独立测试;替换后端时无需修改 Store。 - 显式声明状态类型。 使用
withState<FooState>(initialState)——不要只依赖类型推断。后续调用patchState时需要该类型来检查拼写错误。 - 组合功能。 将请求状态、实体管理和日志记录提取到
signalStoreFeature(...),以便在多个 Store 中复用。 - 选择合适的异步工具。
- 只需等待单次调用时,使用
async方法。 - 需要 RxJS 运算符(
debounceTime、switchMap、重试、取消)时,使用rxMethod<Input>(pipe(...))。 - 输入为信号/值且方法体同步时,使用
signalMethod<Input>((value) => ...)——无需 RxJS 开销。 - 要向其中任一方法传入信号或计算函数?请在注入上下文中调用,或传入
{ injector }——在注入上下文之外调用已弃用。
- 只需等待单次调用时,使用
- 仅通过公开 API 进行测试。 使用
TestBed.inject(Store),通过provide:模拟注入的服务,并断言信号和方法产生的效果。使用@ngrx/signals/testing中的patchState(unprotected(store), ...)为受保护的状态设置初始值。
错误 → 正确(最重要的更正)
这些是最常出现的错误。生成代码时,请始终牢记这些模式。
1. updateEntity — 对依赖状态的更新使用函数形式
// ❌ Wrong: read entityMap manually then patch with a partial — race-prone
// and leaks store internals into the method body.
const current = store.entityMap()[id];
patchState(store, updateEntity({ id, changes: { completed: !current.completed } }));
// ✅ Right: function-form changes — atomic, no manual lookup.
patchState(store, updateEntity({
id,
changes: (todo) => ({ completed: !todo.completed }),
}));
2. removeEntities — 使用谓词形式,而不是 filter+map
// ❌ Wrong: pulls everything into the method body, fragile if signal shape changes.
const ids = store.entities().filter((t) => t.completed).map((t) => t.id);
patchState(store, removeEntities(ids));
// ✅ Right: predicate is evaluated against each entity by the updater.
patchState(store, removeEntities((todo) => todo.completed));
3. 乐观更新 — 使用 getState 创建快照,出错时还原
// ❌ Wrong: no rollback path — the UI lies if the API call fails.
patchState(store, addEntity(item));
await api.add(item);
// ✅ Right: snapshot, patch optimistically, restore on failure.
const previous = getState(store);
patchState(store, addEntity(item));
try {
await api.add(item);
} catch (e) {
patchState(store, { entityMap: previous.entityMap, ids: previous.ids });
throw e;
}
4. 请求状态 — 使用独立的更新器,而不是绑定到 store 的方法
// ❌ Wrong: reaching into the store from the feature, can't compose into patchState calls.
withMethods((store) => ({ setPending() { patchState(store, { requestStatus: 'pending' }); } }))
// ✅ Right: standalone updater functions that return Partial<State>.
// Compose with other updaters in a single patchState call.
export function setPending(): RequestStatusState { return { requestStatus: 'pending' }; }
patchState(store, setAllEntities(items), setFulfilled());
5. 带前置条件的自定义特性 — 使用类型化重载
// ❌ Wrong: bare signalStoreFeature — no compile error if used without withEntities.
export function withSelectedEntity<T>() {
return signalStoreFeature(withState({ selectedId: null }), /* ... */);
}
// ✅ Right: declare prerequisite via { state: type<EntityState<T>>() }.
// Now misuse is a compile error.
export function withSelectedEntity<T extends { id: EntityId }>() {
return signalStoreFeature(
{ state: type<EntityState<T>>() },
withState<{ selectedId: EntityId | null }>({ selectedId: null }),
withComputed(({ selectedId, entityMap }) => ({
selectedEntity: computed(() => {
const id = selectedId();
return id == null ? null : (entityMap()[id] ?? null);
}),
})),
withMethods((store) => ({
select(id: EntityId): void { patchState(store, { selectedId: id }); },
clearSelection(): void { patchState(store, { selectedId: null }); },
})),
);
}
6. 重构 BehaviorSubject — 各部分的对应关系
| 重构前 | 重构后 |
|---|---|
BehaviorSubject<T> 字段 | withState<FooState>(...) 中的状态切片 |
.next(...) | patchState(store, ...) |
.value | 信号 getter store.foo()(或 getState(store)) |
combineLatest / map 派生的可观察对象 | 返回 computed(...) 的 withComputed(...) |
| 服务上的 HTTP 方法 | 由 store 注入的新 FooApi 服务 |
组件通过 | async 订阅 | 读取信号:{{ store.foo() }}(不使用 async 管道) |
输出约定
生成代码时:
- 始终使用 TypeScript,明确声明状态类型,并明确从
@ngrx/signals/@ngrx/signals/entities/@ngrx/signals/rxjs-interop导入内容。 - 始终使用
patchState进行变更,绝不直接给信号赋值。 - 始终将 HTTP 逻辑放在通过
inject(SomeApi)注入的服务中;store 不导入HttpClient。 - 优先使用命名且类型明确的更新器函数(
setPending()、setError(msg)),尤其是在多个地方执行相同更新时。 - 优先使用
withEntities<T>(),而不是手写T[]数组(当条目具有稳定的id时)。 - 优先使用
signalMethod,而不是rxMethod(无需 RxJS 运算符时)。 - 仅在原因不明显时添加注释。不要赘述显而易见的内容。
模式 → 参考文件速查表
阅读与任务匹配的一份参考文件。大多数任务只需阅读一份。
| 任务 | 阅读 |
|---|---|
| 普通特性 store、计算属性、方法 | references/api-reference.md |
rxMethod / signalMethod / RxJS 桥接 | references/api-reference.md |
withLinkedState、watchState、事件插件、资源扩展 | references/api-reference.md |
| 实体集合(使用 ID 执行 CRUD) | references/entities.md |
自定义可复用特性(withXxx) | references/custom-features.md |
| 架构:作用域、乐观更新、BS 重构 | references/patterns.md |
| 测试:TestBed、模拟对象、signalMethod 测试 | references/testing.md |
如果任务涉及多种模式(例如,“带有乐观更新和请求状态的实体 store”),通常只需使用上方的框架代码和 entities.md。
需要指出的反模式
如果请求或现有代码中出现以下任何情况,请指出:
- 混淆
@ngrx/store和@ngrx/signals。 这是不同的包。createReducer/createEffect/@Effect属于经典 NgRx,而非 Signal Store。 - 在 store 中处理 HTTP。 将其移至服务。store 不应导入
HttpClient。 - 跨 store 导入。 store 绝不注入其他 store。应将状态提升到更高层,通过 feature 组合,或共享服务。
- 为实体数据手动维护
T[]数组。 如果 ID 稳定,请使用withEntities<T>()。 - 从外部直接修改 signal。
store.count.set(...)会破坏封装。应公开一个方法。 - 对简单的同步逻辑使用
rxMethod。 请使用signalMethod或普通方法。 - 在另一个切片发生变化时重新同步某个切片(例如,在
items变化时通过effect或方法重置selectedId)。请使用withLinkedState。 - 状态未指定类型。
withState({ count: 0 })可以正常工作;withState<CounterState>({ count: 0 })可确保后续补丁具有类型安全性。 - 使用过时的 API 名称。
withEffects(events 插件)现在是withEventHandlers;实体的idKey现在通过entityConfig使用selectId;rxMethod(...).unsubscribe()现在是.destroy()。