第10章 10.2 CLI 工具开发(commander + chalk)
🎯 上一章我们折腾完了实时聊天系统,用 WebSocket 实现了「发一条消息,对方立刻能看到」的效果。那种「双向实时」的体验,有没有让你感受到一点「专业感」?
但说实话,WebSocket 太偏底层了——你总不能每次想做个简单功能就去写一堆事件监听吧?
今天我们来学点「更接地气」的:用 Node.js 写命令行工具。想象一下,你在终端里敲几个字母,就能让程序帮你干活——自动备份文件、批量处理数据、爬取网页信息……这不比点鼠标爽多了?
学完这章,你就能写出带颜色、带进度条、带交互提示的专业 CLI 工具。准备好了吗?Let's go!
🎯 开场 3 分钟:为什么要学这个?
你有没有遇到过这些情况?
- 每天要手动复制粘贴一堆文件,烦死了,想一键自动处理
- 看到别人写的
vue create或npm init这种命令,觉得很酷,自己也想写一个 - 想做一个数据导出工具,但不想写网页,直接命令行搞定
CLI 工具就是解决这些问题的。CLI 是 Command Line Interface 的缩写,翻译过来就是「命令行界面」——就是你那个黑乎乎的终端窗口。
举个例子,你每天用的 npm install、git commit、vue create 都是 CLI 工具。学会了,你也能写出这种「专业感」十足的东西。
本章学完你能解决:
- 如何解析命令行参数(比如 node app.js --name 小明 --age 18)
- 如何给终端输出加上五颜六色(红色报错、绿色成功、黄色警告)
- 如何做出进度条、选择菜单等交互效果
🧱 基础 25 分钟:核心概念
什么是 CLI 工具?
类比:点餐机 vs 人工服务员
想象你去奶茶店:
- 网页/APP 点餐 = 人工服务员,你点啥他给你拿啥(对应 Web 开发)
- 自助点餐机 = 你自己在屏幕上戳戳戳,自己选规格、自己选配料(对应 CLI 工具)
CLI 工具就是那个「自助点餐机」——用户通过命令告诉程序要什么,程序自动执行。你不需要写界面,只需要处理好「命令」和「参数」。
10.2.1 commander.js——命令行参数解析
是什么: commander 是 Node.js 里最流行的命令行参数解析库,专门帮你把 node app.js --name 小明 --age 18 这种命令转换成程序能用的变量。
为什么用: 你自己写参数解析?光是要处理 --name=value、--name value、-n value 各种格式就够头秃了。commander 帮你搞定这一切。
怎么用:
// app.js
const { Command } = require('commander');
const program = new Command();
program
.name('mytool')
.description('一个超实用的 CLI 工具')
.version('1.0.0');
// 定义一个 greet 命令
program
.command('greet')
.description('向指定人打招呼')
.option('-n, --name <name>', '打招呼的人的名字', 'World')
.action((options) => {
console.log(`你好,${options.name}!`);
});
program.parse();
运行一下:
node app.js greet
# 输出:你好,World!
node app.js greet -n 小明
# 输出:你好,小明!
node app.js greet --name Alice
# 输出:你好,Alice!

