02-启动优化
第 2 篇:启动优化 – 毫秒级 CLI 启动的工程艺术
本篇是《深入 Claude Code 源码》系列的第 2 篇。我们将深入
cli.tsx和main.tsx的启动路径,揭示 Claude Code 团队如何将 CLI 启动时间优化到毫秒级别。
为什么启动速度如此重要?
CLI 工具的第一印象就是启动速度。用户在终端输入 claude 并回车的那一刻,心理预期是”即时响应”。如果启动需要 2 秒,用户会觉得卡顿;如果需要 5 秒,用户会开始怀疑是不是命令输错了。
对于 Claude Code 这样一个重量级应用——近 1900 个 TypeScript 文件、依赖 React/Ink/Yoga 渲染引擎、需要连接 MCP 服务器和 Anthropic API——要做到毫秒级启动,绝非易事。
本篇将揭示 Claude Code 采用的 6 大启动优化策略:
- 快速路径(Fast Path):对简单命令实现零 import 返回
- 侧效果前置(Side-Effect Hoisting):利用模块求值时间并行执行 I/O
- API 预连接(Preconnect):在用户还在打字时完成 TCP+TLS 握手
- 早期输入捕获(Early Input Capture):启动期间不丢失用户输入
- 编译期 Dead Code Elimination:
feature()函数让未使用的代码从构建产物中彻底消失 memoize防重复初始化:确保昂贵的初始化逻辑只执行一次
一、快速路径:让简单命令零代价返回
1.1 核心思想
Claude Code 的入口文件 cli.tsx 遵循一个核心原则:尽可能少加载模块,尽可能快返回。
当用户执行 claude --version 时,他们不需要 React、不需要 Ink、不需要 API 客户端、不需要工具系统——他们只需要一个版本号字符串。所以 cli.tsx 在最早的位置截断执行:
1 | // entrypoints/cli.tsx:33-42 |
MACRO.VERSION 是编译时内联的常量,连函数调用的开销都没有。整个 --version 路径不再动态加载任何额外业务模块。
注意:
cli.tsx并非完全没有顶层副作用。在main()函数之前,它还执行了几项环境修正:修复 corepack auto-pinning(COREPACK_ENABLE_AUTO_PIN = '0')、为 CCR 容器环境设置NODE_OPTIONS堆大小、以及内部版的 Ablation Baseline 环境变量批量写入。这些副作用的共同特点是:不 import 任何业务模块,仅操作process.env,耗时可忽略。
1.2 多层快速路径
--version 只是第一层。cli.tsx 定义了一个瀑布式的快速路径链,每条路径只动态 import 它真正需要的模块:
1 | flowchart TD |
注意一个关键细节:许多可裁剪的快速路径使用 feature() 做编译期门控。例如:
1 | // entrypoints/cli.tsx:100-106 |
feature('DAEMON') 在编译时会被替换为 true 或 false。如果构建的是不支持 Daemon 的版本,整个 if 分支(包括 import('../daemon/workerRegistry.js'))会被 Bun 的 Dead Code Elimination 完全删除。这意味着外部版构建产物中根本不存在这些快速路径的代码。
不过,并非所有快速路径都有 feature() 门控。例如 --claude-in-chrome-mcp 和 --chrome-native-host 这两条路径没有 feature() 包裹(cli.tsx:72-85),说明它们在所有构建版本中都可用。feature() 门控主要用于那些明确只属于特定构建版本的功能。
1.3 动态 import 的战术运用
注意所有快速路径中 import 的写法:
1 | // 动态 import —— 运行到此处时才加载模块 |
而不是顶部的静态 import。这是有意为之的——cli.tsx 顶部没有任何静态的业务模块 import(唯一的静态 import 是 bun:bundle,它是编译期原语,不引入运行时模块)。整个文件的设计哲学是:在确定了要走哪条路径之后,才加载那条路径需要的模块。
这个设计的效果是:如果 cli.tsx 顶部有 20 个静态 import,每次启动都要加载它们。但使用动态 import 后,claude daemon 只加载 daemon 相关模块,claude ps 只加载会话管理模块,互不影响。
二、侧效果前置:利用模块求值的”空闲时间”
2.1 问题背景
当快速路径都没有匹配时,cli.tsx 需要加载完整的 main.tsx。而 main.tsx 的静态 import 链非常庞大——有上百个静态 import 语句。根据源码注释(main.tsx:4),这些模块的求值大约需要 ~135ms。
在这段时间内,JavaScript 引擎忙着求值模块、执行顶层代码、构建依赖图。这段时间事件循环是被阻塞的——但操作系统的 I/O 子系统是空闲的!
Claude Code 团队的洞察是:有些启动时必须做的 I/O 操作(子进程、Keychain 读取),可以在模块求值开始前就启动,让它们与模块加载并行执行。
2.2 main.tsx 的前 20 行
这是整个代码库中最精妙的启动优化之一。让我们逐行分析 main.tsx 的开头:
1 | // main.tsx:1-20 |
注意这里的模式:import 语句和函数调用交错排列。在 JavaScript 中,模块求值是同步顺序执行的。所以执行流程是:
- 加载
startupProfiler.js(很小,几乎不耗时) - 调用
profileCheckpoint('main_tsx_entry')—— 记录入口时间 - 加载
rawRead.js(只依赖child_process和fs,很快) - 调用
startMdmRawRead()—— 启动 MDM 子进程但不等待结果 - 加载
keychainPrefetch.js(同样轻量) - 调用
startKeychainPrefetch()—— 启动 Keychain 子进程但不等待结果 - 开始加载剩余的上百个模块… 此时 MDM 和 Keychain 子进程已经在并行运行了!
2.3 MDM 子进程预取:startMdmRawRead()
MDM(Mobile Device Management)是 macOS/Windows 上的企业设备管理机制。Claude Code 需要读取 MDM 配置来应用企业策略。
1 | // utils/settings/mdm/rawRead.ts:120-123 |
fireRawRead() 内部是平台分支逻辑:
- macOS:并行读取多个 plist 文件路径(先
existsSync()检查文件是否存在,不存在则跳过 5ms 的 plutil 子进程) - Windows:并行查询 HKLM 和 HKCU 两个注册表路径
- Linux:直接返回空(无 MDM)
1 | // utils/settings/mdm/rawRead.ts:57-88 |
这里有两个值得注意的优化:
existsSync()前置检查:在非 MDM 管理的机器上(绝大多数开发者),plist 文件根本不存在。用同步的existsSync()跳过不存在的文件,避免了启动一个注定失败的 plutil 子进程(每个约 5ms)。Promise.all并行:如果有多个 plist 路径需要检查,它们是并行执行的,而不是串行等待。
2.4 Keychain 预取:startKeychainPrefetch()
这个优化更加精彩。在 macOS 上,Claude Code 需要从 Keychain 读取两个条目:
- OAuth token(
Claude Code-credentials):~32ms - Legacy API key(
Claude Code):~33ms
如果串行读取,这就是 ~65ms 的阻塞时间。而且原来的代码使用的是同步的 execSync,这意味着主线程完全阻塞。
预取方案是在 main.tsx 模块求值开始时就启动这两个读取,使用异步的 execFile:
1 | // utils/secureStorage/keychainPrefetch.ts:69-89 |
关键细节:
primeKeychainCacheFromPrefetch():将结果写入缓存,后续同步的 Keychain 读取会直接命中缓存,不再启动子进程。- 超时处理:如果预取超时(
timedOut),不会写入缓存——让后续的同步读取重试,而不是用一个可能不完整的结果。 isBareMode()跳过:--bare模式下跳过 Keychain 读取(该模式仅使用环境变量认证)。
2.5 等待预取完成
在 main.tsx 的 Commander preAction hook 中,所有预取的结果被统一等待:
1 | // main.tsx:907-916 |
注释中写道:”Nearly free — subprocesses complete during the ~135ms of imports above”。这两个子进程各需要 ~30ms,而 import 求值需要 ~135ms。所以等到 preAction 执行时,子进程早已完成,await 几乎是零耗时的。
效果:将 macOS 上 ~65ms 的串行阻塞优化为 ~0ms 的额外等待。
三、API 预连接:在用户打字时完成握手
3.1 TCP+TLS 握手的隐性成本
每次 HTTPS 请求的第一步是 TCP 连接 + TLS 握手,这通常需要 100-200ms。对于 Claude Code 来说,用户发送第一条消息时,这 100-200ms 是纯等待——API 还没开始处理请求,时间全花在了网络握手上。
Claude Code 的解决方案是 API 预连接:在 init() 阶段就向 Anthropic API 发送一个 HEAD 请求,提前完成 TCP+TLS 握手。源码注释指出,这在两种模式下都有效:交互模式下与”用户正在打字”的时间重叠;-p 模式下与 action-handler 的约 100ms 工作(setup、commands、MCP 配置)重叠:
1 | // utils/apiPreconnect.ts:31-71 |
3.2 为什么这行得通?
这个优化依赖于 Bun 的连接池机制:
fetch()使用全局 keep-alive 连接池:Bun 的 fetch 实现共享一个进程级的连接池HEAD请求没有 response body:连接在 headers 返回后立即可被复用- 后续 API 请求复用暖连接:当 Anthropic SDK 发起真正的 API 请求时,连接池中已经有一个完成了 TLS 握手的连接
3.3 调用时机的考量
预连接在 init() 内部调用,位于 applyExtraCACertsFromConfig() 和 configureGlobalAgents() 之后。这个顺序很重要:
1 | // entrypoints/init.ts:78-79 |
如果顺序反了——在 CA 证书配置前就预连接——会导致两个问题:
- 预连接使用错误的 TLS 证书,握手失败
- 更严重的是,Bun 的 BoringSSL 会在首次 TLS 握手时锁定证书存储,导致后续配置的自定义 CA 证书不生效
3.4 智能跳过
预连接在以下情况被跳过:
- 使用 Bedrock/Vertex/Foundry:不同的 API 端点,预连接 Anthropic API 无意义
- 使用代理/mTLS/Unix Socket:SDK 会使用自定义的 dispatcher/agent,不会复用全局连接池
- 这些情况下预连接反而会浪费一个连接,得不偿失
四、早期输入捕获:启动时不丢失用户按键
4.1 问题场景
很多用户的习惯是:输入 claude 回车后,立刻开始打要问的问题,不等 REPL 渲染完成。但在 REPL 初始化完成前,终端处于”无人接管 stdin”的状态——用户的击键会丢失。
Claude Code 的解决方案是在 cli.tsx 中尽早接管 stdin:
1 | // entrypoints/cli.tsx:288-291 |
注意时序:startCapturingEarlyInput() 在 import('../main.js') 之前调用。这意味着在 main.tsx 模块加载期间(源码注释称约 ~135ms),用户的输入已经被捕获了。
4.2 实现细节
1 | // utils/earlyInput.ts:29-67 |
processChunk() 函数处理了各种边界情况:
- Ctrl+C (code 3):立即退出进程,exit code 130
- Ctrl+D (code 4):EOF,停止捕获
- Backspace (code 127/8):删除最后一个 grapheme cluster(注意不是简单的删除最后一个 char,而是正确处理 Unicode 组合字符)
- ESC 序列:跳过箭头键、功能键等转义序列
- 回车 (code 13):转换为换行符
当 REPL 准备就绪后,调用 consumeEarlyInput() 获取缓冲区中的文本,并自动停止捕获:
1 | // utils/earlyInput.ts:164-169 |
4.3 与 Ink 的完整交接闭环
早期输入捕获不是一个孤立的模块——它与 Ink 框架和 REPL 组件有精确的交接协议。整个生命周期涉及三个参与者:
1. 停止捕获的时机
stopCapturingEarlyInput() 有三个调用点,覆盖了所有场景:
- 非交互模式(
main.tsx:807):-p/--init-only/--sdk-url等非交互模式下,直接停止捕获 - Ink 接管 stdin(
ink/components/App.tsx:224-228):当 Ink 的App组件首次启用 raw mode 时,必须先停止 early capture,因为两者都使用stdin.on('readable')+stdin.read()模式,不能共存——否则 early capture 的 handler 会抢先 drain stdin,Ink 的 handler 就读不到数据了 - 消费缓冲区时(
earlyInput.ts:165):consumeEarlyInput()内部自动调用stopCapturingEarlyInput()
1 | // ink/components/App.tsx:224-228 |
2. 消费缓冲区
REPL 组件在初始化时通过 useState 的 lazy initializer 消费早期输入:
1 | // screens/REPL.tsx:1331 |
这个设计很巧妙——useState 的 lazy initializer 只在组件首次挂载时执行一次,正好对应”REPL 准备就绪”的时刻。
3. 不重置 stdin 状态
stopCapturingEarlyInput() 不会调用 setRawMode(false) 来重置 stdin:
Don’t reset stdin state - the REPL’s Ink App will manage stdin state.
If we call setRawMode(false) here, it can interfere with the REPL’s own stdin setup which happens around the same time.
整个交接链是:cli.tsx 开始捕获 → Ink App 接管 stdin 时停止捕获 → REPL 组件消费缓冲区。三个阶段无缝衔接,既不丢输入,也不与 Ink 冲突。
五、编译期 Dead Code Elimination
5.1 feature() 函数机制
Bun 的 bun:bundle 提供了一个编译期函数 feature(),它在构建时被替换为字面量 true 或 false。配合 JavaScript 引擎的常量折叠优化,整个代码分支可以在编译期被删除:
1 | // 源码 |
5.2 构建期优化补充:feature() + require() 的组合拳
上面展示的 feature() 在 cli.tsx 中是直接的启动路径优化——未匹配的快速路径代码物理消失,减少了 cli.tsx 自身的大小。
但 feature() + require() 的组合还有另一个重要用途:构建产物裁剪。这主要影响的不是启动热路径的延迟,而是整个包的依赖图大小。例如在 tools.ts 中:
1 | // tools.ts:25-53 |
为什么这里用 require() 而不是 import?
- 静态
import无论条件如何都会被 bundler 纳入依赖图 require()是运行时调用,当feature()为false时,require()所在的分支被 DCE 删除,连带其模块引用一并消失- 效果:外部版的构建产物中,
SleepTool、CronTool、MonitorTool的代码物理上不存在
5.3 cli.tsx 中的 Ablation Baseline
cli.tsx 顶部有一个有趣的 feature() 使用:
1 | // entrypoints/cli.tsx:21-26 |
注释解释了为什么这段代码必须在 cli.tsx 而不是 init.ts:
BashTool/AgentTool/PowerShellTool capture DISABLE_BACKGROUND_TASKS into module-level consts at import time — init() runs too late.
某些工具模块在 import 时就读取环境变量并缓存为模块级常量。如果在 init() 中才设置环境变量,这些模块已经加载完毕,读到的是旧值。所以必须在模块求值开始前设置。这是一个典型的”执行时序”问题。
六、memoize 防重复初始化
6.1 init() 的 memoize 包装
init() 函数包含大量昂贵的初始化逻辑(配置验证、CA 证书、代理配置、mTLS、遥测等),它被 lodash 的 memoize 包装:
1 | // entrypoints/init.ts:57 |
memoize 确保无论 init() 被调用多少次,内部逻辑只执行一次,后续调用直接返回缓存的 Promise。这在复杂的启动流程中非常重要——不同的代码路径可能都需要确保初始化已完成,但不需要担心重复执行。
6.2 init() 内的延迟加载
init() 内部也运用了延迟加载策略。以 OpenTelemetry 初始化为例:
1 | // entrypoints/init.ts:306-310 |
注释说明了原因:OpenTelemetry + protobuf 模块约有 ~400KB,gRPC 导出器更是有 ~700KB。如果在启动时全部加载,会显著增加启动时间。通过动态 import,这些模块只在遥测真正初始化时才加载。
6.3 init() 中”安全”与”完整”环境变量的区分
init() 区分了两类环境变量的应用时机:
1 | // init.ts:73-74 |
完整的环境变量(applyConfigEnvironmentVariables())要等到 trust dialog 之后才应用。这是一个安全性考量:未确认信任的项目不应该能通过 .claude/settings.json 修改关键环境变量(如 ANTHROPIC_API_KEY、HTTP_PROXY)。
七、启动性能度量:profileCheckpoint()
7.1 双模式设计
启动性能不能只靠”感觉”,需要数据。Claude Code 的启动分析器有两种模式:
1 | // utils/startupProfiler.ts:26-36 |
- 采样日志:100% 内部用户 + 0.5% 外部用户,自动上报关键阶段耗时到 Statsig
源码考古发现:文件顶部的文档注释(
startupProfiler.ts:6)写的是”0.1% of external users”,但实际常量STATSIG_SAMPLE_RATE = 0.005对应的是 0.5%。注释与实现不一致——以常量为准。这种注释过时的情况在大型项目中很常见,也提醒我们在读源码时要以代码为准,注释仅作参考。
- 详细分析:
CLAUDE_CODE_PROFILE_STARTUP=1开启,输出完整的时间线报告和内存快照
7.2 零开销守卫
对于未被采样的 99.5% 外部用户:
1 | // utils/startupProfiler.ts:65-68 |
SHOULD_PROFILE 是模块加载时就确定的常量,不会改变。所以 JIT 编译器可以将这个函数优化为空操作。
7.3 关键阶段定义
1 | // utils/startupProfiler.ts:49-54 |
这些阶段定义了 Claude Code 团队最关心的启动性能指标。通过采样日志,他们可以在 Statsig 上看到全球用户的启动耗时分布,及时发现性能退化。
值得一提的是,main_after_run 这个 checkpoint 在源码中被打了两次——一次在 main() 函数末尾(main.tsx:855),一次在内部的 run() 函数末尾(main.tsx:4508)。PHASE_DEFINITIONS 中的 total_time 引用了这个名字,但 perf_hooks 的 getEntriesByType('mark') 会返回所有同名打点。这说明打点体系在实践中并不是教科书式完美的——但作为采样日志(而非精确计时),这个误差在可接受范围内。
八、启动优化的完整时间线
将以上所有优化策略串联起来,一次完整的交互式启动时间线如下:
1 | T+0ms cli.tsx 入口(顶层副作用:env 修正,无模块加载) |
注意:上述时间线中的毫秒数来源于源码注释(如
main.tsx:4提到 ~135ms),代表团队在开发时测量的典型值,实际耗时因机器性能和模块数量变化而不同。
九、可迁移的设计模式
模式 1:瀑布式快速路径
将 CLI 入口设计为一系列条件短路:每条路径只加载必需的模块,越简单的命令越早返回。使用动态 import() 而非静态 import,确保模块加载是按需的。
适用场景:任何有多个子命令的 CLI 工具。特别是当不同子命令的依赖差异很大时(如一个子命令需要 React,另一个只需要 fs),快速路径可以避免加载无关模块。
模式 2:I/O 前置并行化
利用 JavaScript 模块求值的阻塞时间,在 import 之间插入异步 I/O 的启动调用。子进程/网络请求在模块加载期间并行执行,加载完成后 await 几乎是零耗时的。
适用场景:任何启动时需要执行子进程、网络请求或文件读取的应用。关键是将”启动 I/O”和”等待 I/O 结果”分离,让两者之间填入模块加载的时间。
模式 3:API 预连接
在实际 API 请求之前,发送一个轻量的 HEAD 请求来预热 TCP+TLS 连接。利用 keep-alive 连接池,后续请求复用已建立的连接。
适用场景:任何需要与远端 API 通信的应用。特别是第一次请求延迟敏感的场景。注意要在 TLS 配置(CA 证书、代理)完成后再预连接,否则预连接使用的证书/通道可能与后续请求不一致。
下一篇预告
第 3 篇:状态管理 – React 与非 React 世界的状态桥接
我们将深入 state/store.ts,看看一个仅 35 行代码的极简 Store 实现如何同时服务 React 组件和非 React 的工具系统。你会发现 Claude Code 如何在 React Context 和命令式代码之间建立优雅的桥梁。
全部内容请关注 https://github.com/luyao618/Claude-Code-Source-Study (求一颗免费的小星星)