FORMA

状态管理

Angular 没有强制单一状态库:服务 + DISignalRxJS 即可覆盖多数场景;大型应用可选用 NgRx。见 Signal 基础依赖注入

核心概念

Angular 的「状态」通常分三个层次:

  1. 组件内状态:只影响单个组件的 UI 状态(展开/收起、当前 tab),用 signal 即可。
  2. 跨组件共享状态:多个不直接父子关系的组件需要同一份数据(购物车、当前用户),用 可注入服务 + Signal 承载,借助 Angular DI 的单例特性天然共享。
  3. 全局/复杂业务状态:涉及多步骤流程、时间旅行调试、严格的状态变更审计,用 NgRx 等 Redux 风格方案。

选型的关键是不要过度设计:多数中台/后台系统用第 2 层(服务 + Signal)就足够,只有状态流转复杂、团队规模大、需要强约束时才引入 NgRx。

选型简表

场景建议
组件内 UI 状态signal / computed
跨组件共享(中等复杂度)@Injectable({ providedIn: 'root' }) 服务 + signal
异步服务端数据HttpClient + toSignal / async 管道
复杂事件溯源、时间旅行调试NgRx Store

服务作为状态容器

ts
@Injectable({ providedIn: "root" })
export class CartService {
  private items = signal<CartItem[]>([]);
  readonly itemsReadonly = this.items.asReadonly();
  readonly total = computed(() => this.items().reduce((s, i) => s + i.price, 0));

  add(item: CartItem) {
    this.items.update((list) => [...list, item]);
  }

  remove(id: string) {
    this.items.update((list) => list.filter((i) => i.id !== id));
  }

  clear() {
    this.items.set([]);
  }
}

组件通过 inject(CartService) 读取,模板使用 cart.total()

ts
@Component({
  standalone: true,
  template: `
    <p>共 {{ cart.itemsReadonly().length }} 件,合计 {{ cart.total() }}</p>
    <button (click)="cart.clear()">清空</button>
  `,
})
export class CartSummaryComponent {
  cart = inject(CartService);
}

要点:内部可写状态用 private items = signal(...),只暴露 asReadonly()computed,防止外部组件绕过 add/remove 直接改数据,保持变更路径可追踪。

跨模块共享与生命周期

providedIn: 'root' 的服务在整个应用中是单例,只要不特意在某个组件/路由层重新 provide,所有注入点拿到的是同一份实例——这是 Angular DI 替代「全局 store」的关键机制。若需要每个路由/懒加载模块各自一份状态,则在该路由的 providers 数组里重新声明该 Service,形成子作用域实例。

ts
export const routes: Routes = [
  {
    path: "checkout",
    providers: [CartService], // 该路由子树内单独一份 CartService,退出后随路由销毁
    loadComponent: () => import("./checkout.component").then((m) => m.CheckoutComponent),
  },
];

NgRx(可选)

NgRx 实现 Redux 风格:StoreActionsReducersEffects(副作用)。适合多团队协作、强约束数据流;小项目可能过重。

ts
// 简化示意:Action、Reducer、Selector
export const increment = createAction("[Counter] Increment");
export const counterReducer = createReducer(0, on(increment, (state) => state + 1));
export const selectCount = createFeatureSelector<number>("count");
ts
@Component({
  template: `<p>{{ count() }}</p><button (click)="inc()">+1</button>`,
})
export class CounterComponent {
  private store = inject(Store);
  count = this.store.selectSignal(selectCount); // NgRx 提供 Signal 化 selector
  inc() {
    this.store.dispatch(increment());
  }
}

官方也提供 NgRx SignalStore,与 Signal API 更贴近,比传统 Store + Reducer + Effects 写法更简洁,适合不需要严格 Action/Reducer 分层但仍想要结构化状态管理的团队。

与 Vue Pinia / React Redux 对照

AngularVueReact
轻量方案Service + signalPiniaZustand / Context
重量方案NgRxVuex(旧)Redux Toolkit
派生状态computedcomputeduseMemo / selector
DevToolsNgRx Store DevToolsPinia DevToolsRedux DevTools

最佳实践

  • 优先用服务 + Signal,只有确认业务复杂度需要(多步骤流程、跨团队协作、严格审计)才引入 NgRx。
  • Service 内部状态尽量私有,只暴露 readonly 的 signal/computed,写操作封装成有业务含义的方法(addcheckout)。
  • 服务端数据(HTTP 响应)用 toSignal 转换为 Signal,避免手动 subscribe 后再赋值造成的内存泄漏风险。
  • 若引入 NgRx,先从 SignalStore 或局部 feature store 开始,不必一次性把全应用状态搬进全局 Store。
  • 路由级临时状态(如某个多步表单向导)用路由 providers 声明子作用域服务,随路由销毁自动清理。

常见坑

现象常见原因处理
服务里的数据「跨路由丢失」服务被声明在懒加载模块 providers 里形成了新实例确认服务应是 root 单例还是路由子作用域,按需选择
组件外部直接改了服务内部数组暴露了可写 signal 而非 asReadonly()/computed只导出只读引用,写操作走方法
NgRx 引入后代码量剧增、维护成本高业务并不复杂却套用完整 Redux 分层评估是否降级为 Service + Signal 或 SignalStore
computed 没有更新依赖的 signal 未被在 computed 函数体内实际读取(如被条件短路跳过)确保依赖的 signal 在每次求值路径中都被调用

延伸阅读

参考文献

以下链接在编写时均可正常访问:

资料说明
状态管理模式官方 Signal + 服务
NgRx Store英文
NgRx SignalStoreSignal 风格
依赖注入providedIn 作用域

Series

angular

13 / 14