Skip to content

了解一下nodejs中常用交互式命令行用户界面的集合和命令行接口。

Commander 是一个 npm 库,它帮我们封装了 解析命令行 的能力。它主要在nodejs程序运行的时候执行一次,对此次执行传入的参数解析,而不是做交互式命令行,要是想交互式的传递一些内容,这是不行的。

开发文档可以看这里:开发文档 | Commander 中文网

Github仓库在这里:GitHub - tj/commander.js: node.js command-line interfaces made easy

开发文档和Github仓库中都为我们提供了大量的demo作为参考。

shell
npm install commander

上面的commander是用于解析参数的,我们想要做交互命令行呢?就是打印一些提示信息,然后用户输入一些内容,这个时候我们就可以用 Inquirer,它是一个常用的交互式命令行用户界面的集合。

开发文档可以看这里:开发文档 | Inquirer 中文网

Github仓库在这里:GitHub - SBoudrias/Inquirer.js: A collection of common interactive command line user interfaces.

开发文档和Github仓库中都为我们提供了大量的demo作为参考。

shell
npm install @inquirer/prompts

我之前在刚开始使用inquirer的时候写了一个登录的简单demo,但是出现了下面的问题:

shell
? 请输入用户名: aaa
 请输入用户名: aaa
? 请输入密码: ********
 请输入密码: ********
登录信息验证中...

 登录成功!欢迎回来,aaa

但是实际上这样才是正常的:

shell
 请输入用户名: aaa
 请输入密码: ********
登录信息验证中...

 登录成功!欢迎回来,aaa

查了很久,发现屏蔽掉这个命令调用的函数 createMarkdownFile :

typescript
// 添加创建markdown文件的命令
program
  //......
  .action(async (filename, options) => {
    try {
      await createMarkdownFile(filename, options);
    } catch (err) {
      console.error('❌ 创建文档失败:', (err as Error).message);
      process.exit(1);
    }
  });

屏蔽之后恢复正常。

去查了各种资料,从现象看,问题出现在login命令执行时,但实际与 new 命令的执行函数 createMarkdownFile 相关。可能的原因包括有以下三点。

cmd_create_md.ts 可能在模块加载时(import时)执行了某些初始化代码,这些代码可能修改了全局状态或process.argv等,影响了后续login命令的执行。我们可以修改如下:

typescript
program
  	//......
    try {
      const { createMarkdownFile } = await import('./cmd/cmd_create_md');
      await createMarkdownFile(filename, options);
    } catch (err) {
      console.error('❌ 创建文档失败:', (err as Error).message);
      process.exit(1);
    }
  });

这样处理之后,问题消失,但是其实没有找到根本原因,这里这样修改避免了模块加载时的副作用影响其他命令,确保createMarkdownFile只在需要时加载,保持了原有功能完整性,可以作为一种解决方案,那有没有其他方案?

可能两个命令的参数解析存在冲突,createMarkdownFile可能修改了commander的默认行为,这个其实是可以排除的,两个命令名称是不一样的。

createMarkdownFile可能包含未正确处理的异步操作,影响了login命令的promise链。分析一下这个代码,主要的异步操作有:

  • (1)confirmOverwrite函数中的readline.question

  • (2)整个createMarkdownFile函数是async的

发现的问题点:

a) 模块顶层立即创建了readline实例(rl),这会立即修改process.stdin/process.stdout,可能干扰其他命令的输入输出:

typescript
/**
 * @brief 创建readline接口用于用户交互
 * @description 初始化标准输入输出接口用于命令行交互
 */
const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout
});

这段代码使用 Node.js 的 readline 模块创建一个交互式命令行接口,具体功能是:建立输入/输出通道,input: process.stdin表示从系统标准输入流读取数据(通常是用户键盘输入),output: process.stdout表示将输出定向到系统标准输出流(通常是终端控制台),它的核心作用:实现逐行读取用户输入(例如用户在终端输入内容后按回车键),便于创建交互式命令行程序(如问答系统、命令行工具等)。

b) rl.close()在finally块中调用,如果函数提前return(如文件存在且不覆盖),可能导致readline实例未正确关闭。

这些异步操作的问题表现:模块加载时就创建了readline实例,可能占用标准输入输出,影响其他命令(如login)的交互。

解决方案建议

  • 将readline实例移到函数内部
  • 确保所有路径都正确关闭readline
  • 避免模块加载时的副作用

移除模块顶层的readline实例,将readline实例创建移到confirmOverwrite函数内部,确保所有路径都正确关闭readline:

typescript
async function confirmOverwrite(filePath: string): Promise<boolean> {
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout
  });

  try {
    return await new Promise((resolve) => {
      rl.question(`⚠️  文件已存在: ${filePath} 是否覆盖? (y/N) `, (answer) => {
        resolve(answer.trim().toLowerCase() === 'y');
      });
    });
  } finally {
    rl.close();
  }
}

上面出现问题,主要是因为定义了一个全局变量实例,当一个模块(例如名为 es 的模块)中定义了实例对象(如全局变量实例)时,若被其他模块通过 import 导入,会产生以下影响:

  • 实例的初始化时机

(1)首次导入时:模块的顶层代码会执行,实例对象立即被创建(例如 my_instance = MyClass())。

(2)后续导入:直接从缓存加载模块,不会重新创建实例,而是复用首次创建的实例。

  • 全局单例效应

该实例会成为事实上的单例:所有导入这个模块的都是同一个实例对象。在任何模块中修改该实例的状态(如 es.my_instance.value = 10),全局生效

⚠️ 风险:多个模块修改同一实例可能导致不可预料的副作用(如状态混乱)。

  • 资源占用问题

若实例的初始化涉及高成本操作(如连接数据库、加载大文件),那么首次导入时立即消耗资源。即使后续未使用该实例,资源也持续占用直到程序结束。

莫道桑榆晚 为霞尚满天.