# Step 02: REPL 循环与用户输入
一句话导读:用 Node 的
readline搭一个最小 REPL——while(true)里await ask()等一行输入,非空就回显,exit就退出。几行代码,却是 Claude Code 整个交互体验的最底层骨架。
# 一、这一步做了什么(What)
step02/index.ts 做三件事:用 createInterface 连接 stdin/stdout;把回调式的 rl.question 包成返回 Promise 的 ask();在一个无限循环里反复「读输入 → 处理 → 回到读」。当前处理逻辑只是回显,但这个 Read-Eval-Print Loop 的形状,往后 50 步都不会变——只是把中间的「Eval」从回显换成「调用 Claude」。
# 二、面试官视角:为什么要把回调包成 Promise?(Why)
面试题:
rl.question(q, callback)本来就能用,为什么非要用new Promise包一层?
因为回调风格和 async/await 风格无法直接混用,而整个 Claude Code 是 async 的世界(API 调用、文件读写、工具执行全是 await)。如果输入层还停留在回调,主循环就得写成回调地狱:
// 不包装:回调嵌套,无法用 while 循环
rl.question('> ', input => {
callAI(input, reply => {
rl.question('> ', input2 => { /* 又一层... */ });
});
});
2
3
4
5
6
包装成 Promise 后,主循环变回线性可读的样子:
while (true) {
const input = await ask('> '); // 一行读输入
const reply = await callAI(input); // 一行调 AI
console.log(reply);
}
2
3
4
5
| 维度 | 裸回调 | 包成 Promise |
|---|---|---|
| 控制流 | 嵌套,越写越深 | 线性,while 里顺序读 |
| 错误处理 | 每层单独处理 | 统一 try/catch |
| 与 async 世界 | 割裂 | 无缝 await |
这个「回调转 Promise」是 Node 里最基础也最高频的适配动作。看透它,才理解为什么 REPL 能写成朴素的 while(true) { await ... }。
# 三、原理:readline 与 REPL 循环(How)
# ask() 的真实实现
const rl = createInterface({
input: process.stdin,
output: process.stdout,
terminal: true, // 启用终端模式
});
function ask(question: string): Promise<string> {
return new Promise(resolve => {
rl.question(question, answer => {
resolve(answer.trim()); // 回调触发时 resolve,await 才拿到值
});
});
}
2
3
4
5
6
7
8
9
10
11
12
13
resolve 被塞进了回调里——用户按下回车、回调触发的那一刻,Promise 才 fulfilled,外层 await ask() 才拿到这一行。Promise 的完成时机 = 用户敲回车的时机,这是把「事件」翻译成「值」的经典手法。
# readline 悄悄替你做的事
terminal: true 不只是显示文字,它让 Node 接管 tty:
- 行缓冲 —— 逐字符累积,回车才触发回调(而不是每敲一个键就触发)
- 终端控制 —— 光标移动、退格、历史上下翻的底层支持
- 信号处理 —— Ctrl+C / Ctrl+D 触发
close事件
代码里用 rl.on('close', ...) 接住了退出信号,打印「再见」后 process.exit(0),实现优雅退出。
# REPL 数据流
createInterface 连接 stdin/stdout
↓
while(true):
await ask('> 你说:') ←── 阻塞在这,等用户敲回车
↓ 拿到 input
input 为空? → continue(跳过)
input === 'exit'? → break(退出循环)
↓ 否则
处理(当前=回显;未来=调 Claude)
↓
回到 while 顶部
2
3
4
5
6
7
8
9
10
11
「空输入 continue、exit break」这两个守卫看似琐碎,却是所有 REPL 的标配——先处理边界,再处理主流程。
# 四、深入追问(面试常见 follow-up)
Q:while(true) 是死循环,为什么不会把 CPU 跑满 100%?
A:因为循环体第一件事就是 await ask(),而 ask() 在等 I/O(用户输入)。await 会把控制权交还给事件循环,当前任务挂起,进程进入空闲、等待 stdin 可读的事件。CPU 完全空转不了——它在睡觉,直到用户敲回车才被唤醒。这是「异步死循环」和「同步死循环」的本质区别。
Q:为什么要 answer.trim()?不 trim 会怎样?
A:终端输入常带首尾空格或意外的空白。不 trim 的话,input === 'exit' 会因为 'exit ' 带空格而判断失败,用户以为退出了却没退。trim() 是输入归一化——在业务判断前先把杂质去掉,是防御式编程的基本功。
Q:真实 Claude Code 的输入框比这复杂几十倍,这一步还有意义吗?
A:有。真实版的 PromptInput/ 有 21 个文件,支持多行输入、粘贴检测、语法高亮、/ 命令补全、历史搜索——但它们全是在这个 Promise 之上叠加的体验层。最底层的「读一行、返回一个值」永远是 readline.question 这个模式。抓住不变的内核,增量的复杂度才好理解。
# 五、设计权衡
terminal: true 换来了 tty 的便利,代价是这份代码只能跑在真实终端里——管道输入(echo hi | node index.ts)或 CI 环境下 tty 行为会退化。学习阶段假设「人坐在终端前」,这个假设成立;但真实版必须同时处理非交互输入(headless / 管道模式),这也是为什么它的输入层要膨胀到 21 个文件。
# 六、与真实源码的对照
| 我们的实现 | Claude Code 源码 |
|---|---|
createInterface + rl.question | components/PromptInput/(21 文件) |
ask() 回调转 Promise | hooks/useTextInput.ts |
while(true) REPL | entrypoints/cli.tsx 主循环 |
Ctrl+C → close 退出 | 真实版有确认弹窗、中断当前请求 |
# 七、一句话总结
Step 02 = 一个 Promise 化的 while(true):用 readline 读一行、把回调包成 ask() 让主循环能线性 await、用空输入和 exit 两个守卫收边。REPL 的形状定死了——后面所有步骤都是往这个 while 中间塞更聪明的「Eval」。
# 下一步
cd step03 && npm start —— 把这坨代码拆进四个文件,看看「模块化」到底解决了什么。