Node.js readline 全景指南:createInterface 配置、逐行读取与交互模式


Node.js readline 全景指南:createInterface 配置、逐行读取与交互模式

Node.js 的 readline 模块用于从可读流中逐行读取数据,也能为终端程序提供提示符、输入编辑、历史记录和自动补全等交互能力。它既可以处理 process.stdin,也可以读取文件、网络连接和子进程输出。

使用 readline 时,需要先区分两套接口:node:readline 提供事件和回调 API,node:readline/promisesquestion() 提供 Promise API。除此之外,rl.write() 不是普通的输出方法,rl.close() 关闭的也只是 readline 接口,而不是底层输入流。

本文使用现代 Node.js 的 ESM 语法,重点说明:

  • createInterface() 如何连接输入流与输出流;
  • ReadLineOptions 中常用配置的准确含义;
  • question()'line' 事件和异步迭代器的区别;
  • 交互式 CLI、文件和管道分别应该如何配置;
  • write()prompt()close() 容易混淆的行为。

一、readline 解决什么问题

Node.js 中的标准输入、文件和网络连接都可以表现为可读流。可读流提供的是连续数据块,而业务代码通常希望按行处理文本。

readline 位于这两者之间:

text 复制代码
Readable 输入流
      │
      │ 字节或字符串数据块
      ▼
readline.Interface
      │
      │ 识别 \n、\r\n 等换行边界
      ▼
一行一行的字符串

创建接口时,还可以提供一个可写流:

text 复制代码
Readable input ──► readline.Interface ──► Writable output
                        │
                        ├── question()
                        ├── 'line' 事件
                        ├── for await...of
                        └── prompt() / 终端输入编辑

其中:

  • input 决定从哪里读取;
  • output 决定提示符和终端编辑内容写到哪里;
  • terminal 决定是否启用终端交互行为;
  • Interface 负责识别行边界并提供上层 API。

readline 既能用于交互式 CLI,也能用于非交互式文本处理,但这两类场景的配置并不完全相同。

二、先选择正确的导入入口

Node.js 提供两套 readline API。

1. 回调与事件 API

node:readline 导入:

js 复制代码
import { createInterface } from 'node:readline';

适合使用:

  • rl.question(query, callback)
  • rl.on('line', callback)
  • rl.prompt()
  • for await...of

其中 question() 通过回调返回回答:

js 复制代码
import { createInterface } from 'node:readline';
import { stdin as input, stdout as output } from 'node:process';

const rl = createInterface({ input, output });

rl.question('请输入姓名:', (name) => {
  console.log(`你好,${name}`);
  rl.close();
});

2. Promise API

node:readline/promises 导入:

js 复制代码
import { createInterface } from 'node:readline/promises';

此时 question() 返回 Promise<string>

js 复制代码
import { createInterface } from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';

const rl = createInterface({ input, output });

try {
  const name = await rl.question('请输入姓名:');
  console.log(`你好,${name}`);
} finally {
  rl.close();
}

如果程序使用 async/await 实现连续问答,应优先选择 node:readline/promises

下面的写法不能得到回答:

js 复制代码
import { createInterface } from 'node:readline';

const rl = createInterface({
  input: process.stdin,
  output: process.stdout,
});

// 错误:传统 readline 的 question() 不返回回答 Promise
const answer = await rl.question('请输入内容:');

问题不在 await,而在于导入的是回调版本。

三、使用 createInterface 创建接口

createInterface() 接收一个配置对象并返回 readline.Interface

js 复制代码
const rl = createInterface({
  input: process.stdin,
  output: process.stdout,
});

常用配置可以概括为:

字段 作用 默认值或常用值
input 要监听的可读流 process.stdin、文件流、socket
output 提示符和终端内容的输出位置 交互程序常用 process.stdout
terminal 是否启用 TTY 交互行为 未指定时根据 output.isTTY 判断
crlfDelay \r 与后续 \n 合并为一个换行的最大间隔 默认 100 毫秒;文件常用 Infinity
prompt rl.prompt() 使用的默认提示符 '> '
history 初始输入历史 []
historySize 最多保留的历史记录数量 30
removeHistoryDuplicates 是否移除重复历史记录 false

下面分别解释这些配置。

四、input:从哪里读取数据

input 是 readline 监听的可读流,也是创建接口时最核心的配置。

1. 读取键盘输入

js 复制代码
const rl = createInterface({
  input: process.stdin,
  output: process.stdout,
});

