Skip to content

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.Xfeature('X')
什麼時候判斷?程式執行程式打包
程式碼還在不在?在,只是不執行完全被刪除
打包後的檔案大小不變可能小很多

打包工具(Bun)在建置時把 feature('DAEMON') 替換成字面量 truefalse。如果替換成 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)
記憶化計算結果在會話內不變避免重複的幾百毫秒計算
延遲載入可選的重量級模組避免不必要的記憶體和啟動開銷
失敗快取原生模組可能不存在避免重複的失敗嘗試

Claude Code 架構設計深度分析