不用 readline,用原生模块写一个更好的 Node.js 交互式 CLI

不用 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 更新内部状态(buffercursor),然后调用 render() 重绘整行。render() 的工作流程是:

  1. \r\x1b[K 清除从光标位置到行尾的内容
  2. 重新写出提示符和完整的 buffer
  3. 把光标移到正确的列位置

这里用到了两个重要的 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 行,但现代终端可以动态调整)。我们需要把屏幕分成两个区域:

  • 上面的区域:显示聊天记录,新消息从底部加入,旧消息往上推
  • 最底下一行:输入框,始终固定

实现这个效果的关键是控制光标位置。每次有新消息到达时:

  1. 把光标移到输入框的上一行末尾
  2. 打印新消息
  3. 把光标移回输入框的位置
  4. 重绘输入框的内容
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 方法里有一步看起来有点奇怪:在打印消息之前,保存了 buffercursor,打印完消息之后又恢复了它们。为什么?

因为打印消息的过程会改变终端上的内容。新消息输出到屏幕后,光标停在新消息的末尾(下一行开头)。如果不把输入框重绘出来,用户就看不到自己在打什么。而重绘输入框需要调用 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 的终端交互,遇到了类似的问题,希望这篇文章能帮你少走一些弯路。有什么想法或者更好的方案,欢迎在评论区交流。