在终端中运行时,process.stdin 通常连接到用户键盘。

process.stdin 不一定来自键盘。例如:

sh 复制代码
printf "first\nsecond\n" | node app.mjs

此时 process.stdin 来自管道。因此,process.stdin 只表示当前进程的标准输入,不能无条件等同于交互式键盘。

2. 读取文件

js 复制代码
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';

const input = createReadStream('./data.txt', {
  encoding: 'utf8',
});

const rl = createInterface({
  input,
  crlfDelay: Infinity,
});

文件流负责读取文件,readline 负责把文件内容拆成一行一行的字符串。

3. 读取网络连接

net.Socket 同时实现了可读流和可写流接口,因此也可以交给 readline:

js 复制代码
import { createInterface } from 'node:readline';

const rl = createInterface({
  input: socket,
  output: socket,
  terminal: false,
});

这适合处理以换行符分隔的简单文本协议。这里需要明确设置 terminal: false,因为网络 socket 不是供用户编辑命令的终端。

4. 读取子进程输出

js 复制代码
import { spawn } from 'node:child_process';
import { createInterface } from 'node:readline';

const child = spawn('node', ['worker.mjs'], {
  stdio: ['ignore', 'pipe', 'inherit'],
});

const rl = createInterface({
  input: child.stdout,
  terminal: false,
});

rl.on('line', (line) => {
  console.log('子进程输出:', line);
});

这类场景中,input 来自子进程的标准输出,同样不需要终端编辑能力。

五、output:提示符和终端编辑内容写到哪里

output 是 readline 使用的可写流:

js 复制代码
const rl = createInterface({
  input: process.stdin,
  output: process.stdout,
});

在交互式终端中,readline 会通过 output

  • 输出 question() 的问询文字;
  • 输出 prompt() 的默认提示符;
  • 回显用户输入;
  • 重绘当前编辑行;
  • 移动光标;
  • 展示历史输入和自动补全内容。

因此,output 不只是普通的日志输出位置,它还参与 readline 的终端编辑过程。

非交互场景可以省略 output

读取文件时通常不需要提示符:

js 复制代码
const rl = createInterface({
  input: fileStream,
  terminal: false,
});

Node.js 运行时可以接受 output: null,但 TypeScript 类型定义通常将 output 声明为可选的 Writable。在严格类型检查下,显式传入 null 可能无法通过类型检查。

因此,更稳妥的写法是直接省略:

js 复制代码
const rl = createInterface({
  input: fileStream,
  terminal: false,
});

如果没有配置 outputquestion() 仍可以等待输入,但问询文字不会显示。交互式程序通常不应该这样使用。

六、terminal:是否启用终端交互能力

terminal 决定 readline 是否把输入输出当作 TTY 终端处理。

js 复制代码
const rl = createInterface({
  input: process.stdin,
  output: process.stdout,
  terminal: true,
});

terminal: true

终端模式会启用:

  • 左右方向键移动光标;
  • 上下方向键浏览历史记录;
  • Backspace 和 Delete 编辑;
  • Ctrl+C、Ctrl+D 等终端按键处理;
  • Tab 自动补全;
  • 当前输入行的清除和重绘;
  • 必要的终端控制序列。

它适合用户直接操作的交互式 CLI。

terminal: false

非终端模式只把输入视为普通文本流,重点是识别换行并产生一行一行的字符串:

js 复制代码
const rl = createInterface({
  input: fileStream,
  terminal: false,
});

适合:

  • 文件;
  • 管道;
  • 输入重定向;
  • 子进程输出;
  • 网络文本协议;
  • 自动化测试。

默认值不是固定的 true

如果没有显式传入 terminal,Node.js 会根据 output.isTTY 判断:

js 复制代码
terminal = Boolean(output?.isTTY);

典型结果如下:

output 状态 推导出的 terminal
process.stdout 连接真实终端 true
process.stdout 被重定向到文件 false
普通文件输出流 false
没有提供 output false

判断依据主要是 output.isTTY,不是 input.isTTY

处理文件、管道和 socket 时,建议显式设置:

js 复制代码
terminal: false

不要对文件或网络输出流强制设置 terminal: true。终端模式可能向 output 写入光标移动和输入行重绘所需的控制序列,这些内容不应该出现在普通文件或网络协议中。

七、crlfDelay:正确处理 Windows 换行

常见文本换行符包括:

