了解一下nodejs中常用交互式命令行用户界面的集合和命令行接口。
Commander 是一个 npm 库,它帮我们封装了 解析命令行 的能力。它主要在nodejs程序运行的时候执行一次,对此次执行传入的参数解析,而不是做交互式命令行,要是想交互式的传递一些内容,这是不行的。
开发文档可以看这里:开发文档 | Commander 中文网
Github仓库在这里:GitHub - tj/commander.js: node.js command-line interfaces made easy
开发文档和Github仓库中都为我们提供了大量的demo作为参考。
npm install commander
上面的commander是用于解析参数的,我们想要做交互命令行呢?就是打印一些提示信息,然后用户输入一些内容,这个时候我们就可以用 Inquirer,它是一个常用的交互式命令行用户界面的集合。
开发文档可以看这里:开发文档 | Inquirer 中文网
Github仓库在这里:GitHub - SBoudrias/Inquirer.js: A collection of common interactive command line user interfaces.
开发文档和Github仓库中都为我们提供了大量的demo作为参考。
npm install @inquirer/prompts
我之前在刚开始使用inquirer的时候写了一个登录的简单demo,但是出现了下面的问题:
? 请输入用户名: aaa
✔ 请输入用户名: aaa
? 请输入密码: ********
✔ 请输入密码: ********
登录信息验证中...
✅ 登录成功!欢迎回来,aaa
但是实际上这样才是正常的:
✔ 请输入用户名: aaa
✔ 请输入密码: ********
登录信息验证中...
✅ 登录成功!欢迎回来,aaa
查了很久,发现屏蔽掉这个命令调用的函数 createMarkdownFile :
// 添加创建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命令的执行。我们可以修改如下:
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,可能干扰其他命令的输入输出:
/**
* @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:
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
),全局生效。
⚠️ 风险:多个模块修改同一实例可能导致不可预料的副作用(如状态混乱)。
- 资源占用问题
若实例的初始化涉及高成本操作(如连接数据库、加载大文件),那么首次导入时立即消耗资源。即使后续未使用该实例,资源也持续占用直到程序结束。