解释: .command('greet') 定义了一个叫 greet 的子命令,.option() 定义了这个命令可以接受什么参数,.action() 定义了参数传进来后执行什么代码。
10.2.2 chalk——终端着色
是什么: chalk 是个让你在终端里输出彩色文字的库。报错用红色、成功用绿色、提示用黄色,清晰又好看。
为什么用: 终端默认只有黑白两色,看久了眼睛累。加上颜色,重点信息一眼就能看到,用户体验直接提升一个档次。
怎么用:
// color.js
const chalk = require('chalk');
// 各种颜色
console.log(chalk.red('这是红色的错误信息'));
console.log(chalk.green('这是绿色的成功信息'));
console.log(chalk.yellow('这是黄色的警告信息'));
console.log(chalk.blue('这是蓝色的普通信息'));
// 组合使用
console.log(chalk.red.bold('粗体红色'));
console.log(chalk.bgGreen.white('绿色背景白色字'));
// 模板字符串也可以
const name = '小明';
console.log(chalk`{green 成功!} {red.bold ${name}} 操作失败了`);
运行输出大概长这样(想象一下带颜色的终端):
这是红色的错误信息
这是绿色的成功信息
这是黄色的警告信息
这是蓝色的普通信息
粗体红色
绿色背景白色字
成功! 小明 操作失败了
坑点提醒: chalk 默认导出的是函数,直接调用 chalk.red() 就行。别再去 new chalk() 了,会报错的!
10.2.3 ora——进度条和loading动画
是什么: ora 是个让你在终端里显示「加载中」动画的库,比如爬数据时转圈圈,告诉用户「我在干活呢,别急」。
为什么用: 没有 loading 动画,用户以为程序卡死了;有了动画,用户就知道「哦,在跑呢」。这个小细节能大大减少用户的焦虑感。
怎么用:
// loading.js
const ora = require('ora');
const spinner = ora('正在加载数据...').start();
setTimeout(() => {
spinner.succeed('数据加载成功!');
}, 2000);
setTimeout(() => {
const spinner2 = ora({
text: '正在处理文件...',
color: 'yellow'
}).start();
setTimeout(() => {
spinner2.fail('处理失败,请重试');
}, 1500);
}, 3000);
运行结果:
- 先显示「正在加载数据...」并带一个旋转的动画
- 2秒后变成绿色的勾「数据加载成功!」
- 再过1.5秒后显示红色的叉「处理失败,请重试」
10.2.4 inquirer——交互式提示
是什么: inquirer 让你在命令行里做出「选择菜单」「输入框」「确认提示」这种交互效果。用户不是只能敲参数,还可以边跑边问。
为什么用: 有些参数用户可能不确定要填什么,这时给他一个选择菜单比让他查文档省事多了。
怎么用:
// interactive.js
const inquirer = require('inquirer');
async function main() {
const answers = await inquirer.prompt([
{
type: 'input',
name: 'username',
message: '你叫什么名字?',
default: '游客'
},
{
type: 'list',
name: 'action',
message: '你想做什么?',
choices: ['查看列表', '添加项目', '删除项目', '退出']
},
{
type: 'confirm',
name: 'confirm',
message: '确认执行?',
default: true
}
]);
console.log('你输入的信息是:', answers);
}
main();
运行后终端会依次弹出提示让用户输入/选择,最后打印出所有答案。