换行格式 字符 常见环境
LF \n Linux、现代 macOS
CRLF \r\n Windows
CR \r 部分旧式文本格式

流中的数据不是按"完整行"到达的,而是以数据块到达。一个 \r\n 可能被拆到两个数据块中:

text 复制代码
数据块 1:hello\r
数据块 2:\nworld\r\n

readline 收到 \r 后,会等待后续的 \n。如果 \ncrlfDelay 指定的时间内到达,二者会被视为同一个换行符。

js 复制代码
const rl = createInterface({
  input,
  crlfDelay: 100,
});

需要注意:

  • 默认值是 100 毫秒;
  • 小于 100 的有效值会按 100 处理;
  • 它不是读取一整行的超时时间;
  • 它只控制相邻 \r\n 的组合关系。

文件读取通常使用 Infinity

读取文件时通常使用:

js 复制代码
const rl = createInterface({
  input: fileStream,
  crlfDelay: Infinity,
});

Infinity 能避免 \r\n 因数据块边界或处理延迟而被识别成两个换行。这也是 Node.js 官方文件逐行读取示例中的常见配置。

八、prompt:设置默认提示符

promptrl.prompt() 使用的默认提示符:

js 复制代码
const rl = createInterface({
  input: process.stdin,
  output: process.stdout,
  prompt: 'command> ',
});

调用:

js 复制代码
rl.prompt();

终端显示:

text 复制代码
command>

默认提示符是 '> ',末尾包含一个空格。

创建接口后还可以修改提示符:

js 复制代码
rl.setPrompt('next> ');
rl.prompt();

读取当前提示符:

js 复制代码
console.log(rl.getPrompt());

prompt 配置只影响 rl.prompt()。它不会替换传给 rl.question() 的问询文字:

js 复制代码
const answer = await rl.question('请输入项目名称:');

这里显示的是"请输入项目名称:",不是默认的 prompt

九、history:提供初始输入历史

history 用于设置初始输入历史:

js 复制代码
const rl = createInterface({
  input: process.stdin,
  output: process.stdout,
  history: ['version', 'status', 'help'],
});

在终端模式中,用户可以使用上下方向键浏览这些记录。默认值是空数组:

js 复制代码
[]

该配置主要在 terminal: true 时有意义。对于文件、管道和 socket,通常不需要历史记录。

运行期间可以通过 rl.history 查看当前记录:

js 复制代码
console.log(rl.history);

新提交的输入通常会出现在数组前部。

historySize

historySize 限制最多保留多少条历史记录:

js 复制代码
const rl = createInterface({
  input: process.stdin,
  output: process.stdout,
  historySize: 100,
});

默认值是 30。设置为 0 可以禁用历史记录:

js 复制代码
historySize: 0

removeHistoryDuplicates

如果不希望历史记录中重复保存同一条命令,可以设置:

js 复制代码
const rl = createInterface({
  input: process.stdin,
  output: process.stdout,
  removeHistoryDuplicates: true,
});

默认值是 false

这三个配置的职责分别是:

配置 职责
history 提供接口创建时的初始历史
historySize 限制历史记录数量
removeHistoryDuplicates 控制是否移除重复项

十、rl.question():读取一次回答

question() 适合"一问一答"流程:

text 复制代码
显示问题
→ 等待用户输入一行
→ 把该行作为回答

1. Promise 版本

node:readline/promises 导入:

js 复制代码
import { createInterface } from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';

const rl = createInterface({ input, output });

try {
  const projectName = await rl.question('项目名称:');
  const port = await rl.question('监听端口:');

  console.log({
    projectName,
    port: Number(port),
  });
} finally {
  rl.close();
}

question() 的返回类型是 Promise<string>。返回的字符串不包含用户按下回车时产生的换行符。

question() 不会自动关闭接口。处理完成后应调用 rl.close(),并推荐使用 finally 保证异常情况下也能清理资源。

2. 回调版本

node:readline 导入:

js 复制代码
import { createInterface } from 'node:readline';
import { stdin as input, stdout as output } from 'node:process';

const rl = createInterface({ input, output });

rl.question('项目名称:', (projectName) => {
  console.log(`准备创建项目:${projectName}`);
  rl.close();
});

传统版本的返回值是 undefined,回答通过回调参数传入。

3. 使用 AbortSignal 取消等待

Promise 版本可以通过 AbortSignal 取消等待:

js 复制代码
import { createInterface } from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';

const rl = createInterface({ input, output });
const controller = new AbortController();

const timer = setTimeout(() => {
  controller.abort();
}, 10_000);

try {
  const answer = await rl.question('请在 10 秒内确认(y/n):', {
    signal: controller.signal,
  });

  console.log(`输入结果:${answer}`);
} catch (error) {
  if (error.name === 'AbortError') {
    console.log('等待输入超时');
  } else {
    throw error;
  }
} finally {
  clearTimeout(timer);
  rl.close();
}

取消等待会让 Promise 版本的 question() 拒绝,并产生 AbortError。它不会自动替程序决定是否继续使用或关闭整个 readline 接口。

4. 不要并发调用多个 question

下面的代码同时发起两个问题,但同一个终端无法清晰表达两个并发等待状态:

js 复制代码
const [name, port] = await Promise.all([
  rl.question('项目名称:'),
  rl.question('监听端口:'),
]);

交互式问答应按顺序执行:

js 复制代码
const name = await rl.question('项目名称:');
const port = await rl.question('监听端口:');

十一、rl.write():向 readline 注入输入

rl.write(data[, key]) 容易被误解为普通输出方法。它不是下面方法的替代品:

js 复制代码
process.stdout.write(data);

rl.write() 会把字符串或按键交给 readline 处理,相当于以编程方式向当前输入行注入内容。

js 复制代码
rl.prompt();
rl.write('status');

此时当前编辑行会变成:

text 复制代码
> status

status 还没有提交。可以继续注入一个回车按键:

js 复制代码
rl.write(null, {
  name: 'return',
});

如果传入了 key,readline 按按键对象处理,data 不再是主要输入内容。常用按键字段包括:

ts 复制代码
{
  name?: string;
  ctrl?: boolean;
  meta?: boolean;
  shift?: boolean;
}

例如模拟 Ctrl+U:

js 复制代码
rl.write(null, {
  ctrl: true,
  name: 'u',
});

具体按键行为取决于终端模式和目标终端的能力。

非终端模式下的 write

terminal: false 时,rl.write() 会把内容交给普通行解析逻辑:

js 复制代码
import { createInterface } from 'node:readline';
import { PassThrough } from 'node:stream';

const input = new PassThrough();

const rl = createInterface({
  input,
  terminal: false,
});

rl.on('line', (line) => {
  console.log('读取到:', line);
});

rl.write('first\nsecond\n');

会依次处理:

text 复制代码
first
second

只想打印文字时应该怎么做

如果只是向终端输出消息,应使用:

js 复制代码
process.stdout.write('正在处理......');

也可以使用当前接口关联的输出流:

js 复制代码
rl.output?.write('正在处理......');

这类输出不会被当作用户输入交给 readline 解析。

十二、rl.prompt():显示或重绘默认提示符

rl.prompt() 会输出或重绘通过 prompt 配置的默认提示符:

js 复制代码
const rl = createInterface({
  input: process.stdin,
  output: process.stdout,
  prompt: 'command> ',
});

rl.prompt();

如果 readline 当前处于暂停状态,prompt() 还会恢复输入。

需要注意,prompt()

  • 不会等待并返回一行输入;
  • 不返回用户输入字符串;
  • 不等同于 question()
  • 通常与 'line' 事件组合使用。

典型流程如下:

js 复制代码
rl.prompt();

rl.on('line', (line) => {
  handleCommand(line);
  rl.prompt();
});

每处理完一条命令,再次显示提示符。

十三、rl.close():关闭接口而不是销毁底层流

调用:

js 复制代码
rl.close();

会关闭 readline.Interface,停止 readline 继续控制输入输出,并触发 'close' 事件。

它主要会:

  • 暂停输入;
  • 停止 readline 继续解析数据;
  • 在终端模式下恢复相关终端状态;
  • 释放 readline 对输入输出流的控制;
  • 触发 'close' 事件。

但它不会自动:

  • 调用 input.destroy()
  • 销毁网络 socket;
  • 关闭文件描述符;
  • 调用 output.end()
  • 永久关闭 process.stdin

因此,下面两种操作的职责不同:

js 复制代码
rl.close();       // 关闭 readline 接口
socket.destroy(); // 销毁网络连接

如果读取文件时需要提前终止,可以同时关闭接口和文件流:

js 复制代码
rl.close();
fileStream.destroy();

