Appearance
CLI 工具怎麼做到秒開?
先感受一下問題
打開 main.tsx,前 200 行全是 import 語句。200 個模組。如果全部同步載入,啟動可能要 2-3 秒。
但使用者只是想看看版本號——claude --version——他們不想等 2 秒。
Claude Code 用了 6 種策略把「最快的路徑」加速到毫秒級,同時讓「完整啟動」也足夠快。
策略一:快速路徑——不需要的東西就不載入
打開 cli.tsx(程式的第一個進入點),你會看到:
typescript
async function main(): Promise<void> {
const args = process.argv.slice(2);
// 快速路徑:--version 不載入任何模組
if (args[0] === '--version') {
console.log(`${MACRO.VERSION} (Claude Code)`);
return; // 整個程式結束,沒載入任何東西
}
// 只有不是 --version 時,才開始載入東西
const { profileCheckpoint } = await import('../utils/startupProfiler.js');
// ...
}關鍵字:return。--version 路徑在印出版本號後就直接結束了。它沒有 import 任何東西、沒有初始化任何東西、沒有連線到任何服務。
所有的 import 都是 await import()(動態匯入),而不是檔案頂部的靜態 import。這意味著只有當程式真的需要某個模組時,才會去載入它。
「分支 return」模式
cli.tsx 的結構像一棵決策樹,每個分支都以 return 結束:
使用者輸入
├── --version → 印版本 → return(零 import)
├── --daemon-worker → 載入 daemon 模組 → return
├── remote-control → 載入 bridge 模組 → return
├── daemon → 載入 daemon 主程式 → return
├── ps/logs/attach → 載入會話管理 → return
└── 其他 → 載入完整 main.tsx(最慢的路徑)用流程圖看整個分支結構:
每個分支只載入自己需要的模組。如果你跑的是 claude daemon,你不會載入 bridge 的程式碼,也不會載入 REPL 的程式碼。
實戰思考
看看你的 CLI 工具或服務的入口點。有沒有「不管做什麼都全部載入」的情況?試試把常用的快速操作提取成獨立的分支,讓它們不需要載入完整的應用程式。
策略二:建置時消除不需要的程式碼
什麼是「死碼消除」?
你的程式碼中可能有很多「只在特定條件下才使用」的功能。比如「只有內部員工才能用的 debug 工具」。如果你用普通的 if 判斷:
typescript
// 普通的 if 判斷——程式碼仍然被打包進最終檔案
if (process.env.USER_TYPE === 'ant') {
const debugTool = require('./debugTool.js')
debugTool.run()
}即使程式執行時 USER_TYPE 永遠不是 'ant',那個 if 區塊裡的 debugTool.js 程式碼仍然被打包在你的最終檔案中。它就躺在那裡,佔空間、增加解析時間,只是永遠不會被執行。
這就像你搬家時把冬天的厚外套也搬到了夏威夷的新家——你永遠不會穿,但它佔了衣櫃空間。
feature() 的魔法——搬家前就把厚外套丟掉
Claude Code 用的是 feature() 函數:
typescript
import { feature } from 'bun:bundle'
if (feature('DAEMON')) {
const { daemonMain } = await import('../daemon/main.js');
await daemonMain();
}feature() 不是一個普通的函數呼叫。它和 process.env 有本質的區別:
process.env.X | feature('X') | |
|---|---|---|
| 什麼時候判斷? | 程式執行時 | 程式打包時 |
| 程式碼還在不在? | 在,只是不執行 | 完全被刪除 |
| 打包後的檔案大小 | 不變 | 可能小很多 |
打包工具(Bun)在建置時把 feature('DAEMON') 替換成字面量 true 或 false。如果替換成 false:
typescript
// 打包工具看到的是這樣:
if (false) {
const { daemonMain } = await import('../daemon/main.js');
await daemonMain();
}
// 打包工具說:「這段程式碼永遠不會執行」
// 然後直接刪掉整個 if 區塊
// 連 daemon/main.js 這個模組都不會被打包
// 最終檔案中完全不存在這些程式碼效果有多大? Claude Code 有幾十個 feature gate:DAEMON、BRIDGE_MODE、COORDINATOR_MODE、KAIROS、PROACTIVE、HISTORY_SNIP、REACTIVE_COMPACT……外部版本把這些全部關掉後,打包出來的檔案可能比內部版本小 30%。
用搬家的比喻:這不是「搬到夏威夷後把厚外套塞到角落」,而是「搬家之前就把厚外套捐掉,根本不裝箱」。
為什麼用 require() 而不是 import()?
在 feature() 的 if 區塊中,Claude Code 用的是 require():
typescript
const coordinatorModeModule = feature('COORDINATOR_MODE')
? require('./coordinator/coordinatorMode.js')
: null原因:require() 是同步的,Bun 的 bundler 能更可靠地識別和消除它。動態 import() 是非同步的,bundler 不容易判斷「這個 import 是否可以安全刪除」。
實戰思考
如果你使用 Vite、Webpack 或其他打包工具,查看它們的「定義常量」功能(Vite 的 define、Webpack 的 DefinePlugin)。你可以用類似的方式在建置時消除不需要的程式碼。
策略三:趁等待的時間預先載入
main.tsx 的前 20 行做了一件很聰明的事:
typescript
// 在所有其他 import 之前執行這些——
// 它們啟動的子程序可以和後續 135ms 的 import 平行運行
import { profileCheckpoint } from './utils/startupProfiler.js';
profileCheckpoint('main_tsx_entry');
import { startMdmRawRead } from './utils/settings/mdm/rawRead.js';
startMdmRawRead(); // 啟動 MDM 設定讀取子程序
import { startKeychainPrefetch } from './utils/secureStorage/keychainPrefetch.js';
startKeychainPrefetch(); // 啟動 macOS 鑰匙圈讀取
// ... 接下來是 200 行的 import 語句(需要 ~135ms)...想像一下時間線:
時間 →
├── startMdmRawRead() 啟動子程序 ──────────────────► 完成
├── startKeychainPrefetch() 啟動子程序 ─────────────► 完成
├── 200 個 import 語句(~135ms)────────────────────►
│
└── 等到 import 完成時,子程序也差不多完成了!如果不這樣做,執行順序會是:
時間 →
├── 200 個 import 語句(~135ms)─────────────────────────►
├── 等 import 完成後,才啟動 MDM 讀取 ──────────────────────► 完成
├── MDM 完成後,才啟動鑰匙圈讀取 ──────────────────────────────► 完成
│
└── 總時間長了很多!省下了約 65ms(macOS 鑰匙圈讀取的時間)。聽起來不多,但對於 CLI 工具來說,每毫秒都重要。
策略四:記住結果,不要重複計算
Claude Code 使用 memoize(記憶化)來避免重複的計算:
typescript
// context.ts
export const getSystemContext = memoize(async () => {
// 執行 5 個 git 命令(getBranch, getDefaultBranch, status, log, user.name)
// 這可能需要幾百毫秒
const gitStatus = await getGitStatus()
return { gitStatus }
})memoize 的意思是「第一次呼叫時計算結果並記住它,之後的呼叫直接返回記住的結果,不再重新計算」。
為什麼 Git 狀態只在開始時取一次? 因為在對話過程中,AI 可能多次需要知道「我在哪個分支上」。如果每次都重新執行 git branch,在大型 repo 中可能要幾百毫秒。記住一次的結果就夠了。
策略五:原生模組用到才載入
Claude Code 包含 4 個用 C++ 寫的原生模組(圖片處理、音訊擷取等)。它們的載入方式:
typescript
let cachedModule = undefined // undefined 表示還沒嘗試過
function loadNativeModule() {
if (cachedModule !== undefined) return cachedModule // 已經嘗試過了
try {
cachedModule = require('./image-processor.node') // 嘗試載入
return cachedModule
} catch {
cachedModule = null // 載入失敗,記住失敗,不再嘗試
return null
}
}三個狀態的精妙設計:
cachedModule 的值 | 含義 |
|---|---|
undefined | 還沒嘗試載入過 |
| 一個物件 | 載入成功,這就是模組 |
null | 載入過但失敗了,不要再試了 |
為什麼「載入失敗也要記住」? 因為如果不記住失敗,每次用到圖片處理時都會嘗試載入 → 失敗 → 報錯。把 null 當成「我知道這台機器沒有這個模組」的信號,後續呼叫會跳過載入而選擇替代方案。
實戰思考
你的應用中有沒有「可選的重量級依賴」?比如 AI 模型、圖片處理庫、資料庫驅動。試試延遲載入它們——只在真正需要時才載入,載入失敗時提供替代方案(而不是崩潰)。
整體回顧:6 種策略速查表
| 策略 | 適用場景 | 節省效果 |
|---|---|---|
| 快速路徑 | 常用的簡單命令 | --version 從 2s 降到 <50ms |
| 死碼消除 | 環境特定的功能 | 檔案大小減少 ~30% |
| 平行預取 | 啟動時需要讀取外部資料 | 省去等待 I/O 的時間(~65ms) |
| 記憶化 | 計算結果在會話內不變 | 避免重複的幾百毫秒計算 |
| 延遲載入 | 可選的重量級模組 | 避免不必要的記憶體和啟動開銷 |
| 失敗快取 | 原生模組可能不存在 | 避免重複的失敗嘗試 |