不用 readline,用原生模块写一个更好的 Node.js 交互式 CLI
标签:Node.js / CLI / 终端编程 / 踩坑记录
这篇文章起源于一个真实的 Bug。我在写一个 TCP 聊天室的客户端时,发现 readline 在 Windows 终端下有严重的重复渲染问题。排查了一晚上之后,我决定弃用它,改用原生模块自己实现输入控制。这个过程比我想象的有趣得多。
一、Bug 现场:readline 在 Windows 终端下的表现
事情是这样的。聊天室客户端最初用 readline(逐行读取)模块做命令行交互,代码看起来很正常:
typescript
const readline = require('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
rl.question('请输入昵称: ', (name) => {
console.log(`你好, ${name}`);
rl.close();
});
在 Linux 下一切正常。但把客户端拿到 Windows 的 PowerShell 和 CMD 下测试,画面就开始崩坏:
- 输入字符时,光标位置乱跳
- 按退格键后,屏幕上残留的字符没有被正确清除
- 中文输入时,整行内容被重复打印一次甚至多次
- 快速打字时,提示符(Prompt)和输入内容重叠在一起
我当时的反应是:这不可能是我代码的问题,就这么几行。
更糟的是,这个 Bug 不是 100% 必现。它跟输入速度、字符宽度、终端窗口大小都有关系。你慢慢打字可能没事,一旦快起来,画面就开始分裂。这种不确定性让排查变得特别折磨人。
二、为什么会这样?
要理解这个问题的根因,需要知道 readline 是怎么工作的,以及 Windows 终端有什么特殊之处。
readline 模块并不是 Node.js 凭空造的。在 Unix 系统下,它底层依赖 GNU Readline(一个用 C 写的库),这个库跟终端的配合已经非常成熟。终端发送转义序列(Escape Sequence),Readline 解析这些序列,控制光标位置、清屏、重绘行内容。
但 Windows 没有 GNU Readline。Windows 的传统控制台(conhost.exe,CMD 和旧版 PowerShell 的宿主)用的完全是另一套 API。Node.js 的 readline 在 Windows 下走的是一套兼容层,试图用 Windows 控制台 API 模拟 Readline 的行为。
这里的矛盾在于:
- readline 假设终端支持标准的 VT(Virtual Terminal,虚拟终端)转义序列
- 旧版 Windows 控制台对 VT 序列的支持不完整
- Node.js 在两端之间做翻译,但翻译有漏洞,尤其在处理多字节字符(比如中文)和宽度计算时
中文为什么是重灾区?因为中文字符在终端里占两个列宽(Column),但 readline 的某些代码路径按字节数来算位置。一个 "你" 字占 2 个显示宽度,但可能是 3 个字节(UTF-8 编码)。当 readline 试图重绘一行时,它算错了光标该往哪移,结果把已经显示过的内容又输出了一遍。
这个过程就像你在一张纸上写字,旁边有个人负责帮你擦掉写错的部分。但他眼睛不好使,看不清楚你到底写了多宽的字,结果该擦的地方没擦干净,还把你写对的部分又抄了一遍。
三、替代方案有哪些?
我调研了三种可能的替代路线,这里列出来并附上我的判断:
- 方案 A:原生 process.stdin 逐字节读取,自己处理所有逻辑
- 方案 B:使用 node-pty(伪终端,Pseudoterminal)模块
- 方案 C:换一个更现代的第三方库,比如 enquirer 或 prompts
下面逐一说明。
方案 A 是本文最终采用的路线,也是改动量最大的。process.stdin 是 Node.js 的流(Stream:数据流)对象,代表标准输入。通过调用 process.stdin.setRawMode(true),可以让终端把每个按键都直接发给你的程序,而不是等用户按回车才给一整行。这意味着你需要自己实现字符回显、退格处理、光标移动、行缓冲------基本上就是重新发明 readline 的一个子集。听起来很吓人,但实际上核心的输入控制逻辑只有一两百行。
方案 B 的 node-pty 是一个更底层的方案。它创建一个伪终端,让子进程以为自己连接到了一个真实的 TTY(TeleTYpewriter,电传打字机,终端的 historical 名称来源)。node-pty 在跨平台方面做得很好,Windows 下用的是 WinPty 或 ConPTY(Windows Pseudo Console,Windows 伪控制台)。但它的体积比较大,而且引入了一个原生依赖(Native Dependency:需要编译的 C++ 扩展)。对于我的聊天室客户端来说,这有点杀鸡用牛刀了。
方案 C 是最省事的。enquirer 和 prompts 都是专门做交互式命令行的库,对各类终端的兼容性处理得很到位。但它们都是"黑盒",封装程度高,你要做定制(比如后面会提到的 IRC 式界面)时会很受限制。而且,既然我已经被 readline 坑过了,我对再包一层这件事有点抵触。
最后选了方案 A,主要是因为我想搞明白终端输入到底是怎么回事。另外一个现实考量是:我的聊天室客户端不需要 readline 的全部功能,只需要一个受控的输入行,加上一些自定义的渲染行为。
四、实战:用 setRawMode 实现轻量级输入控制器
这是文章的核心部分。我会从零开始写一个 Minimal(极简的)输入控制器,支持:
- 普通字符输入与回显
- 退格键删除字符
- 左右方向键移动光标
- Ctrl+C 退出
- 回车提交整行
4.1 setRawMode 是什么?
process.stdin.setRawMode(true) 是 Node.js 提供的一个方法,它把标准输入从" cooked mode(烹饪模式)"切换到" raw mode(原始模式)"。
在 cooked mode 下,操作系统内核帮你做了一件事:它维护一个行缓冲区。用户输入的字符先存在这个缓冲区里,直到按回车,整行才一次性送给你的程序。这就是为什么你平时写 process.stdin.on('data', ...) 时,回调里收到的是一整行字符串。
raw mode 把这个缓冲区的行为关掉了。每个按键(包括方向键、功能键)都会立即产生数据,你的程序可以实时响应。
javascript
process.stdin.setRawMode(true);
process.stdin.on('data', (chunk) => {
console.log('收到:', chunk, '长度:', chunk.length);
});
运行这段代码,按一个 'a',你会收到一个长度为 1 的 Buffer。按一下左方向键,你会收到一个长度为 3 的 Buffer:[0x1b, 0x5b, 0x44]。这是 ESC 序列 \x1b[D,表示"光标左移"。
方向键、退格键、删除键,所有这些特殊按键,在 raw mode 下都是通过 ESC 序列表达的。你的工作就是解析这些序列,然后决定屏幕上该发生什么。
4.2 输入控制器的完整实现
下面是一个可以直接运行的轻量级输入控制器。代码量不大,但覆盖了核心逻辑:
javascript
const { stdout, stdin, exit } = process;
class InputController {
constructor() {
this.buffer = ''; // 当前行缓冲区
this.cursor = 0; // 光标在 buffer 中的位置(字符索引)
this.prompt = '> '; // 提示符
stdin.setRawMode(true);
stdin.setEncoding('utf8');
stdin.on('data', this.onKey.bind(this));
stdin.resume();
this.render();
}
onKey(chunk) {
// Ctrl+C: 退出
if (chunk === '\x03') {
stdout.write('\n');
exit(0);
}
// 回车: 提交
if (chunk === '\r' || chunk === '\n') {
stdout.write('\n');
this.submit(this.buffer);
this.buffer = '';
this.cursor = 0;
this.render();
return;
}
// 退格键 (Backspace): \x7f
if (chunk === '\x7f') {
if (this.cursor > 0) {
this.buffer = this.buffer.slice(0, this.cursor - 1)
+ this.buffer.slice(this.cursor);
this.cursor--;
this.render();
}
return;
}
// 左方向键: \x1b[D
if (chunk === '\x1b[D') {
if (this.cursor > 0) {
this.cursor--;
this.render();
}
return;
}
// 右方向键: \x1b[C
if (chunk === '\x1b[C') {
if (this.cursor < this.buffer.length) {
this.cursor++;
this.render();
}
return;
}
// 普通可打印字符
if (chunk >= ' ' && chunk <= '~') {
this.buffer = this.buffer.slice(0, this.cursor)
+ chunk
+ this.buffer.slice(this.cursor);
this.cursor++;
this.render();
return;
}
// 其他按键忽略
}
render() {
// 清到行首: \r
// 清整行: \x1b[K
stdout.write('\r\x1b[K');
// 写出提示符 + 当前内容
stdout.write(this.prompt + this.buffer);
// 计算光标应该所在的列位置
// 简单处理:假设 prompt 和 buffer 中的字符都是等宽
const displayPos = this.prompt.length + this.displayWidth(
this.buffer.slice(0, this.cursor)
);
// 移动光标到正确位置
// 先回车,再向右移动 displayPos 格
stdout.write(`\r\x1b[${displayPos}C`);
}
displayWidth(str) {
// 简单版本:假设输入都是 ASCII
// 实际项目中需要处理中文宽字符
let width = 0;
for (const ch of str) {
width += (ch.charCodeAt(0) > 0x7f) ? 2 : 1;
}
return width;
}
submit(line) {
stdout.write(`你输入了: ${line}\n`);
}
}
new InputController();
4.3 代码解析:render 方法是最关键的部分
上面的代码里,render() 是整个控制器的核心。它负责把当前状态正确地画到屏幕上。
每次用户按一个键,onKey 更新内部状态(buffer 和 cursor),然后调用 render() 重绘整行。render() 的工作流程是:
- 用
\r\x1b[K清除从光标位置到行尾的内容 - 重新写出提示符和完整的
buffer - 把光标移到正确的列位置
这里用到了两个重要的 ESC 序列:
\x1b[K:EL(Erase in Line,擦除行),清除从当前光标位置到行尾的所有字符\x1b[nC:CUF(Cursor Forward,光标前移),把光标向右移动 n 列
为什么每次都重绘整行,而不是只更新变化的部分?因为只更新局部的话,你需要精确计算哪些字符被删了、哪些插入了,光标位置的变化也很复杂。重绘整行虽然看起来有点暴力,但逻辑简单,不容易出错。终端的刷新速度完全跟得上,你不会看到闪烁。
4.4 中文宽字符的处理
上面的代码里有一个 displayWidth 方法,它遍历字符串中的每个字符,判断字符编码是否大于 0x7f(127),如果是就认为这个字符占两个显示列宽。
这是一个简化版本。在真正的项目中,你可能需要引入 wcwidth 这样的库来做更准确的宽度计算。Unicode 里面有些字符占 0 宽(组合字符),有些占 1 宽,有些占 2 宽,手写判断会相当啰嗦。
javascript
// 生产环境可以用这个库
const wcwidth = require('wcwidth');
// 替换 displayWidth 方法
displayWidth(str) {
return wcwidth(str);
}
这个库遵循 Unicode East Asian Width(东亚宽度)标准,能正确处理中英文混排、emoji、组合字符等复杂情况。
说实话,readline 出问题的地方之一就是宽字符宽度算不对。我们自己实现时,把宽度计算独立成一个方法,出了问题也容易定位和修复,不需要去翻 Node.js 源码。
五、延伸到聊天室:IRC 式的「消息上滚 + 输入框固定」
解决了基础输入控制之后,下一步是做一个类 IRC(Internet Relay Chat,互联网中继聊天,一种古老的即时通讯协议)的界面:聊天记录从底部往上滚,最底下一行固定作为输入框。
这个需求用 readline 很难实现,因为 readline 接管了整个标准输出的行为,你自己控制不了光标在哪。但用我们刚才写的 InputController,这件事变得可行。
5.1 核心思路
终端就像一个固定大小的网格(通常是 80 列 × 24 行,但现代终端可以动态调整)。我们需要把屏幕分成两个区域:
- 上面的区域:显示聊天记录,新消息从底部加入,旧消息往上推
- 最底下一行:输入框,始终固定
实现这个效果的关键是控制光标位置。每次有新消息到达时:
- 把光标移到输入框的上一行末尾
- 打印新消息
- 把光标移回输入框的位置
- 重绘输入框的内容
javascript
class IRCRenderer {
constructor() {
this.messages = []; // 消息历史
this.controller = new InputController();
// 重写 controller 的 render,让它知道要保留最后一行
this.controller.render = this.renderInput.bind(this);
}
addMessage(text) {
// 保存输入框当前状态
const savedBuffer = this.controller.buffer;
const savedCursor = this.controller.cursor;
// 清掉输入框,给消息腾地方
stdout.write('\r\x1b[K');
// 输出新消息
stdout.write(text + '\n');
// 重新绘制输入框
this.controller.buffer = savedBuffer;
this.controller.cursor = savedCursor;
this.renderInput();
}
renderInput() {
// 移到最后一行
stdout.write('\x1b[999B'); // 光标下移到底
stdout.write('\r\x1b[K'); // 清行
stdout.write(this.controller.prompt + this.controller.buffer);
// 光标定位到正确位置
const pos = this.controller.prompt.length
+ this.controller.displayWidth(
this.controller.buffer.slice(0, this.controller.cursor)
);
stdout.write(`\r\x1b[${pos}C`);
}
}
5.2 为什么要保存和恢复输入框状态?
addMessage 方法里有一步看起来有点奇怪:在打印消息之前,保存了 buffer 和 cursor,打印完消息之后又恢复了它们。为什么?
因为打印消息的过程会改变终端上的内容。新消息输出到屏幕后,光标停在新消息的末尾(下一行开头)。如果不把输入框重绘出来,用户就看不到自己在打什么。而重绘输入框需要调用 renderInput,这个函数会读取 controller.buffer 来决定显示什么。所以必须在打印消息前把输入状态记下来,打印完后再恢复。
这个流程有点像你在纸上写字,突然有人要在你的纸上方添一行内容。你得先把自己写到一半的那行折起来保护好,等对方写完了,再把自己的那行展开继续写。
5.3 滚动控制
上面的代码有个问题:如果消息太多,屏幕会被填满,新消息会滚出可视区域。实际的 IRC 客户端需要处理滚动行为(比如按 Shift+PgUp 翻看历史)。
一个简单的方法是维护一个虚拟的消息缓冲区,只渲染当前可见区域的内容。当用户按下上方向键时,滚动偏移量(Scroll Offset)加一,然后重绘整个消息区域。
javascript
// 简化的滚动控制
scrollOffset = 0;
scrollUp() {
this.scrollOffset = Math.min(
this.scrollOffset + 1,
this.messages.length - this.visibleHeight
);
this.redrawMessages();
}
scrollDown() {
this.scrollOffset = Math.max(this.scrollOffset - 1, 0);
this.redrawMessages();
}
redrawMessages() {
// 清屏
stdout.write('\x1b[2J\x1b[H');
// 计算可见范围
const end = this.messages.length - this.scrollOffset;
const start = Math.max(0, end - this.visibleHeight);
// 重绘
for (let i = start; i < end; i++) {
stdout.write(this.messages[i] + '\n');
}
// 重绘输入框
this.renderInput();
}
\x1b[2J 是 ED(Erase in Display,擦除显示),清整个屏幕。\x1b[H 是把光标移到左上角(Home Position)。
完整的 IRC 界面实现还有很多细节要处理,比如终端窗口大小变化(SIGWINCH 信号)时的重绘、emoji 宽度计算、颜色代码的宽度处理等等。但核心的架构就是上面这样:维护两个独立的状态(消息列表和输入框),通过精确的光标控制来分别渲染。
六、收益与代价
自己实现输入控制,我得到了什么,又失去了什么?
得到的:
- 完全可控的渲染行为。readline 的底层逻辑你看不到,出了问题只能绕过去。自己写的代码,每一行都在你的掌控中。
- 跨平台一致性。不再依赖 readline 的兼容层,Windows 和 macOS 的行为可以做得一模一样。
- 可以定制任何交互模式。IRC 式界面、自动补全弹出框、内联提示------这些在 readline 下面很难做,但在 raw mode 下只是光标控制的问题。
失去的:
- readline 内置的高级功能没有了,比如历史记录(按上下键翻找之前输入过的命令)、Tab 补全、多行编辑。这些需要自己实现,或者引入专门的库。
- 代码量变多了。readline 几行就能搞定的事,现在可能需要上百行。
- 你需要了解终端 ESC 序列的知识。这个学习曲线是真实存在的。
我的建议是:如果你的 CLI 工具只是简单地问几个问题、读几行输入,readline 还是够用的,只要避开 Windows 下的重绘场景。但如果你要做任何非标准的终端交互(固定输入框、实时预览、富文本渲染),raw mode 几乎是必经之路。
七、写在最后
这个踩坑的过程花了我大约两个晚上。第一晚在折腾 readline,试图找到绕过 Bug 的办法,失败了。第二晚看 Node.js 查终端控制序列的文档,写出了上面这个 InputController。
回头看,readline 在 Windows 下的问题本质上是一个历史包袱。Node.js 为了跨平台兼容,在 Windows 上模拟了一套 Unix 终端的行为,但模拟总有缝隙。中文输入、快速打字、窗口缩放------这些边缘场景把缝隙撕开了。
自己实现输入控制这件事,一开始我觉得是"被迫的",但做完之后发现收获很大。终端不是一个黑盒,它是一套非常明确的协议。一旦你理解了 ESC 序列和光标控制,你就可以做任何你想做的界面。
聊天室客户端现在运行在 Windows PowerShell、CMD、Windows Terminal、macOS Terminal、iTerm2 下,表现完全一致。这个一致性来之不易,但值得。
如果你也在做 Node.js 的终端交互,遇到了类似的问题,希望这篇文章能帮你少走一些弯路。有什么想法或者更好的方案,欢迎在评论区交流。