即使使用 node:readline/promisesrl.close() 仍然是同步方法,不需要写成 await rl.close()

监听 close 事件

js 复制代码
rl.on('close', () => {
  console.log('readline 接口已关闭');
});

'close' 事件表示 readline 接口生命周期结束,不一定表示底层输入流已经被销毁。

十四、使用 line 事件持续读取

'line' 事件适合持续处理输入:

js 复制代码
rl.on('line', (line) => {
  console.log(line);
});

每识别到一个换行边界,readline 就会触发一次回调。传入的 line

  • 是字符串;
  • 不包含结尾的 \n
  • 不包含结尾的 \r\n
  • 空行对应空字符串;
  • 输入结束时,最后一行即使没有换行符,也会作为一行被处理。

例如输入:

text 复制代码
alpha
beta

gamma

回调依次收到:

js 复制代码
'alpha'
'beta'
''
'gamma'

逐行读取文件

js 复制代码
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';

const input = createReadStream('./data.txt', {
  encoding: 'utf8',
});

const rl = createInterface({
  input,
  crlfDelay: Infinity,
  terminal: false,
});

let lineNumber = 0;

rl.on('line', (line) => {
  lineNumber += 1;
  console.log(`${lineNumber}: ${line}`);
});

rl.on('close', () => {
  console.log(`读取完成,共 ${lineNumber} 行`);
});

rl.on('error', (error) => {
  console.error('读取失败:', error);
});

当文件到达 EOF 时,readline 会结束读取并关闭接口。

line 事件适合什么场景

事件模式适合:

  • 持续监听标准输入;
  • 实现 REPL 或命令处理器;
  • 逐行处理文件;
  • 逐行消费子进程输出;
  • 需要同时监听 'close''pause' 等事件;
  • 对逐行处理开销比较敏感的场景。

如果每一行都需要等待异步任务完成,异步迭代器通常更容易控制执行顺序。

十五、使用异步迭代器逐行读取

readline.Interface 实现了异步迭代协议,因此可以直接使用:

js 复制代码
for await (const line of rl) {
  console.log(line);
}

1. 使用 for await...of

js 复制代码
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';

const input = createReadStream('./data.txt', {
  encoding: 'utf8',
});

const rl = createInterface({
  input,
  crlfDelay: Infinity,
  terminal: false,
});

try {
  for await (const line of rl) {
    console.log('当前行:', line);
  }
} finally {
  rl.close();
}

每次循环得到一个不包含行尾换行符的字符串。

2. 顺序执行异步任务

异步迭代器适合每行都需要等待异步处理完成的场景:

js 复制代码
for await (const line of rl) {
  await saveToDatabase(line);
}

执行顺序是:

text 复制代码
取得一行
→ 等待该行处理完成
→ 进入下一次循环

这比在 'line' 回调中直接使用异步函数更容易表达顺序关系。

下面的事件写法并不会等待上一行处理完:

js 复制代码
rl.on('line', async (line) => {
  await saveToDatabase(line);
});

如果输入到达速度快于数据库写入速度,多次回调可能同时处于等待状态。是否允许这种并发,应该由业务需求决定,不能仅凭语法判断。

3. 手动调用 next()

也可以显式取得异步迭代器:

js 复制代码
const iterator = rl[Symbol.asyncIterator]();

逐次获取一行:

js 复制代码
const first = await iterator.next();

if (!first.done) {
  console.log('第一行:', first.value);
}

next() 返回 Promise<IteratorResult<string>>。读取到一行时:

js 复制代码
{
  value: '当前行内容',
  done: false,
}

输入结束时:

js 复制代码
{
  value: undefined,
  done: true,
}

手动调用 next() 适合只读取固定数量行,或者需要由其他状态控制读取时机的场景。

4. 提前退出时显式关闭接口

处理 process.stdin 时,如果循环提前 break,应使用 finally 关闭接口:

js 复制代码
const rl = createInterface({
  input: process.stdin,
});

try {
  for await (const line of rl) {
    if (line.trim() === 'exit') {
      break;
    }

    console.log(`收到:${line}`);
  }
} finally {
  rl.close();
}

这能保证 readline 不再占用标准输入,并清理终端状态和监听关系。

5. 创建接口后应及时开始消费

createInterface() 创建接口后,readline 就会开始监听并恢复输入流。不要在创建接口和开始异步迭代之间执行耗时异步操作:

js 复制代码
const rl = createInterface({
  input: fastInputStream,
});