🔥 实战 35 分钟:3 个递进的小项目
项目 1(5分钟):个人信息收集工具
目标: 学会用 commander 定义带参数的命令,用 chalk 给输出着色。
完整代码:
// project1.js
const { Command } = require('commander');
const chalk = require('chalk');
const program = new Command();
program
.name('profile')
.description('个人信息收集工具')
.version('1.0.0');
program
.command('collect')
.description('收集并展示个人信息')
.option('-n, --name <name>', '姓名')
.option('-a, --age <age>', '年龄')
.option('-c, --city <city>', '城市')
.action((options) => {
console.log(chalk.blue('\n📋 个人信息收集结果\n'));
console.log(chalk.white(' 姓名:') + chalk.green(options.name || '未填写'));
console.log(chalk.white(' 年龄:') + chalk.green(options.age || '未填写'));
console.log(chalk.white(' 城市:') + chalk.green(options.city || '未填写'));
console.log(chalk.gray('\n 收集时间:') + new Date().toLocaleString());
});
program.parse();
运行:
node project1.js collect -n 张三 -a 25 -c 北京
预期输出:
📋 个人信息收集结果
姓名:张三
年龄:25
城市:北京
收集时间:2024/1/15 14:30:00
一句话解释: 定义了一个 collect 命令,用 -n、-a、-c 接收参数,最后用 chalk 把输出打扮得好看点。
项目 2(15分钟):CSV 数据处理器
目标: 读取 CSV 文件,统计里面的数据,用 ora 显示处理进度。
准备: 先创建一个 students.csv 文件:
name,score,grade
小明,85,A
小红,72,B
小李,91,A
小张,68,C
小王,78,B
完整代码:
// project2.js
const { Command } = require('commander');
const chalk = require('chalk');
const ora = require('ora');
const fs = require('fs');
const path = require('path');
const program = new Command();
program
.name('student-analyzer')
.description('学生成绩分析工具')
.version('1.0.0');
program
.command('analyze <file>')
.description('分析学生成绩CSV文件')
.action((file) => {
const spinner = ora(chalk.blue('正在读取文件...')).start();
// 检查文件是否存在
if (!fs.existsSync(file)) {
spinner.fail(chalk.red(`文件不存在:${file}`));
process.exit(1);
}
// 读取文件
const content = fs.readFileSync(file, 'utf-8');
spinner.succeed(chalk.green('文件读取成功'));
// 解析 CSV
const parseSpinner = ora(chalk.blue('正在解析数据...')).start();
const lines = content.trim().split('\n');
const headers = lines[0].split(',');
const students = [];
for (let i = 1; i < lines.length; i++) {
const values = lines[i].split(',');
const student = {};
headers.forEach((header, index) => {
student[header.trim()] = values[index].trim();
});
student.score = parseInt(student.score);
students.push(student);
}
parseSpinner.succeed(chalk.green(`解析了 ${students.length} 条记录`));
// 统计分析
const analyzeSpinner = ora(chalk.blue('正在分析数据...')).start();
const totalScore = students.reduce((sum, s) => sum + s.score, 0);
const avgScore = (totalScore / students.length).toFixed(2);
const maxScore = Math.max(...students.map(s => s.score));
const minScore = Math.min(...students.map(s => s.score));
const aCount = students.filter(s => s.grade === 'A').length;
setTimeout(() => {
analyzeSpinner.succeed(chalk.green('分析完成'));
// 输出结果
console.log(chalk.blue('\n📊 成绩统计报告\n'));
console.log(chalk.white(' 学生总数:') + chalk.green(students.length));
console.log(chalk.white(' 平均分: ') + chalk.green(avgScore));
console.log(chalk.white(' 最高分: ') + chalk.green(maxScore));
console.log(chalk.white(' 最低分: ') + chalk.green(minScore));
console.log(chalk.white(' A级人数: ') + chalk.green(aCount));
// 找出最高分学生
const topStudent = students.find(s => s.score === maxScore);
console.log(chalk.yellow('\n🏆 最高分学生:') + chalk.green(`${topStudent.name} (${maxScore}分)`));
}, 500);
});
program.parse();
运行:
node project2.js analyze students.csv
预期输出:
✔ 文件读取成功
✔ 解析了 5 条记录
✔ 分析完成
📊 成绩统计报告
学生总数:5
平均分:78.80
最高分:91
最低分:68
A级人数:2
🏆 最高分学生:小李 (91分)
一句话解释: 先用 ora 显示「处理中」的动画增强仪式感,然后解析 CSV 做统计分析,最后输出带颜色的报告。
项目 3(15分钟):待办事项管理 CLI 工具
目标: 综合运用 commander + chalk + ora + inquirer,写一个带增删改查功能的待办事项工具,数据存在本地 JSON 文件里。
完整代码:
// project3.js
const { Command } = require('commander');
const chalk = require('chalk');
const ora = require('ora');
const inquirer = require('inquirer');
const fs = require('fs');
const path = require('path');
const TODO_FILE = path.join(__dirname, 'todos.json');
// 初始化文件
function initFile() {
if (!fs.existsSync(TODO_FILE)) {
fs.writeFileSync(TODO_FILE, JSON.stringify([]));
}
}
// 读取待办
function loadTodos() {
initFile();
const content = fs.readFileSync(TODO_FILE, 'utf-8');
return JSON.parse(content);
}
// 保存待办
function saveTodos(todos) {
fs.writeFileSync(TODO_FILE, JSON.stringify(todos, null, 2));
}
const program = new Command();
program
.name('todo')
.description('待办事项管理工具')
.version('1.0.0');
// 查看列表
program
.command('list')
.description('查看所有待办事项')
.action(() => {
const todos = loadTodos();
if (todos.length === 0) {
console.log(chalk.yellow('\n📝 暂无待办事项,抓紧去添加吧!\n'));
return;
}
console.log(chalk.blue('\n📝 待办事项列表\n'));
todos.forEach((todo, index) => {
const status = todo.done
? chalk.green('✅ 已完成')
: chalk.gray('⬜ 待完成');
const num = chalk.bold(`${index + 1}.`);
console.log(`${num} ${status} ${chalk.white(todo.task)}`);
});
console.log(chalk.gray(`\n共 ${todos.length} 项,待完成 ${todos.filter(t => !t.done).length} 项\n`));
});
// 添加待办
program
.command('add')
.description('添加新待办事项')
.action(async () => {
const { task } = await inquirer.prompt([
{
type: 'input',
name: 'task',
message: '请输入待办事项:',
validate: (input) => input.trim() !== '' || '内容不能为空'
}
]);
const todos = loadTodos();
todos.push({ task: task.trim(), done: false, createdAt: new Date().toISOString() });
saveTodos(todos);
const spinner = ora(chalk.green('添加成功')).start();
setTimeout(() => spinner.succeed(), 500);
});
// 完成待办
program
.command('done <index>')
.description('标记待办事项为完成')
.action((index) => {
const todos = loadTodos();
const num = parseInt(index) - 1;
if (num < 0 || num >= todos.length) {
console.log(chalk.red('\n❌ 序号不存在,请使用 todo list 查看序号\n'));
return;
}
todos[num].done = true;
todos[num].completedAt = new Date().toISOString();
saveTodos(todos);
console.log(chalk.green(`\n✅ 已标记「${todos[num].task}」为完成\n`));
});
// 删除待办
program
.command('delete <index>')
.description('删除待办事项')
.action((index) => {
const todos = loadTodos();
const num = parseInt(index) - 1;
if (num < 0 || num >= todos.length) {
console.log(chalk.red('\n❌ 序号不存在,请使用 todo list 查看序号\n'));
return;
}
const deleted = todos.splice(num, 1)[0];
saveTodos(todos);
console.log(chalk.yellow(`\n🗑️ 已删除「${deleted.task}」\n`));
});
program.parse();
运行示例:
# 添加待办
node project3.js add
# 终端会弹出输入框让你输入
# 查看列表
node project3.js list
# 输出待办列表
# 标记完成
node project3.js done 1
# 删除
node project3.js delete 2
预期输出(list):
📝 待办事项列表
1. ✅ 已完成 买牛奶
2. ⬜ 待完成 写周报
3. ⬜ 待完成 打电话给妈妈
共 3 项,待完成 2 项
一句话解释: 用 JSON 文件做数据持久化,实现了增删改查的完整流程,inquirer 让添加操作变得交互友好。
💪 进阶 20 分钟:常见坑 + 性能小贴士
坑 1:chalk 颜色不显示
❌ 错误:
const chalk = require('chalk');
console.log(chalk.red('错误信息'));
// 在某些终端或 CI 环境下显示的是原始字符串 [31mxxx[39m
✅ 正确:
// 确保 chalk 输出到支持颜色的终端
process.stdout.isTTY ? chalk.red('有颜色') : '无颜色';
// 或者检测环境
const supportsColor = require('supports-color');
if (supportsColor) {
console.log(chalk.red('有颜色'));
} else {
console.log('错误信息');
}
坑 2:commander 参数类型转换
❌ 错误:
.option('-a, --age <age>', '年龄')
.action((options) => {
console.log(options.age + 1); // 字符串拼接,不是加法!
});
✅ 正确:
.option('-a, --age <age>', '年龄', parseInt) // 手动指定转换函数
.action((options) => {
console.log(options.age + 1); // 正常加法
});
// 或者在 action 里转换
.action((options) => {
const age = parseInt(options.age);
console.log(age + 1);
});
坑 3:ora 的异步操作
❌ 错误:
const spinner = ora('加载中').start();
fetchData(); // 异步操作,但没等待
spinner.succeed('完成');
// 结果:spinner 变成 succeed 时,数据可能还没加载完
✅ 正确:
const spinner = ora('加载中').start();
await fetchData(); // 用 async/await 等待完成
spinner.succeed('完成');
// 或者
spinner.start();
fetchData().then(() => spinner.succeed()).catch(() => spinner.fail());
坑 4:inquirer 在某些环境不工作
❌ 错误:
// 直接在非交互环境调用 inquirer
const answers = await inquirer.prompt([...]);
// 在脚本自动化执行时可能出错
✅ 正确:
// 先检查是否是 TTY 环境
if (!process.stdout.isTTY) {
console.log('请在交互式终端运行此命令');
process.exit(1);
}
const answers = await inquirer.prompt([...]);
坑 5:文件路径拼接
❌ 错误:
const file = 'data/' + filename; // Windows 下可能出问题
✅ 正确:
const path = require('path');
const file = path.join(__dirname, 'data', filename); // 跨平台兼容
性能小贴士:批量操作用 stream
如果处理大文件,不要一次性 fs.readFileSync 全部读进内存,用流式处理:
const fs = require('fs');
const readline = require('readline');
async function processLargeFile(file) {
const stream = fs.createReadStream(file);
const rl = readline.createInterface({ input: stream });
let count = 0;
for await (const line of rl) {
count++;
// 逐行处理,不占内存
}
console.log(`共 ${count} 行`);
}
调试技巧:console.log + chalk 配合
// 调试时打印中间变量
console.log(chalk.blue('DEBUG:'), chalk.yellow(JSON.stringify(data)));
// 配合 --debug 参数开启调试模式
if (process.argv.includes('--debug')) {
console.log('详细调试信息...');
}
✏️ 练习题 + 作业题
练习题(10 分钟)
练习 1(2 分钟):改名字
- 输入:node profile.js collect -n 李四 -a 30 -c 上海
- 预期输出:显示「李四」「30」「上海」
- 提示:只改 .action() 里的 console.log 拼写
练习 2(2 分钟):加个电话字段
- 输入:在 collect 命令加 -p, --phone 选项,运行 node profile.js collect -n 王五 -p 13800138000
- 预期输出:额外显示电话号码
- 提示:参考已有的 option 写法,再加一行
练习 3(3 分钟):统计不及格人数
- 输入:用项目 2 的代码处理一个及格/不及格都有的成绩 CSV
- 预期输出:统计 60 分以下的人数
- 提示:在 analyze 命令的统计分析部分,加一个 filter 条件 score < 60
练习 4(3 分钟):结合项目和 chalk
- 输入:把项目 2 的输出全部加上 chalk 颜色
- 预期输出:带颜色的统计报告
- 提示:给每个 console.log 的字符串包上 chalk.xx()
作业题(30 分钟-2 小时)
作业:做一个「个人日记 CLI 工具」
- 需求描述: 用 commander + chalk + inquirer 做一个命令行日记本,可以写日记、查看日记列表、搜索日记内容
- 功能点:
1.diary add- 交互式添加日记(日期自动生成,内容手动输入)
2.diary list- 显示所有日记,按日期倒序
3.diary search <keyword>- 搜索日记内容包含关键词的条目 - 加分项:
1. 给日记加「标签」功能(工作/生活/学习)
2. 支持diary delete <id>删除日记 - 验收标准: 能跑起来 + 数据保存在本地 JSON + 输出带 chalk 颜色
- 提交方式: 评论区贴代码或 GitHub 链接
📚 总结 + 资源
本文学了 3 件事:
1. 用 commander 解析命令行参数,让你的程序可以被「命令」驱动
2. 用 chalk + ora 美化输出,让终端不再是黑白世界
3. 用 inquirer 做交互式提示,让 CLI 工具也能「对话」
延伸学习资源:
- commander.js 官方文档 - 最权威的参考,写得很清晰
- chalk 官方文档 - 颜色配置表很全
- 《Node.js 实战:使用 Electron 跨平台桌面应用开发》- CLI 工具是桌面应用的「简化版」,想深入可以看看
互动钩子: 你有没有想过用 CLI 工具自动处理什么重复性工作?比如自动备份照片、自动整理下载文件夹?用过什么好用的 CLI 工具吗?评论区聊聊,老粉优先回复!👇
📢 下章预告:学会了 CLI 工具,是时候做一个「真正的项目」了——下一章我们要用 Node.js 仿 V2EX 写一个完整的论坛 API,包括发帖子、看帖子、评论、用户注册登录……从命令行一步跨到「正经项目」,敬请期待!

评论(0)