首页 / Node.js 教程 / readline 与命令行交互

Node.js 教程

readline 与命令行交互

本教程共 76 篇 · 第 22 篇 · 更新于 2026-07-25 · 约 5 分钟阅读

Node.jsreadlineCLI命令行交互

22. readline 与命令行交互

本节目标:readline 逐行读取、命令行交互和构建简单 CLI 工具。

命令行工具是 Node.js 的强项之一。无论是脚手架工具、DevOps 脚本还是交互式问卷,你都需要从终端读取用户输入、逐行处理日志文件。readline 模块就是干这个的。自 v17.0.0 起,它还提供了基于 Promise 的接口,写起来更顺手。

22.1 逐行读取文件

日志分析是最常见的场景。一个 10GB 的日志文件,用 readFile 直接读会炸内存,用 readline 逐行处理就很稳:

import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';

const fileStream = createReadStream('access.log', 'utf8');
const rl = createInterface({
  input: fileStream,
  crlfDelay: Infinity  // 兼容 Windows 的 \r\n
});

let errorCount = 0;

for await (const line of rl) {
  if (line.includes('ERROR')) {
    errorCount++;
  }
}

console.log(`共发现 ${errorCount} 条错误日志`);

for await...of 在这里同样适用,readline.Interface 是个异步可迭代对象。

22.2 交互式提问

v17 引入的 readline/promises 让写问答脚本变得很像现代代码:

import { createInterface } from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';

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

const name = await rl.question('项目名称: ');
const port = await rl.question('监听端口 [3000]: ');

console.log(`即将创建 ${name},运行在 ${port || 3000} 端口`);
rl.close();

question() 返回 Promise,等待用户输入并按下回车。注意最后要手动 rl.close(),否则进程不会退出。

老版本的回调写法

如果你维护的是 v16 或更早的代码,写法长一些:

import { createInterface } from 'node:readline';
import { stdin, stdout } from 'node:process';

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

rl.question('项目名称: ', name => {
  console.log(`你输入了: ${name}`);
  rl.close();
});

22.3 自定义提示与补全

修改提示符

import { createInterface } from 'node:readline/promises';
import { stdin, stdout } from 'node:process';

const rl = createInterface({
  input: stdin,
  output: stdout,
  prompt: 'my-cli> '
});

rl.prompt();
const answer = await rl.question('');
console.log(answer);

Tab 补全

import { createInterface } from 'node:readline';
import { stdin, stdout } from 'node:process';

const commands = ['init', 'build', 'test', 'deploy', 'help'];

const rl = createInterface({
  input: stdin,
  output: stdout,
  completer: (line) => {
    const hits = commands.filter(c => c.startsWith(line));
    return [hits.length ? hits : commands, line];
  }
});

rl.question('输入命令: ', cmd => {
  console.log(`执行: ${cmd}`);
  rl.close();
});

completer 函数接收当前输入,返回 [补全候选项数组, 原始输入]。用户按 Tab 时,readline 会展示候选项。

22.4 处理标准输入的原始数据

有时候你需要处理管道输入,比如 cat file.txt | node script.js

import { createInterface } from 'node:readline';
import { stdin } from 'node:process';

const rl = createInterface({ input: stdin });

for await (const line of rl) {
  console.log(`处理: ${line.toUpperCase()}`);
}

这种写法让脚本既能直接运行交互,也能接受管道数据,和 Unix 工具的行为一致。

22.5 隐藏密码输入

用户输密码时不该回显到屏幕上。readline 本身没有内建隐藏功能,但可以临时关闭 stdout 的显示:

import { createInterface } from 'node:readline/promises';
import { stdin, stdout } from 'node:process';

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

async function hiddenQuestion(prompt) {
  stdout.write(prompt);
  stdin.setRawMode(true);

  let password = '';
  for await (const chunk of stdin) {
    const key = chunk.toString();
    if (key === '\n' || key === '\r') break;
    if (key === '\u0003') process.exit(); // Ctrl+C
    if (key === '\u007f') { // Backspace
      password = password.slice(0, -1);
      continue;
    }
    password += key;
  }

  stdin.setRawMode(false);
  stdout.write('\n');
  return password;
}

const pwd = await hiddenQuestion('密码: ');
console.log(`密码长度: ${pwd.length}`);
rl.close();

生产环境可以考虑用成熟的 inquirerprompts 包,它们把密码隐藏、选择列表、多选框这些交互都封装好了。

22.6 实战:简易 CLI 工具

把上面的知识点串起来,写一个项目初始化脚手架:

import { createInterface } from 'node:readline/promises';
import { stdin, stdout } from 'node:process';
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';

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

async function main() {
  const name = await rl.question('项目名称: ');
  if (!name.trim()) {
    console.error('项目名称不能为空');
    rl.close();
    return;
  }

  const type = await rl.question('类型 (esm/cjs) [esm]: ');
  const isEsm = (type.trim() || 'esm') === 'esm';

  await mkdir(name, { recursive: true });

  const pkg = {
    name,
    version: '1.0.0',
    type: isEsm ? 'module' : 'commonjs',
    main: isEsm ? 'index.js' : 'index.js',
    scripts: { start: 'node index.js' }
  };

  await writeFile(
    join(name, 'package.json'),
    JSON.stringify(pkg, null, 2)
  );

  const indexContent = isEsm
    ? "console.log('Hello, ESM!');\n"
    : "console.log('Hello, CommonJS!');\n";

  await writeFile(join(name, 'index.js'), indexContent);

  console.log(`项目 ${name} 初始化完成`);
  rl.close();
}

main().catch(err => {
  console.error(err);
  rl.close();
  process.exit(1);
});