// 不推荐:输入流可能已经开始产生数据
await initializeApplication();

for await (const line of rl) {
  // 处理输入
}

更稳妥的顺序是先完成初始化,再创建接口:

js 复制代码
await initializeApplication();

const rl = createInterface({
  input: fastInputStream,
});

for await (const line of rl) {
  // 处理输入
}

十六、三种输入消费模式如何选择

readline 主要有三种消费方式:

模式 获取输入的方式 适合场景
question() 获取一次回答 安装向导、确认流程、固定步骤问答
'line' 事件 每行触发回调 REPL、持续命令输入、高频逐行处理
for await...of 异步顺序迭代 文件处理、每行需要等待异步任务

固定数量的问题:使用 question

js 复制代码
const name = await rl.question('姓名:');
const email = await rl.question('邮箱:');

它的优势是控制流直观,每一步都能等待上一项输入完成。

持续输入命令:使用 line 事件

js 复制代码
rl.on('line', (line) => {
  executeCommand(line);
  rl.prompt();
});

它更符合事件驱动的 CLI 和 REPL 模型。

每行都需要异步处理:使用 for await

js 复制代码
for await (const line of rl) {
  await processLine(line);
}

它能够直接表达顺序等待。

不要随意混用消费模式

同一个 readline 接口最好确定一种主要的输入消费方式。

例如,question() 正在等待时,下一行输入会被它消费为回答,而不是按普通方式交给其他 'line' 监听逻辑。再叠加异步迭代器后,输入由谁消费会变得更难判断。

可以在不同生命周期阶段切换设计,但应明确状态边界,不应让多套消费者同时等待同一输入流。

十七、完整示例:实现一个交互式 CLI

下面使用 'line' 事件实现一个简单的命令行:

js 复制代码
import { createInterface } from 'node:readline';
import { stdin as input, stdout as output } from 'node:process';

const rl = createInterface({
  input,
  output,
  prompt: 'node-cli> ',
  historySize: 50,
  removeHistoryDuplicates: true,
});

function printHelp() {
  console.log(`
可用命令:
  help       显示帮助
  version    显示版本
  exit       退出程序
`.trim());
}

rl.prompt();

rl.on('line', (line) => {
  const command = line.trim();

  switch (command) {
    case '':
      break;

    case 'help':
      printHelp();
      break;

    case 'version':
      console.log(process.version);
      break;

    case 'exit':
      rl.close();
      return;

    default:
      console.log(`未知命令:${command}`);
  }

  rl.prompt();
});

rl.on('close', () => {
  console.log('命令行已退出');
});

执行效果中的 Node.js 版本取决于本地环境:

text 复制代码
node-cli> help
可用命令:
  help       显示帮助
  version    显示版本
  exit       退出程序
node-cli> version
v20.x.x
node-cli> exit
命令行已退出

这个示例中各部分职责明确:

  • prompt 定义默认提示符;
  • rl.prompt() 显示提示符;
  • 'line' 事件接收提交后的命令;
  • 每次命令处理完成后重新调用 prompt()
  • exit 调用 rl.close() 结束接口;
  • 'close' 事件执行退出提示。

十八、完整示例:逐行统计文件内容

下面使用异步迭代器统计文件中的总行数、空行数和非空白字符数:

js 复制代码
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';

const input = createReadStream('./data.txt', {
  encoding: 'utf8',
});

const rl = createInterface({
  input,
  crlfDelay: Infinity,
  terminal: false,
});

let totalLines = 0;
let emptyLines = 0;
let nonWhitespaceCharacters = 0;

try {
  for await (const line of rl) {
    totalLines += 1;

    if (line.trim() === '') {
      emptyLines += 1;
    }

    nonWhitespaceCharacters += line.replaceAll(/\s/g, '').length;
  }

  console.log({
    totalLines,
    emptyLines,
    nonWhitespaceCharacters,
  });
} catch (error) {
  console.error('文件处理失败:', error);
  process.exitCode = 1;
} finally {
  rl.close();
}

这个场景不需要:

  • output
  • 提示符;
  • 历史记录;
  • 终端编辑能力。

因此配置中只保留输入流、换行处理和明确的非终端模式。

十九、常见误区

1. 认为所有 question 都返回 Promise

错误写法:

js 复制代码
import { createInterface } from 'node:readline';

const answer = await rl.question('请输入:');

正确做法是从 Promise 模块导入:

