来源与授权
本文来自 Claude-Code-Source-Study 原文,固定导入版本为 31b6e07a82d4。Copyright (c) 2026 Yao,依据 MIT License 提供。
本站仅按“github-public-noncommercial-2026-07-11”用于匿名、免费、非商业学习,并调整章节编排、站内链接、重复标题与阅读标记;不代表原作者对本站的合作、认证或背书。如权利方要求删除,我们会立即下架;授权或删除请求请通过本站首页底部公开联系方式联系。
本章是《深入 Claude Code 源码》系列的开篇。我们将从技术栈选型、启动链路、模块依赖三个维度,建立对整个项目的全局认知。
为什么要从全景开始?
当你面对一个约 1835 个 TypeScript 源码文件(含 .tsx)、最大单文件 main.tsx 4683 行的大型项目时,最大的挑战不是读懂某个函数,而是不知道从哪里开始读。
Claude Code 是 Anthropic 公司开发的 AI 驱动命令行编程助手。它不是一个简单的 API wrapper —— 它是一个完整的 AI Agent 运行时,包含了从终端 UI 渲染、多 Agent 编排、工具系统、权限安全到 Prompt 工程的全技术栈。理解它的全貌,等于理解了一个生产级 AI 产品的完整架构。
本章将回答四个核心问题:
- 同一份源码是怎么对外露出的? —
entrypoints/下四种入口形态(CLI / SDK / MCP server / Sandbox runner)共用一份代码 - 为什么选择这些技术? — Bun + TypeScript + Ink + Commander.js 的技术栈选型逻辑
- 程序是怎么启动的? — 从
cli.tsx到 REPL 的启动链路 - 代码是怎么组织的? — 约 1835 个 TypeScript 文件的模块依赖全景
入口形态全景:同一份源码,四张脸
打开 entrypoints/ 目录,你会看到的不是一个 main.ts,而是六个并列的文件 / 目录:
entrypoints/
├── cli.tsx # 入口形态一:交互式 / 非交互式 CLI
├── sdk/ # 入口形态二:Agent SDK 的内部实现
│ ├── controlSchemas.ts
│ ├── coreSchemas.ts
│ └── coreTypes.ts
├── agentSdkTypes.ts # 入口形态二的公共表面(@npm 包导出)
├── mcp.ts # 入口形态三:把自己作为 MCP server 启动
├── sandboxTypes.ts # 入口形态四:Sandbox runner 的配置 schema
└── init.ts # 共享初始化模块(仅 main.tsx 的 preAction 真正调用)
这种"一份源码,多个入口"的结构是 Claude Code 区别于普通 CLI 的关键。它意味着同一份工具实现、同一套权限系统、同一套 prompt 工程,可以被四种完全不同的外部使用者调用。
1.1 入口形态一:CLI(cli.tsx)
cli.tsx 是用户在终端键入 claude 后真正执行的那个文件。但它本身并不是主程序 —— 它的角色更像门房:在加载重型模块之前,先看清来人是谁,能就地打发的就当场打发,省下后续几百毫秒的启动开销。
最极端的例子是 --version:
// entrypoints/cli.tsx:36-42
if (args.length === 1 && (args[0] === '--version' || args[0] === '-v' || args[0] === '-V')) {
// MACRO.VERSION is inlined at build time
console.log(`${MACRO.VERSION} (Claude Code)`)
return
}
这段代码只用了一个被编译期内联的常量,连字符串拼接都被 Bun bundler 折叠掉了 —— 也就是说,claude --version 的执行链路里除了 cli.tsx 本身,不加载任何其他模块。
沿着同样的思路,cli.tsx 一口气定义了十几条快速路径,覆盖 --dump-system-prompt、daemon、remote-control / bridge / sync、后台任务管理(ps / logs / attach / kill)、模板任务(new / list / reply)、environment-runner 等等。每一条都遵循同一个套路:参数命中 → 动态 import 只跟这条路径相关的少数模块 → 干完事就 return。
只有当所有快速路径都不匹配时,才会走到文件末尾那行真正昂贵的 await import('../main.js'),把全量 CLI 拉起来。
1.2 入口形态二:Agent SDK(agentSdkTypes.ts + sdk/)
如果你不是在终端里用 claude,而是在自己的 Node 或 Bun 程序里这样写:
import { query, createSdkMcpServer } from '@anthropic-ai/claude-agent-sdk'
那么真正映射到的入口就是 entrypoints/agentSdkTypes.ts。
这个文件有意思的地方在于:它导出的所有运行时函数,比如 query、tool、createSdkMcpServer、unstable_v2_createSession,实现体全部是 throw new Error('not implemented')。看起来像废代码,其实是故意的 —— 真正的实现在 SDK 打包发布时由 bundle 注入,源码仓库里只留下一份"声明骨架"。
它的角色其实是 公共 API 表面:把可以暴露给外部用户的类型、可序列化的消息结构、settings schema、tool 类型,统一从 sdk/ 子目录 re-export 出来。同文件内的 type re-export 在编译后不存在运行时实体,自然也不会 throw。
值得一提的是,agentSdkTypes.ts 里的 import 路径写的是 ./sdk/runtimeTypes.js、./sdk/controlTypes.js、./sdk/settingsTypes.generated.js、./sdk/toolTypes.js 这一组 .js 文件,但当前源码树里 sdk/ 目录下真正能看到的 .ts 源文件只有三个:controlSchemas.ts、coreSchemas.ts、coreTypes.ts。其余几个 .js 路径在受审源码树里查不到 .ts 原型 —— 这是"公共 API 门面"分工下没有纳入本仓库的部分,由发布时的 SDK bundle 补齐。
把"对外接口"和"内部实现"通过单文件门面隔离开,是 Claude Code 发布 SDK 时反复用到的一个手法。
1.3 入口形态三:MCP server(mcp.ts)
entrypoints/mcp.ts 解决的是另一个方向的问题 —— 不是"别人怎么调用 Claude Code",而是"Claude Code 怎么把自己暴露成一个标准 MCP server,让别的程序调用"。
入口函数 startMCPServer(cwd, debug, verbose) 拿到工作目录之后,就注册两个标准 MCP handler:
// entrypoints/mcp.ts:47-57
const server = new Server(
{ name: 'claude/tengu', version: MACRO.VERSION },
{ capabilities: { tools: {} } },
)
ListToolsRequestSchemahandler 把 Claude Code 内部那一整套工具(Bash、FileRead、FileWrite、Grep……)翻译成 MCP 协议里的Tool描述返回。CallToolRequestSchemahandler 把外部 MCP 客户端的调用路由到内部tool.call(),并复用同一套hasPermissionsToUseTool权限校验。
需要留意的是,MCP 入口构造出来的 ToolUseContext 是一个简化版:isNonInteractiveSession: true、mcpClients: []、agentDefinitions 为空。也就是说,它不参与多 Agent 编排,也不挂任何 MCP 子客户端 —— 它只做一件事:把自己的工具暴露给外部 MCP 客户端用。
1.4 入口形态四:Sandbox runner(sandboxTypes.ts)
第四种入口,乍看最不像入口 —— sandboxTypes.ts 整个文件只做一件事:导出几份 Zod schema,包括 SandboxNetworkConfigSchema、SandboxFilesystemConfigSchema、SandboxSettingsSchema,以及它们 infer 出来的 TypeScript 类型。
但它的确是一种入口形态。当 claude 以 sandbox 模式拉起子命令时,使用的就是这份 schema 校验过的配置 —— enabled、failIfUnavailable、autoAllowBashIfSandboxed、network、filesystem 这些字段会一路传到子进程,去限定它的网络和文件系统访问范围。换句话说,schema 即接口:宿主进程读这份 schema 就知道沙箱能开哪些口子,企业部署方写一份 settings JSON 就能把策略焊进 CLI。
文件内的注释还留下了一些有意思的演化痕迹。比如 enableWeakerNetworkIsolation 这个开关,注释明写 "macOS only: Allow access to com.apple.trustd.agent",目的是让 Go 工具链(gh / gcloud / terraform)在 MITM 代理 + 自签 CA 的场景下能完成 TLS 验证。又比如 autoAllowBashIfSandboxed 和 enabledPlatforms,是为了让 NVIDIA 这样的大客户能在 macOS 上启用沙箱、在 Linux / WSL 上先关掉。这些字段不是凭空设计出来的,而是企业部署一线打回来的需求被原样焊进了源码。
1.5 init.ts:不是第五种入口,是 CLI 的初始化模块
容易让人误会的是 entrypoints/init.ts 也躺在同一个目录下,但它不是第五种入口形态。它是一个普通的初始化模块,对外导出一个 init() 函数。
在当前源码树里,唯一真正 import { init } from './entrypoints/init.js' 并把它接入运行时的,是 main.tsx:在文件顶部 import,在 program.hook('preAction', ...) 里 await init()。也就是说,init() 只在 cli.tsx → main.tsx → Commander preAction 这一条链路上被显式触发。
其余三种入口 —— agentSdkTypes.ts、mcp.ts、sandboxTypes.ts —— 在本仓库源码里都没有 import init:
agentSdkTypes.ts的运行时实现由发布时的 SDK bundle 替换,初始化职责由 bundle 自己接管。mcp.ts由外部 MCP 客户端拉起startMCPServer,宿主自负初始化。sandboxTypes.ts只导出 schema,由读 schema 的宿主进程负责自己的初始化。
init() 函数本身用 lodash-es/memoize 包装,无论被 preAction 触发多少次都只执行一次。它做的事情大致按这样的顺序:
- 启用配置系统(
enableConfigs),并只把"安全的"环境变量先注入进来(applySafeConfigEnvironmentVariables),给后续的 trust dialog 留出安全缓冲。 - 提前注入
NODE_EXTRA_CA_CERTS,这一步必须早于第一次 TLS 握手,否则 Bun / BoringSSL 会缓存掉空的证书库。 - 注册优雅退出、惰性初始化 1P 事件日志、回填 OAuth 账户信息、探测 JetBrains IDE 和 GitHub 仓库环境。
- 异步加载远端 managed settings 与 policy limits,配置 mTLS 与代理。
- 对 Anthropic API 提前做一次 TCP + TLS 预连接,与 action handler 的工作并行,省下 100–200ms 的首请求延迟。
- 仅当
CLAUDE_CODE_REMOTE为 truthy 时,才懒加载upstreamproxy/,把 CCR 上游代理挂进去。
因为只有 CLI 主入口走 init(),所以本书后面把"配置即代码"单独拎出来讲的那一章,讨论范围也限定在这条链路上 —— migrations 的演化、企业 MDM 的合并顺序、applyConfigEnvironmentVariables 的两阶段(trust 前的安全子集 / trust 后的完整集),都在这一层发生,不向其余三种入口推广。
一、技术栈选型:为什么是 Bun + TypeScript + Ink?
1.1 运行时:深度依赖 Bun
Claude Code 的构建与部分运行时特性深度依赖 Bun。但需要注意,这并不意味着它是一个 Bun-only 项目 —— 源码中明确检测 Node 版本(setup.ts:69-79),并提供了 isRunningWithBun() 函数来区分运行态(utils/bundledMode.ts:1-22),说明项目同时考虑了非 Bun 环境的兼容。
核心上,选择 Bun 有明确的工程理由:
- 启动速度:Bun 的冷启动时间远低于 Node.js,对 CLI 工具至关重要。用户输入
claude到看到界面的时间直接影响体验。 - 内置 bundler:Bun 的
bun:bundle模块提供了编译期feature()函数,可以实现 Dead Code Elimination(DCE)。这让同一份代码可以构建出内部版和外部版两个不同的产品。 - TypeScript 原生支持:无需
ts-node或额外的转译步骤。
更准确地说,项目的构建/打包能力深度依赖 Bun,且部分运行路径针对 Bun 做了优化或假设,但运行态是被区分对待的。
一个关键的 Bun 特性在整个代码库中被大量使用:
// entrypoints/cli.tsx:1
import { feature } from 'bun:bundle';
// tools.ts:26-28
const SleepTool =
feature('PROACTIVE') || feature('KAIROS')
? require('./tools/SleepTool/SleepTool.js').SleepTool
: null
feature() 在编译时被替换为 true 或 false,配合 require()(而非 import),Bun 的 bundler 可以在编译期直接删除不需要的代码分支,实现真正的零成本抽象。
1.2 语言:TypeScript + Zod
TypeScript 提供静态类型安全。值得注意的是,项目大量使用 Zod 做运行时类型验证 —— 这是因为 AI 模型返回的工具调用参数是动态的,TypeScript 的编译期类型检查无法覆盖。
1.3 终端 UI:Ink(React for CLI)
Claude Code 的终端界面不是用 console.log 拼的,而是用 Ink —— 一个在终端中运行 React 的框架。项目甚至 fork 了 Ink 框架并做了大量深度定制(ink/ 目录约有 96 个文件):
ink/
├── reconciler.ts # 自定义 React reconciler
├── layout/ # 基于 Yoga 的布局引擎
├── render-node-to-output.ts # 渲染到终端
├── optimizer.ts # 渲染性能优化
├── line-width-cache.ts # 行宽缓存
└── ...(共约 96 个文件)
为什么用 React 做终端 UI?因为 Claude Code 的界面是高度动态的:流式输出、多工具并行进度条、权限确认对话框、主题切换……这些用命令式代码管理会变成噩梦,但用 React 的声明式模型就很自然。
1.4 CLI 参数解析:Commander.js
参数解析使用 @commander-js/extra-typings —— Commander.js 的类型增强版。在 main.tsx 中可以看到它被用来定义所有 CLI 选项和子命令。
技术栈总结
| 层级 | 技术选择 | 选择理由 |
|---|---|---|
| 运行时 | Bun(主要) | 快速启动 + 编译期 DCE(兼容 Node.js 运行态) |
| 语言 | TypeScript | 类型安全 + 大型项目可维护性 |
| 运行时验证 | Zod | AI 返回值的动态验证 |
| 终端 UI | Ink (forked) | React 声明式 UI 模型 |
| CLI 解析 | Commander.js | 成熟的参数解析方案 |
| 布局引擎 | Yoga | 终端中的 Flexbox |
二、启动链路:从 claude 到 REPL 的调用流程
当用户在终端输入 claude 并回车时,在交互式主路径上,程序大体经过以下几个阶段的初始化,最终启动交互式 REPL。每个阶段都有明确的职责分工:
注意:
init()并不是独立的启动层级。它定义在entrypoints/init.ts中,但实际是在main.tsx的 CommanderpreActionhook 内被调用的(main.tsx:907-916)。这意味着init()的执行时机由 Commander.js 的命令解析流程控制——只有当用户执行一个真正的命令时才会触发,显示帮助信息(--help)时不会执行。同样需要注意的是,
setup()并非所有命令都会经过的统一启动层。源码明确写到,有些子命令从不调用setup(),所以preAction中需要额外补上initSinks()来保证日志不丢失(main.tsx:926-930)。setup()真正发生在进入交互式 REPL 的默认 action 流程里(main.tsx:1903-1935)。因此,更准确地说,setup()是"进入交互式会话前的 session setup",而不是所有子命令共用的统一启动阶段。
2.1 第一层:cli.tsx — Bootstrap 入口
文件:entrypoints/cli.tsx(约 300 行)
这是程序的真正入口。它的核心设计原则是:尽可能少加载模块,尽可能快返回。
// entrypoints/cli.tsx:33-42
async function main(): Promise<void> {
const args = process.argv.slice(2);
// Fast-path for --version/-v: zero module loading needed
if (args.length === 1 && (args[0] === '--version' || args[0] === '-v' || args[0] === '-V')) {
console.log(`${MACRO.VERSION} (Claude Code)`);
return;
}
// ...
}
--version 路径实现了零 import 返回 —— 除了 cli.tsx 本身,不加载任何其他模块。MACRO.VERSION 是编译时内联的常量,连字符串拼接的成本都省了。
cli.tsx 定义了大量「快速路径」(fast-path),每个路径只动态 import 必需的模块。以下是主要的快速路径(完整列表还包括 --daemon-worker、--claude-in-chrome-mcp、environment-runner、self-hosted-runner、template jobs、tmux worktree 等,详见源码):
| 快速路径 | 触发条件 | 加载的模块 |
|---|---|---|
| --version | 单参数 -v/-V/--version | 无 |
| --dump-system-prompt | 首参数匹配 | config, model, prompts |
| daemon | 首参数 daemon | config, sinks, daemon/main |
| bridge/remote | 首参数 remote-control 等 | config, auth, bridge |
| ps/logs/attach/kill | 首参数匹配 | config, cli/bg |
| 正常启动 | 无匹配 | main.tsx(全量) |
只有当没有任何快速路径匹配时,才加载最重的 main.tsx:
// entrypoints/cli.tsx:291-298
const { startCapturingEarlyInput } = await import('../utils/earlyInput.js');
startCapturingEarlyInput();
profileCheckpoint('cli_before_main_import');
const { main: cliMain } = await import('../main.js');
profileCheckpoint('cli_after_main_import');
await cliMain();
注意这里用了 profileCheckpoint() 打点 —— 团队在持续监控 import main.js 的耗时,因为这一步会触发大量模块的求值。
2.2 第二层:main.tsx — 主应用编排器
文件:main.tsx(约 4683 行)
这是整个项目最大的单文件。它是整个 CLI 应用的编排中心,负责:
- 用 Commander.js 定义所有 CLI 参数和子命令(
config、doctor、mcp等) - 通过
preActionhook 编排初始化流程:init()→ 配置迁移 → 远程设置加载 - 认证流程(API Key / OAuth / AWS / GCP)
- 模型选择与验证
- 权限模式初始化
- MCP 服务器配置与连接
- 插件加载与 Agent 定义发现
- 创建 AppState 并启动 REPL(交互模式)或执行 print 模式(非交互模式)
main.tsx 的前 20 行展示了一个精妙的启动优化技巧 —— 侧效果前置:
// main.tsx:1-20
import { profileCheckpoint, profileReport } from './utils/startupProfiler.js';
profileCheckpoint('main_tsx_entry'); // 立即打点
import { startMdmRawRead } from './utils/settings/mdm/rawRead.js';
startMdmRawRead(); // 立即启动 MDM 子进程
import { ensureKeychainPrefetchCompleted, startKeychainPrefetch }
from './utils/secureStorage/keychainPrefetch.js';
startKeychainPrefetch(); // 立即启动 Keychain 预取
这些 import 语句之间穿插着函数调用 —— 在后续 135ms 的 import 求值期间,MDM 子进程和 Keychain 读取已经在并行执行了。这是一个巧妙的性能优化:利用 JavaScript 模块求值的阻塞时间来并行执行 I/O。
2.3 init() — 核心初始化(在 main.tsx 的 preAction 中调用)
文件:entrypoints/init.ts
init() 定义在单独的文件中,但通过 main.tsx 的 Commander preAction hook 调用(main.tsx:916)。它用 lodash 的 memoize 包装,确保无论被调用多少次都只执行一次:
// entrypoints/init.ts:57
export const init = memoize(async (): Promise<void> => {
// 配置验证
enableConfigs();
// 环境变量应用(安全的部分)
applySafeConfigEnvironmentVariables();
// CA 证书配置
applyExtraCACertsFromConfig();
// 优雅退出注册
setupGracefulShutdown();
// 遥测初始化
// 代理配置
// MTLS 配置
// Policy limits 加载
// ...
});
关键设计:init() 区分了「安全环境变量」和「完整环境变量」。在用户接受信任对话框(trust dialog)之前,只应用安全的环境变量。这是安全性考虑 —— 未确认信任的项目不应该能通过 .claude/settings.json 修改关键环境变量。
2.4 setup.ts — 交互式会话的 Session Setup
文件:setup.ts(约 477 行)
setup() 处理进入交互式 REPL 前的 session 级初始化。需要强调的是,它只在交互式会话路径上被调用,并非所有子命令(如 config、doctor)都会执行此步骤:
- 设置工作目录(CWD)
- Git Worktree 创建(如果启用
--worktree) - Hooks 配置快照
- 文件变更监听器初始化
- Session Memory 初始化
- Analytics 事件
tengu_started发送
// setup.ts:56-66
export async function setup(
cwd: string,
permissionMode: PermissionMode,
allowDangerouslySkipPermissions: boolean,
worktreeEnabled: boolean,
// ...
): Promise<void> {
setCwd(cwd);
captureHooksConfigSnapshot();
initializeFileChangedWatcher(cwd);
// ...
}
2.5 replLauncher.tsx — 启动 REPL
文件:replLauncher.tsx(约 22 行)
最终的 REPL 启动异常简洁 —— 它只做一件事:将 <App> 和 <REPL> 组件渲染到 Ink 的 React 树中:
// replLauncher.tsx:12-22
export async function launchRepl(
root: Root, appProps: AppWrapperProps,
replProps: REPLProps,
renderAndRun: (root: Root, element: React.ReactNode) => Promise<void>
): Promise<void> {
const { App } = await import('./components/App.js');
const { REPL } = await import('./screens/REPL.js');
await renderAndRun(root,
<App {...appProps}>
<REPL {...replProps} />
</App>
);
}
注意 App 和 REPL 都是动态 import 的 —— 延迟到最后一刻才加载这些重量级 UI 模块。
三、模块依赖全景:约 1835 个文件的组织方式
Claude Code 的源码按职责划分为 10+ 个顶层模块:
3.1 核心文件规模
| 文件 | 行数 | 职责 |
|---|---|---|
main.tsx | 4683 | 主编排器,CLI 参数定义,启动流程 |
query.ts | 1729 | 对话循环,API 调用,工具执行 |
Tool.ts | 792 | Tool 接口定义,buildTool() |
commands.ts | 754 | 命令聚合入口:内建命令 + 动态技能 + 插件 + workflow 命令 |
tools.ts | 389 | 工具注册表,getAllBaseTools() |
setup.ts | 477 | 会话初始化 |
cli.tsx | 303 | Bootstrap 入口 |
3.2 目录结构与职责
claude-code-cli/
├── entrypoints/ # 入口点 (cli.tsx, init.ts)
├── state/ # 状态管理 (store, AppState)
├── tools/ # 40+ 工具实现,每个一个目录
│ ├── AgentTool/ # Agent 子系统
│ ├── BashTool/ # Shell 命令执行
│ ├── FileEditTool/ # 文件编辑
│ ├── GlobTool/ # 文件搜索
│ └── ...
├── commands/ # 内建斜杠命令(通过 commands.ts 与技能/插件命令动态聚合)
├── services/ # 核心服务
│ ├── api/ # Anthropic API 客户端
│ ├── compact/ # 上下文压缩
│ ├── mcp/ # MCP 协议实现
│ └── analytics/ # 分析与 Feature Flags
├── components/ # 380+ UI 组件文件
│ └── design-system/ # 设计系统基础组件
├── ink/ # Fork 的 Ink 框架 (约 96 个文件)
├── utils/ # 工具函数
│ ├── permissions/ # 权限系统
│ ├── settings/ # 多层配置系统
│ └── hooks/ # Hook 系统
├── constants/ # 常量定义(System Prompt 的核心组装逻辑在 prompts.ts 及相关 section 系统中)
├── tasks/ # 任务系统 (并发 Agent)
├── coordinator/ # 多 Agent 协调
├── memdir/ # 自动记忆系统
├── skills/ # 技能框架
├── bridge/ # IDE 远程桥接
└── migrations/ # 配置迁移脚本
3.3 关键数据流
整个应用的核心数据流可以概括为一条主线:
用户输入 → query.ts 组装消息 → Anthropic API → 模型返回 tool_use
→ 查找并执行 Tool → 结果回传 API → 模型继续/结束
这条主线由 query.ts 驱动,它是 agent turn orchestration 的核心入口。每一次用户提问,都会触发这个循环,直到模型决定停止使用工具并给出最终回复。需要注意的是,随着项目演进,很多关键逻辑已经分散到 services/tools/toolOrchestration.js、services/api/*、services/compact/* 等模块中,query.ts 更准确地说是对话循环的编排入口,而非唯一的核心。
四、几个值得注意的架构决策
4.1 单文件 vs 模块化的取舍
main.tsx 有 4683 行,query.ts 有 1729 行 —— 这看起来违背了「小文件」原则。但这是有意为之的:
- main.tsx 是编排器,它不仅需要 import 几乎所有模块来完成初始化,更重要的是它承担了大量 CLI option parsing、feature gate 分支、auth/provider 策略选择、interactive/non-interactive 分流、resume/remote/assistant 等模式编排。它是一个"策略汇聚层",拆分只会把紧密耦合的编排逻辑分散到多个文件,并不会减少复杂度。
- query.ts 是对话循环,它的逻辑是高度内聚的。拆分会增加理解成本。
4.2 动态 import 的战略性使用
项目中有两种 import 模式:
// 静态 import:模块求值时立即加载
import { BashTool } from './tools/BashTool/BashTool.js'
// 动态 import:运行到此处时才加载
const { App } = await import('./components/App.js')
规律:核心逻辑用静态 import(确保类型检查),UI 和可选功能用动态 import(延迟加载)。
4.3 懒 require() 的两种用途
项目中大量使用懒 require() 模式,但它服务于两个不同的目的:
用途一:打破循环依赖
// tools.ts:63-66
const getTeamCreateTool = () =>
require('./tools/TeamCreateTool/TeamCreateTool.js')
.TeamCreateTool as typeof import('./tools/TeamCreateTool/TeamCreateTool.js').TeamCreateTool
用途二:配合 feature() 实现编译期 DCE
// main.tsx:74-81, tools.ts:25-53
const SleepTool =
feature('PROACTIVE') || feature('KAIROS')
? require('./tools/SleepTool/SleepTool.js').SleepTool
: null
这里用 require() 而非 import 是刻意为之:静态 import 会被模块系统无条件加载,即使在 if (false) 分支里也会被打包。而 require() 是运行时调用,配合编译期 feature() 常量折叠,Bun 的 bundler 可以在构建时直接删除整个 require() 调用及其依赖。
共同的模式特征:
- 用函数包装
require()—— 只在调用时才执行 - 用
as typeof import(...)保留类型信息 —— 不丢失类型安全 - 注释标注原因 ——
// Lazy require to break circular dependency或 feature gate 条件
五、可迁移的设计模式
模式 1:分层启动 + 快速路径
将 CLI 启动分为多层,每层只加载必需的模块。对于简单命令(如 --version),在最早的层级就返回,避免加载整个应用。
适用场景:任何 CLI 工具,特别是启动速度敏感的场景。
模式 2:侧效果前置并行化
利用 JavaScript 模块求值的阻塞时间,在 import 语句之间插入 I/O 操作的启动调用。这样在后续模块加载期间,I/O 已经在并行执行。
适用场景:任何需要在启动时执行 I/O(网络请求、文件读取、子进程)的应用。
模式 3:编译期 Feature Flag + 条件 require
用编译期常量(如 Bun 的 feature())配合条件 require() 实现零成本的功能门控。编译器可以在构建时完全删除不需要的代码分支。
适用场景:需要从同一份代码构建多个版本(如内部版/外部版、免费版/付费版)的项目。
下一章预告
第 2 章:启动链路与冷启动优化 — 毫秒级 CLI 启动的工程艺术
我们将深入 cli.tsx 和 main.tsx 的启动路径,揭示 Claude Code 团队如何将 CLI 启动时间优化到毫秒级别。
全部内容请关注 https://github.com/luyao618/Claude-Code-Source-Study (求一颗免费的小星星)