你输入 claude 按下回车,到看到交互界面,中间只有不到 200ms。但这段时间里发生的事情,比你想象的要精心得多。
启动的三个阶段
flowchart LR
A["📦 模块加载\n~135ms\nimport 语句执行"] --> B["⚙️ 环境初始化\nmain() 函数\n安全设置、模式判断"]
B --> C["🎯 CLI 解析\nrun() 函数\n解析参数、启动 REPL"]
style A fill:#dbeafe,stroke:#3b82f6
style B fill:#dcfce7,stroke:#22c55e
style C fill:#fef9c3,stroke:#eab308
三个阶段里,第一个阶段最有意思------它藏着一个很聪明的性能技巧。
阶段一:把慢操作藏在模块加载里
Node.js/Bun 程序启动时,所有 import 语句会依次执行,大约需要 135ms。这段时间 CPU 在忙,但有些 I/O 操作可以同步进行。
Claude Code 利用了这一点:在第一行 import 之前,就触发两个后台子进程。
gantt
title 启动时间线
dateFormat X
axisFormat %dms
section 主线程
模块加载(135ms) :0, 135
等待子进程结果 :135, 140
section 后台子进程
读取 macOS Keychain(65ms) :0, 65
读取企业 MDM 策略 :0, 80
这两个子进程是:
- Keychain 读取:读取 macOS 密钥链里存的 OAuth token 和 API Key,同步方式需要 65ms
- MDM 读取:读取企业设备管理策略(大公司用的)
通过在 import 语句期间就触发这两个操作,等模块加载完,子进程也差不多跑完了。等于把 65ms 的等待时间"藏"进了本来就要花的 135ms 里,净节省约 65ms。
这个技巧在 Claude Code 里反复出现:把慢操作藏在不可避免的等待时间里。
阶段二:启动时的特殊模式判断
main() 函数做的第一件事是搞清楚"我现在是什么模式"。同一个 claude 命令,根据参数不同,会走完全不同的路径:
flowchart TD
A["claude 启动"] --> B{"检测特殊参数"}
B --> C["cc:// 开头的 URL\n直接连接模式"]
B --> D["claude assistant\nAssistant 模式(企业版)"]
B --> E["claude ssh host\n远程 SSH 模式"]
B --> F["-p / --print\n非交互模式(管道输出)"]
B --> G["无特殊参数\n普通交互模式"]
C --> H["改写 process.argv\n让主流程统一处理"]
D --> H
E --> H
有意思的是,这些特殊模式都通过改写 process.argv 来处理,然后让同一套 Commander.js 解析逻辑统一处理,而不是各自写一套入口。这样代码复用,也方便测试。
阶段三:Commander.js 的 preAction 钩子
进入 CLI 解析阶段后,有一个关键的设计:真正的初始化不在程序启动时,而在命令执行前。
sequenceDiagram
participant 用户
participant Commander as Commander.js
participant preAction as preAction 钩子
participant 业务逻辑
用户->>Commander: claude --model sonnet "帮我写代码"
Commander->>preAction: 执行命令前先跑这个
preAction->>preAction: 等待 Keychain/MDM 子进程完成
preAction->>preAction: init():认证、配置、遥测初始化
preAction->>preAction: runMigrations():配置文件版本升级
preAction->>preAction: 异步加载远程配置(不阻塞)
preAction->>业务逻辑: 好了,你来
业务逻辑->>用户: 显示交互界面
为什么要用 preAction 而不是直接在启动时初始化?因为当用户运行 claude --help 时,根本不需要初始化任何东西。只有真正要执行命令时,才值得花这些时间。
配置迁移:每次启动都检查一次
每次 Claude Code 启动,都会检查本地配置文件是否需要升级。这个机制叫 runMigrations()。
flowchart LR
A["读取本地配置\nmigrationVersion"] --> B{"版本 == 11?\n(当前最新)"}
B -- 是 --> C["跳过,直接继续"]
B -- 否 --> D["依次执行所有迁移"]
D --> E["迁移 1:自动更新设置"]
E --> F["迁移 2:权限记录格式"]
F --> G["..."]
G --> H["迁移 11:模型名称更新"]
H --> I["写回 migrationVersion = 11"]
每次发布新版本的模型(比如从 Sonnet 4.5 升级到 Sonnet 4.6),都会加一个迁移函数,把用户本地存的旧模型名称自动改成新的。用户感知不到,配置文件自动跟上最新版本。
首屏渲染后:还有一波后台任务
界面显示出来之后,启动并没有结束。还有一批任务在后台悄悄跑:
flowchart TD
A["✅ 界面显示出来了"] --> B["后台开始跑这些"]
subgraph 后台任务
C["初始化用户信息"]
D["预取 git 状态"]
E["统计项目文件数量\n用于估算上下文窗口"]
F["刷新模型能力配置"]
G["初始化 Feature Flag"]
H["预取官方 MCP 服务器列表"]
I["监听配置文件变更\n支持热重载"]
end
B --> C
B --> D
B --> E
B --> F
B --> G
B --> H
B --> I
这些任务都是"用户开始打字的时候就在跑",等用户发出第一条消息,大部分都已经准备好了。
Feature Flag:用编译期裁剪控制功能
Claude Code 有很多实验性功能,通过 feature('FLAG_NAME') 来控制是否启用。
这不是普通的运行时开关------Bun 在打包时会静态分析 这些 feature() 调用,把 false 分支的代码完全从打包产物里删掉。
graph LR
subgraph "源码"
A["feature('KAIROS')\n? 加载 assistant 模块\n: null"]
end
subgraph "外部版本打包产物"
B["null\n(assistant 模块代码完全不存在)"]
end
subgraph "内部版本打包产物"
C["加载 assistant 模块\n(完整功能)"]
end
A --> B
A --> C
好处是:外部发布版本里,企业内部功能的代码完全不存在,不只是"禁用",而是根本不在包里。
小结:启动优化的核心思路
Claude Code 启动优化贯穿了一个思路:把不可避免的等待时间利用起来。
| 优化点 |
做法 |
节省时间 |
| Keychain 读取 |
在 import 期间提前触发 |
~65ms |
| 用户信息、git 状态 |
首屏渲染后后台预取 |
用户打字时完成 |
| 重量级模块(OpenTelemetry 等) |
懒加载,用到才导入 |
减少启动开销 |
| Feature Flag |
编译期删除无用代码 |
减少包体积 |
200ms 的启动时间,不是"写得快",是"每一毫秒都想清楚了"。