js 复制代码
import { createInterface } from 'node:readline/promises';

2. 使用 rl.write 打印普通消息

错误理解:

js 复制代码
rl.write('处理完成');

这会把内容交给 readline,当作输入内容处理。普通输出应使用:

js 复制代码
process.stdout.write('处理完成');

3. 认为 close 会销毁 input

js 复制代码
rl.close();

这只会关闭 readline 接口。需要销毁 socket 或提前终止文件流时,应单独操作底层流。

4. 把 terminal 默认值写成 true

terminal 未指定时会根据 output.isTTY 判断。输出重定向、管道和文件环境下,结果可能是 false

5. 对文件流启用终端模式

js 复制代码
const rl = createInterface({
  input: fileStream,
  output: anotherFileStream,
  terminal: true,
});

这可能把终端控制行为引入普通文件处理。文件和管道通常应设置:

js 复制代码
terminal: false

6. 忘记关闭交互式接口

使用 process.stdin 的程序如果一直保留 readline 接口,Node.js 进程可能继续等待输入。

Promise 问答推荐使用:

js 复制代码
try {
  // 读取输入
} finally {
  rl.close();
}

7. 在同一接口上同时注册多个消费者

同时使用 question()'line' 事件和异步迭代器,会让下一行输入的归属难以判断。应为一个接口选择一种主要消费模式。

二十、API 速查表

createInterface 配置

字段 准确含义 推荐用法
input 被监听的可读流 必需
output 提示符和终端编辑输出流 交互式 CLI 使用 process.stdout
terminal 是否启用 TTY 行编辑 CLI 自动判断;文件和管道显式设为 false
crlfDelay 合并 \r 与后续 \n 的时间范围 文件使用 Infinity
prompt rl.prompt() 使用的提示符 默认 '> '
history 初始历史记录 仅终端模式需要
historySize 最大历史记录数 默认 30
removeHistoryDuplicates 是否移除重复历史 默认 false

Interface 常用 API

API 作用
rl.question(query, callback) 回调方式读取一次回答
await rl.question(query) Promise 方式读取一次回答
rl.write(data[, key]) 向 readline 注入文本或按键输入
rl.prompt() 输出或重绘默认提示符
rl.setPrompt(value) 修改默认提示符
rl.getPrompt() 获取当前默认提示符
rl.close() 关闭 readline 接口
rl.on('line', callback) 每读取一行触发一次
rl.on('close', callback) 接口关闭时触发
rl[Symbol.asyncIterator]() 获取逐行读取的异步迭代器

总结

readline 的基础模型由输入流、Interface 和可选的输出流组成。input 提供连续数据,Interface 负责识别行边界,output 则为交互式终端提供提示符、回显和输入行重绘。

交互式 CLI 通常使用 process.stdinprocess.stdout 和终端模式;文件、管道、socket 和子进程输出通常使用 terminal: false,读取文件时可以设置 crlfDelay: Infinity

选择上层 API 时,可以依据输入流程判断:

  • 固定步骤的一问一答使用 Promise 版 question()
  • 持续命令输入使用 'line' 事件;
  • 每行需要顺序等待异步处理时使用 for await...of

最后需要区分两个容易混淆的行为:rl.write() 会向 readline 注入输入,而不是简单打印文本;rl.close() 只关闭 readline 接口,不负责销毁底层文件流、标准输入或网络连接。

参考资料

相关推荐
To_OC3 小时前
拼路径读文件总踩坑?我把 Node 的 path 和 fs 彻彻底底捋了一遍
javascript·后端·node.js
星栈15 小时前
从装一堆工具到看懂 Node 工程化思维:我的项目复盘记录
后端·node.js
东方小月17 小时前
从零开发一个Coding Agent:monorepo项目搭建
前端·后端·node.js
Lihua奏18 小时前
# 用 Node.js 连接数据库:一次请求是怎么跑起来的?
node.js
kisshyshy21 小时前
给端侧大模型装上“发动机”:React 合成事件 + 进度条组件全解
前端·react.js·node.js
csdn2015_1 天前
nodejs安装
node.js·vue
前端双越老师2 天前
如何以前端视角(非0基础)学 Java ?
java·node.js·全栈
Kel2 天前
Node.js 没那么复杂
人工智能·node.js·全栈
星栈独行2 天前
Node 接口该写同步还是异步?
服务器·开发语言·后端·程序人生·node.js