03-状态管理
第 3 篇:状态管理 — React 与非 React 世界的状态桥接
本篇是《深入 Claude Code 源码》系列的第 3 篇。我们将深入分析 Claude Code 如何用一个 35 行的极简 Store 实现,桥接 React UI 与非 React 业务逻辑之间的状态管理,并理解三层状态架构的设计哲学。
为什么状态管理值得单独一篇?
Claude Code 面临一个独特的状态管理难题:它既是一个 React 应用,又不完全是。
终端 UI 用 Ink(React for CLI)渲染,组件需要响应式的状态更新。但核心业务逻辑 —— API 调用、工具执行、Agent 编排 —— 运行在 React 树之外。一次工具调用的结果需要同时:
- 更新 React 组件(显示在终端 UI 上)
- 被非 React 的
query.ts对话循环读取 - 被 Agent 子系统使用(可能运行在隔离的上下文中)
如果用 Redux/Zustand 这类库?太重了。React 内置的 useState/useReducer?无法从 React 树外部访问。模块级全局变量?无法触发 React 重渲染。
Claude Code 的答案是:三层状态架构 + 一个 35 行的自研 Store。
一、三层状态架构全景
在深入代码之前,先建立全局认知。Claude Code 的状态分布在三个层次,各有明确的职责边界:
1 | graph LR |
| 层次 | 文件 | 生命周期 | 核心用途 |
|---|---|---|---|
| Session 全局 | bootstrap/state.ts |
进程级,整个 session 存活 | sessionId、CWD、成本统计、遥测 |
| AppState Store | state/store.ts + AppStateStore.ts + AppState.tsx |
REPL 级,跟随 React 树 | UI 状态、权限、工具、插件、MCP |
| ToolUseContext | Tool.ts:158-254 |
每次交互/工具执行级 | 工具执行所需的全部运行时上下文 |
这三层的设计原则是向下依赖,向上隔离:ToolUseContext 引用 AppState 的 getter/setter;AppState 可以读取 bootstrap/state 的值;但反过来不成立。
二、第 1 层:bootstrap/state.ts — Session 级全局状态
文件:bootstrap/state.ts(约 600+ 行)
这是整个项目最底层的状态模块。文件开头有一条醒目的注释:
1 | // DO NOT ADD MORE STATE HERE - BE JUDICIOUS WITH GLOBAL STATE |
以及初始化函数前的另一条:
1 | // ALSO HERE - THINK THRICE BEFORE MODIFYING |
这两条注释透露了一个重要的设计决策:严格控制全局状态的规模。
2.1 为什么需要 bootstrap/state?
有些状态天然是进程级别的,不属于任何 React 组件,也不属于任何单次 query 循环:
- sessionId:标识当前会话,从启动到退出不变(除非 resume 另一个 session)
- CWD / projectRoot:工作目录,全局唯一
- 成本统计:
totalCostUSD、totalAPIDuration等累加器 - 遥测计数器:OpenTelemetry 的
Meter、Counter实例 - 模型使用统计:
modelUsage按模型名累积 token 用量
2.2 实现方式:模块级单例 + getter/setter
1 | // bootstrap/state.ts:429 |
这是最朴素的状态管理模式 —— 模块级闭包单例。STATE 是一个模块私有的对象,通过导出的 getter/setter 函数提供访问。没有发布-订阅,没有响应式,就是纯粹的命令式读写。
2.3 为什么不用 Store?
你可能会问:为什么不把这些状态也放进 AppState Store 里?
答案在于 import DAG(依赖有向无环图)的约束。bootstrap/state.ts 处于 import 树的最底部(叶子节点),几乎不 import 其他业务模块。需要澄清的是,state/store.ts 和 state/AppStateStore.ts 本身并不依赖 React —— 真正引入 React 的是 state/AppState.tsx。但问题的核心不在于 React 依赖,而在于分层约束:如果 bootstrap/state 反向依赖了更高层的应用状态模块(无论是 Store 还是 AppStateStore),就会破坏 DAG 的叶子节点地位,极易引入循环依赖。源码注释也明确提到这一点:
1 | // bootstrap can't import listeners directly (DAG leaf), so |
这是一个很有价值的工程决策:将最基础的状态放在依赖树的叶子节点,让所有人都能安全地引用它,而它不引用任何人。
2.4 State 类型的规模
State 类型定义了约 80+ 个字段,涵盖:
| 类别 | 代表字段 | 用途 |
|---|---|---|
| 身份标识 | sessionId, parentSessionId |
会话追踪 |
| 路径信息 | originalCwd, projectRoot, cwd |
目录管理 |
| 成本与性能 | totalCostUSD, totalAPIDuration, turnToolCount |
统计与计费 |
| 模型配置 | mainLoopModelOverride, initialMainLoopModel |
模型选择 |
| 遥测基础设施 | meter, sessionCounter, loggerProvider |
OpenTelemetry |
| Session 标记 | isInteractive, kairosActive, isRemoteMode |
运行模式 |
| 缓存状态 | promptCache1hEligible, afkModeHeaderLatched |
API 优化 |
三、第 2 层:Store + AppState — React 与非 React 的桥梁
这是整个状态管理系统最精妙的部分。它由三个文件组成,各司其职。
3.1 store.ts — 35 行极简 Store
文件:state/store.ts(35 行)
先看完整代码 —— 真的只有 35 行:
1 | // state/store.ts - 完整源码 |
这个 Store 的 API 只有三个方法:
| 方法 | 签名 | 用途 |
|---|---|---|
getState |
() => T |
同步读取当前状态 |
setState |
(updater: (prev: T) => T) => void |
函数式更新(避免 stale closure) |
subscribe |
(listener: () => void) => () => void |
订阅变更,返回取消函数 |
几个值得注意的设计细节:
Object.is相等性检查:如果 updater 返回的是同一个引用,跳过通知。这避免了不必要的重渲染。onChange回调:创建 Store 时可以传入一个onChange,每次状态变更时被调用,携带新旧两个状态。这个回调被用来做全局副作用 —— 比如同步权限模式到外部系统。updater函数式更新:不接受直接赋值(setState(newValue)),只接受函数(setState(prev => newValue))。这是故意的 —— 函数式更新确保每次调用都基于最新的状态快照,避免 stale snapshot 问题,也让多次异步更新可以正确组合(后一次 updater 拿到的prev是前一次更新后的结果)。Set<Listener>而非数组:用 Set 存储 listener,add/delete操作都是 O(1),且天然去重。
3.2 为什么自研而不用 Zustand?
Zustand 的核心 API 也是 getState/setState/subscribe,看起来很像。但 Claude Code 选择自研有几个原因:
- 零依赖:35 行代码,不需要引入任何库
- 完全可控:
onChange回调是 Zustand 不直接支持的特性 - TypeScript 优先:类型定义完全贴合项目需求
- 不需要中间件:没有 devtools、persist、immer 等需求
这个 Store 的 API 设计恰好匹配了 React 18 的 useSyncExternalStore 要求 —— 这不是巧合,而是为了桥接而精确设计的接口。
3.3 AppStateStore.ts — AppState 类型定义
文件:state/AppStateStore.ts(约 570 行)
这个文件定义了 AppState 类型和 getDefaultAppState() 工厂函数。AppState 是整个应用 UI 层面的单一状态树。
1 | // state/AppStateStore.ts:89(简化展示核心字段) |
几个关键设计点:
1. DeepImmutable<T> 包装
整个 AppState 被 DeepImmutable<T> 包装,所有属性递归变成 readonly。这强制所有状态变更必须通过 setState 函数进行,防止任何地方直接修改状态对象。
但注意 & { tasks, agentNameRegistry } 被排除在 DeepImmutable 之外。源码对 tasks 的原因给出了明确注释(AppStateStore.ts:159):
1 | // Unified task state - excluded from DeepImmutable because TaskState contains function types |
TaskState 包含函数类型(如 abortController),而 DeepImmutable 无法正确处理函数类型。agentNameRegistry(Map<string, AgentId>)也被放在 DeepImmutable 之外,但源码没有给出同样明确的因果说明 —— 可能是因为 Map 类型与 DeepImmutable 的递归 readonly 转换不兼容,但这属于结构推断,读者应区分对待。
2. 嵌套结构的组织
状态不是扁平的,而是按领域分组:mcp.* 管理 MCP 连接、plugins.* 管理插件、inbox.* 管理收件箱。这种结构让 selector 可以精确地订阅某个子树,只在相关状态变化时触发重渲染。
3. getDefaultAppState() 的初始化
1 | // state/AppStateStore.ts:456-569 |
注意有些默认值是动态计算的 —— 如 shouldEnableThinkingByDefault() 会根据当前模型能力决定是否默认开启 thinking。
3.4 AppState.tsx — React Context 桥接
文件:state/AppState.tsx
这是桥接的核心。它把非 React 的 Store<AppState> 连接到 React 的组件树中。
Provider 组件:
1 | // state/AppState.tsx(原始 TypeScript 源码,非编译后版本) |
核心技巧在这行:const [store] = useState(() => createStore(...))。
Provider 的 context value 是 store(Store 实例本身),而不是 store.getState()。Store 实例在创建后永远不变 —— 所以 context value 的稳定性避免了”因 context value 改变而导致的消费者全树重渲染”。当然,Provider 组件本身仍可能因父组件重渲染而重新执行(React 的正常行为),但这不会因 context value 变化而向下传播。真正驱动消费者更新的是 useSyncExternalStore 的 subscription 机制,而非 context 变更。这是一个关键的性能设计。
Consumer hook — useSyncExternalStore 桥接:
1 | // state/AppState.tsx:142-163 |
useSyncExternalStore 是 React 18 提供的官方 API,专门用于订阅外部数据源。它需要三个参数:
subscribe:注册监听函数getSnapshot:获取当前值getServerSnapshot:SSR 用,这里传同一个函数
当 store.setState 被调用时,所有 listener 被触发,useSyncExternalStore 内部重新调用 get(),如果 selector 返回值与上次不同(Object.is 比较),组件重渲染。
使用方式:
1 | // 组件中这样使用 — 只在 verbose 变化时重渲染 |
还有一个容错版本 useAppStateMaybeOutsideOfProvider,在没有 Provider 的上下文中返回 undefined 而不是 throw:
1 | // state/AppState.tsx:186-199 |
3.5 onChangeAppState — 全局副作用处理
文件:state/onChangeAppState.ts(172 行)
还记得 createStore 的第二个参数 onChange 吗?onChangeAppState 就是那个回调。它在每次状态变更后被调用,负责将 AppState 的变更同步到外部系统:
1 | // state/onChangeAppState.ts:43-92(核心片段) |
这个设计非常巧妙 —— 注释中解释了历史背景:
Prior to this block, mode changes were relayed to CCR by only 2 of 8+ mutation paths… Every other path mutated AppState without telling CCR, leaving external_metadata stale.
过去,权限模式变更需要在每个修改它的地方手动通知外部系统,导致 8 个修改路径中只有 2 个正确同步。现在通过 onChange 回调,任何 setState 调用导致的变更都会被集中处理,零遗漏。
四、第 3 层:ToolUseContext — 工具执行的运行时上下文
文件:Tool.ts:158-254
ToolUseContext 不是存储在 Store 中的状态,而是面向一次交互或一次工具执行的运行时上下文容器。它最常见的构建场景是 query 对话循环,但不限于此 —— REPL 命令执行(screens/REPL.tsx)、权限弹窗流转、side question fallback(utils/queryContext.ts:142-170)等路径也会构建 ToolUseContext。它是连接所有状态层的”传话人”。
1 | // Tool.ts:158-254(核心字段) |
4.1 为什么不直接传 Store?
ToolUseContext 明确包含 getAppState() 和 setAppState() 而不是直接传 Store 实例,这是有意为之的。因为不同的调用者需要不同版本的 getAppState/setAppState:
- 主对话循环:直接连接真实 Store
- 同步 Agent(如 Explore Agent):共享父级的 Store
- 异步 Agent(如后台 Agent):
setAppState被替换为 no-op,防止干扰 UI
4.2 createSubagentContext — Agent 隔离的关键
文件:utils/forkedAgent.ts:345-435
当一个 Agent 工具创建子 Agent 时,不能直接复用父级的 ToolUseContext —— 那会导致状态互相污染。createSubagentContext 负责创建隔离的上下文:
1 | // utils/forkedAgent.ts:345-435(简化展示) |
这里最精妙的设计是 setAppStateForTasks 的”始终穿透”特性:
即使 setAppState 被设为 no-op(异步 Agent 不应该修改 UI 状态),setAppStateForTasks 仍然连接到根 Store。为什么?因为后台 Agent 启动的 bash 任务需要注册到全局任务列表中,否则在进程退出时无法被正确清理 —— 注释中记录了血泪教训:
Task registration/kill must always reach the root store, even when setAppState is a no-op — otherwise async agents’ background bash tasks are never registered and never killed (PPID=1 zombie).
4.3 Selectors — 派生状态
文件:state/selectors.ts(77 行)
Claude Code 也有 selector 的概念,但非常轻量 —— 只用于从 AppState 派生需要复杂计算的视图状态:
1 | // state/selectors.ts:59-76 |
这个 selector 用可区分联合类型(Discriminated Union) 返回结果,调用方可以用 switch(result.type) 做类型安全的分支处理。
五、数据流全景:一次状态变更的完整旅程
让我们追踪一个具体场景:用户通过 /model 命令切换模型。
1 | sequenceDiagram |
- 用户输入
/model claude-3-haiku - 命令处理器调用
store.setState(prev => ({...prev, mainLoopModel: 'claude-3-haiku'})) - Store 内部
Object.is检查 —— 新旧不同,接受更新 onChangeAppState被触发:- 将模型写入
settings.json(持久化) - 更新
bootstrap/state的mainLoopModelOverride(进程级)
- 将模型写入
- 所有 React listener 被通知
- 使用了
useAppState(s => s.mainLoopModel)的组件重渲染
注意这个流程中,一次 setState 调用同时完成了三件事:更新内存状态、持久化到磁盘、通知 UI。这就是集中式 onChange 的威力。
六、可迁移的设计模式
模式 1:极简 Store + useSyncExternalStore
用 35 行代码实现一个 Store,通过 React 18 的 useSyncExternalStore 桥接到 React 组件。适用于任何需要在 React 和非 React 代码之间共享状态的场景。
关键要点:
- Context value 放 Store 实例(稳定引用),不放 state 值,避免因 context value 变化导致消费者全树重渲染
- 用 selector 订阅切片,避免全量重渲染
setState接受 updater 函数,避免 stale snapshot,让多次异步更新可正确组合
适用场景:中小型应用,不想引入 Zustand/Redux 等外部依赖,又需要跨 React/非 React 边界的状态共享。
模式 2:onChange 集中式副作用
在 Store 创建时传入 onChange 回调,集中处理所有状态变更的副作用(持久化、外部通知、缓存清理)。这比在每个 setState 调用处手动触发副作用可靠得多。
适用场景:状态变更需要同步到多个外部系统(数据库、API、其他进程),且修改入口很多(CLI 命令、UI 交互、配置文件变更)。
模式 3:Context 隔离 + 选择性共享
为子系统(如子 Agent)创建隔离的上下文副本,默认所有可变状态都是隔离的(no-op setter、克隆的缓存),只有经过明确 opt-in 的通道(如 setAppStateForTasks)才允许穿透到共享状态。
关键要点:
- 默认隔离,显式共享 —— 比默认共享、显式隔离安全得多
- 某些关键操作(如任务注册/清理)必须穿透隔离,否则会造成资源泄漏
适用场景:多 Agent/多租户系统、插件系统、任何需要在隔离环境中运行不受信代码的场景。
下一篇预告
第 4 篇:System Prompt 工程 — 精密控制模型行为的提示词体系
我们将深入 constants/prompts.ts 和 constants/systemPromptSections.ts,揭示 Claude Code 如何将数千字的系统提示词分段组装、分区缓存,以及如何通过提示词精确控制模型的行为(代码风格约束、安全指令、工具使用优先级)。
全部内容请关注 https://github.com/luyao618/Claude-Code-Source-Study (求一颗免费的小星星)