Skip to content

第13篇:状态管理 · 第3节 副作用同步 — 状态变更触发外部世界

reducer 保持纯粹;副作用在状态提交之后运行。本节讲解如何把「写文件、刷新 TUI、打点」编排成可测试、可取消、可去重的管线。


学习目标

能力项说明
分层严格区分「计算下一状态」与「对外界产生影响」
模式实现 runEffects(prev, next, action) 或中间层 effectScheduler
幂等对同一 state 指纹不重复写盘或重复上报
顺序处理 effect 之间的先后依赖(如先持久化再通知 UI)
故障effect 失败不反滚已提交 state 时的用户可见策略

生活类比:法院判决书与执行局

法官敲锤定下判决(新 state 成立),但不会亲自去查封账户。执行局在判决生效后启动(副作用):冻结、划扣、登记公示。若执行失败,判决书本身通常不会自动作废——而是走「执行异议 / 重试」流程。同理:store 已更新代表应用内部真相已变;写配置文件失败时,可提示用户、重试或回写「脏标记」,而不是悄悄把内存 state 改回旧值造成双真相


核心模式:订阅 + 差异驱动

typescript
// effects/sync.ts — 教学示意
export type EffectContext = {
  action: { type: string; payload?: unknown };
  prev: AppState;
  next: AppState;
};

export type Effect = (ctx: EffectContext) => void | Promise<void>;

export function attachSideEffects(store: Store<AppState>, effects: Effect[]) {
  let prev = store.getState();
  return store.subscribe(() => {
    const next = store.getState();
    const action = (store as unknown as { __lastAction?: EffectContext["action"] })
      .__lastAction ?? { type: "@@/UNKNOWN" };
    const ctx = { action, prev, next };
    for (const fx of effects) {
      void Promise.resolve(fx(ctx)).catch((e) => {
        console.error("[effect]", e);
      });
    }
    prev = next;
  });
}

生产实现中应在 dispatch 包装器里传入真实 lastAction,上例仅展示结构。

带 lastAction 的 dispatch 包装

typescript
export function withEffectContext(store: Store<AppState>) {
  const raw = store.dispatch.bind(store);
  let last: { type: string; payload?: unknown } = { type: "@@/INIT" };
  store.dispatch = (a) => {
    last = a;
    return raw(a);
  };
  return { ...store, getLastAction: () => last };
}

典型 effect 表

Effect触发条件行为
persistConfignext.config !== prev.config写入 ~/.claude/settings
flushMemdiraction.type === 'memdir/DIRTY'同步偏好目录
repaintTUIui 任意浅变局部 diff 渲染
telemetry关键 action匿名事件队列
auditLog工具调用结束追加本地只读日志

Mermaid:effect 管线

图2:幂等指纹


源码片段:去重写盘

typescript
let lastConfigFingerprint = "";

function fingerprintConfig(c: AppState["config"]): string {
  return JSON.stringify({
    v: c.schemaVersion,
    model: c.model,
    policy: c.approvalPolicy,
    exp: c.experimental,
  });
}

const persistConfigEffect: Effect = async ({ prev, next }) => {
  const fp = fingerprintConfig(next.config);
  if (fp === lastConfigFingerprint) return;
  if (fp === fingerprintConfig(prev.config)) return;
  lastConfigFingerprint = fp;
  await atomicWriteJson("~/.claude/settings.json", next.config);
};

与 Memdir / History 的协作

模块effect 角色
Memdirmemdir/DIRTY 或定时 flush;避免每次按键写盘
Historycheckpoint 前 snapshot session + 压缩消息列表
Migrationshydrate 之后业务 dispatch 之前跑一次性升级 effect

故障与一致性

场景推荐策略
写盘失败ui 显示「设置未保存」;内存 state 保持新值
遥测失败队列落本地,下次启动重发
UI 重绘抛错边界组件捕获,不影响 store
高频 actionrequestAnimationFrame 或 debounce 合并 repaint

表:禁止 vs 允许

位置禁止允许
reducerfsfetch、随机数纯计算
selectorI/O、Date.now 用于缓存键纯派生
effect修改 reducer 输入的旧对象next、调用外部 API

小结

副作用同步的本质是:先承认新状态,再与世界对齐。通过 subscribe + prev/next 对比 + 指纹去重,可以在不引入重型中间件的前提下,保持 Claude Code 风格应用的可测性与可恢复性


自测

  1. 若 effect 内再次 dispatch,需注意哪些死锁或顺序问题?
  2. 如何把「仅会话结束写一次」与「配置立即写」放在同一 runner?
  3. atomicWriteJson 在实现上通常用什么策略避免半写文件?

进阶:按 action 类型的 effect 注册表

将 effect 从线性数组改为模式匹配,可降低无关调用的空转成本(大状态树时尤其明显):

typescript
type EffectMap = Partial<Record<string, Effect[]>>;

export function attachKeyedEffects(
  store: Store<AppState>,
  map: EffectMap,
  getLastAction: () => { type: string }
) {
  let prev = store.getState();
  return store.subscribe(() => {
    const next = store.getState();
    const action = getLastAction();
    const list = map[action.type] ?? [];
    const ctx = { action, prev, next };
    for (const fx of list) void Promise.resolve(fx(ctx)).catch(console.error);
    prev = next;
  });
}
设计点说明
默认桶 *可放「任意 action 都跑」的遥测
顺序同 type 多 effect 时数组顺序即执行序
测试map['tools/INVOCATION_END'] 单测即可隔离

与架构全景(第8节)的对照

副作用层是有向无环图上的叶节点:只读 next、写外部世界,禁止再经隐蔽通道改写 reducer 输入。这样在全链路调试时,只需比对「action 序列 + 最终 state」,不必猜测某个定时器是否偷偷改了内存。


上一节02-app-state.md · 下一节04-memdir.md

本项目仅用于教育学习目的。Claude Code 源码版权归 Anthropic, PBC 所有。