# Step 03: 模块化拆分
一句话导读:功能和 step02 一模一样,但把代码拆成了
index / types / utils/input / utils/display四个文件。这一步不加功能,只加「组织」——为后面 47 步能撑到上千行代码提前铺路。
# 一、这一步做了什么(What)
把 step02 单文件的 REPL 拆成四块:index.ts(主入口)、types.ts(Message 类型)、utils/input.ts(输入封装 + 惰性单例)、utils/display.ts(输出格式化)。功能零变化——还是读一行、回显、退出。变的是每个文件只干一件事,index.ts 靠 import 把它们拼起来。
# 二、面试官视角:功能没变,为什么要花一整步做重构?(Why)
面试题:既然行为完全一样,这次拆分创造了什么价值?「代码更整洁」算价值吗?
算,而且是可量化的价值。真实 Claude Code 有约 1400 个 .ts + 500 个 .tsx 文件,光 src/utils/ 下就 200+ 个模块。它凭什么能管理这种体量?不是靠什么黑科技,而是靠一条铁律:每个文件只做一件事。
如果不在 step03 就养成拆分习惯,会发生什么?代码会在 step10 之前膨胀到几百行的单文件,然后:
| 症状 | 单文件 | 模块化 |
|---|---|---|
| 找一个函数 | 全文搜索 | 按目录直达 |
| 复用输入逻辑 | 复制粘贴 | import { ask } |
| 单独测试显示层 | 做不到,全耦合 | 只 import display 来测 |
| 改动影响面 | 牵一发动全身 | 限定在一个文件 |
这一步的价值是在复杂度还小的时候就立好规矩——等到代码已经乱了再拆,成本高十倍。模块化是「预防性投资」,不是「事后清理」。
# 三、原理:这次拆分里的两个题眼(How)
# 题眼一:惰性单例(lazy singleton)
utils/input.ts 把 readline 实例藏在模块作用域里,用一个函数守卫它的创建:
let rl: ReturnType<typeof createInterface> | null = null;
function getReadline() {
if (!rl) { // 第一次调用才创建
rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
rl.on('close', () => { process.exit(0); });
}
return rl; // 后续调用复用同一个
}
export function ask(question: string): Promise<string> {
return new Promise(resolve =>
getReadline().question(question, a => resolve(a.trim()))
);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
为什么必须是单例?因为一个进程只能有一个 readline 绑定到 stdin。如果每次 ask() 都 createInterface,多个实例会抢同一个输入流,行为直接错乱。惰性(lazy)的部分是:不调用就不创建,零启动开销;用闭包 + 模块变量实现,比写一个 class 更轻。
# 题眼二:类型抽离防循环依赖
types.ts 单独存放 Message:
export interface Message {
role: 'user' | 'assistant' | 'system';
content: string;
}
2
3
4
为什么类型要独立成文件?因为 Message 会被对话历史、API 请求、UI 渲染多处引用。如果把它塞进某个业务模块(比如 input.ts),那所有需要 Message 的文件都得 import 那个业务模块——很快就会绕成 A→B→A 的循环依赖。类型放在没有任何业务依赖的叶子文件里,谁都能安全引用,环不成立。
# 依赖流向
types.ts (叶子,谁都不依赖,被大家依赖)
↑ ↑
input.ts display.ts (工具层,各自独立)
↑ ↑
index.ts (顶层,把工具拼起来)
2
3
4
5
依赖是单向向上收敛的——底层类型不知道上层存在,上层随意引用底层。这就是 step01 那张架构图在文件层面的落地。
# 四、深入追问(面试常见 follow-up)
Q:单例模式一定要用 class 吗?这里为什么用闭包 + 模块变量?
A:不一定。JS 的模块本身就是天然单例——一个模块无论被 import 多少次,模块级变量 let rl 只有一份。所以「模块变量 + 守卫函数」就是最轻量的单例,不需要 class、不需要 getInstance() 样板。Claude Code 里 API 客户端、配置加载器都用这个模式(后面 step06 的 getClient() 就是同款)。
Q:什么样的东西「适合」抽成模块,什么样的不适合? A:判断标准是变化的理由是否独立(单一职责原则)。输入逻辑因「终端交互方式」而变,显示逻辑因「输出格式」而变——两者变化的理由不同,就该分开。反之,如果两段代码总是一起改,硬拆开反而增加跳转成本。别为了拆而拆。
Q:import 路径里为什么写 .js 后缀,源文件明明是 .ts?
A:因为项目用的是 ESM 模块 + TypeScript。TS 规范要求 ESM 下 import 路径写编译后的目标扩展名(.js),即使源文件是 .ts——运行时 tsx 会正确解析。这是 ESM + TS 组合的一个常见坑,第一次见容易懵。
# 五、设计权衡
模块化不是免费的。拆得越细,文件间跳转越多,读一条完整逻辑要开好几个文件。step03 只拆四个文件是克制的——刚好覆盖「输入 / 输出 / 类型 / 编排」四个正交关注点,不多不少。过早拆到二十个文件同样是灾难。粒度的把握本身就是工程经验。
# 六、与真实源码的对照
| 我们的实现 | Claude Code 源码 |
|---|---|
utils/input.ts 单文件 | components/PromptInput/(21 文件) |
utils/display.ts | ink/ 渲染组件群 |
types.ts 一个 Message | src/types/ 全部共享类型 |
| 惰性单例 getReadline | API 客户端 / 配置加载器同款单例 |
# 七、一句话总结
Step 03 = 零功能变化,纯结构投资:用惰性单例锁住 readline 的唯一性、用独立 types.ts 掐断循环依赖、让依赖单向向上收敛。这一步买的不是当下的功能,是后面 47 步不被自己的代码压垮的能力。
# 下一步
cd step04 && npm start —— 在正式接 AI 之前,先把异步编程的几个核心模式练熟。
← REPL 循环与用户输入 异步编程基础 →