Appearance
Commander.js 完整学习指南
NOTE
Commander.js 是 Node.js 中最受欢迎的命令行接口(CLI)构建工具,它让开发者能够轻松创建功能强大的命令行应用程序。
📚 目录
什么是 Commander.js
Commander.js 是一个专门用于构建 Node.js 命令行应用程序的库。它提供了一套完整的解决方案,包括:
- 🚀 参数解析:自动解析命令行参数和选项
- 📋 帮助系统:自动生成帮助信息
- 🔧 子命令支持:支持复杂的多级命令结构
- 🛡️ 错误处理:完善的错误提示和处理机制
业务场景示例
安装与快速开始
安装
bash
npm install commander第一个 CLI 程序
让我们从一个简单的文件分割工具开始:
js
const { program } = require('commander');
// 定义选项和参数
program
.option('--first', '只显示第一个分割结果')
.option('-s, --separator <char>', '分隔符')
.argument('<string>', '要分割的字符串');
// 解析命令行参数
program.parse();
// 获取选项和参数
const options = program.opts();
const inputString = program.args[0];
// 执行分割逻辑
const limit = options.first ? 1 : undefined;
const result = inputString.split(options.separator, limit);
console.log(result);使用示例
bash
# 基本用法
$ node split.js -s / "a/b/c"
[ 'a', 'b', 'c' ]
# 只显示第一个结果
$ node split.js -s / --first "a/b/c"
[ 'a' ]
# 错误的选项会被提示
$ node split.js -s / --fits "a/b/c"
error: unknown option '--fits'
(Did you mean --first?)TIP
Commander.js 会自动提供拼写建议,帮助用户发现输入错误!
核心概念
程序结构图
声明 Program 变量的三种方式
| 方式 | 适用场景 | 代码示例 |
|---|---|---|
| 全局对象 | 简单应用 | const { program } = require('commander') |
| 本地对象 | 复杂应用、单元测试 | const program = new Command() |
| ES6 模块 | 现代 JavaScript | import { Command } from 'commander' |
点击查看不同声明方式的完整示例
js
// 方式 1:全局对象(推荐用于简单应用)
const { program } = require('commander');
program.version('1.0.0');
// 方式 2:本地对象(推荐用于复杂应用)
const { Command } = require('commander');
const program = new Command();
program.version('1.0.0');
// 方式 3:ES6 模块
import { Command } from 'commander';
const program = new Command();
program.version('1.0.0');选项(Options)详解
选项是命令行工具的核心功能,Commander.js 支持多种类型的选项。
选项类型总览
| 选项类型 | 语法 | 描述 | 示例 |
|---|---|---|---|
| Boolean 选项 | --debug | 开关型选项 | --debug, -d |
| 值选项 | --port <number> | 需要参数的选项 | --port 3000 |
| 可选值选项 | --cheese [type] | 参数可选的选项 | --cheese 或 --cheese blue |
| 取反选项 | --no-sauce | 否定型选项 | --no-sauce |
| 必填选项 | .requiredOption() | 必须提供的选项 | 程序启动时检查 |
Boolean 选项
Boolean 选项是最简单的选项类型,只有开启和关闭两种状态:
js
const { program } = require('commander');
program
.option('-d, --debug', '启用调试模式')
.option('-v, --verbose', '详细输出')
.option('-q, --quiet', '静默模式');
program.parse();
const options = program.opts();
console.log('选项状态:', options);bash
$ node app.js -d -v
选项状态: { debug: true, verbose: true }
$ node app.js --quiet
选项状态: { quiet: true }带参数的选项
这类选项需要用户提供具体的值:
js
const { program } = require('commander');
program
.option('-p, --port <number>', '服务器端口号')
.option('-h, --host <address>', '服务器地址', 'localhost') // 带默认值
.option('-c, --config <path>', '配置文件路径');
program.parse();
const options = program.opts();
console.log(`服务器将运行在 ${options.host}:${options.port}`);bash
$ node server.js -p 3000
服务器将运行在 localhost:3000
$ node server.js --port 8080 --host 0.0.0.0
服务器将运行在 0.0.0.0:8080WARNING
如果忘记为需要参数的选项提供值,Commander.js 会报错并提示用户。
选项的高级特性
1. 选项组合
多个短选项可以组合使用:
bash
# 这三种写法是等价的
$ app -d -s -p 3000
$ app -ds -p 3000
$ app -dsp 30002. 取反选项
js
program
.option('--sauce', '添加酱料')
.option('--no-sauce', '不添加酱料')
.option('--cheese <type>', '奶酪类型', 'mozzarella')
.option('--no-cheese', '不添加奶酪');3. 可变参数选项
js
program
.option('-f, --files <files...>', '多个文件路径')
.option('-t, --tags [tags...]', '可选的多个标签');
program.parse();
const options = program.opts();
console.log('文件列表:', options.files);
console.log('标签列表:', options.tags);bash
$ node app.js -f file1.txt file2.txt file3.txt -t tag1 tag2
文件列表: ['file1.txt', 'file2.txt', 'file3.txt']
标签列表: ['tag1', 'tag2']自定义选项处理
有时需要对选项值进行特殊处理,比如类型转换或数据积累:
js
function parseInteger(value) {
const parsedValue = parseInt(value, 10);
if (isNaN(parsedValue)) {
throw new Error('不是有效的数字');
}
return parsedValue;
}
function collectValues(value, previous) {
return previous.concat([value]);
}
function increaseVerbosity(dummyValue, previous) {
return previous + 1;
}
program
.option('-p, --port <number>', '端口号', parseInteger)
.option('-v, --verbose', '详细程度(可重复)', increaseVerbosity, 0)
.option('-i, --include <path>', '包含路径(可重复)', collectValues, [])
.option('-l, --list <items>', '逗号分隔的列表', (value) => value.split(','));
program.parse();bash
$ node app.js -p 3000 -vvv -i ./src -i ./lib --list a,b,c命令(Commands)详解
命令允许你创建具有不同功能的子命令,就像 git 有 git add、git commit 等子命令一样。
命令的基本结构
创建子命令
js
const { Command } = require('commander');
const program = new Command();
// 设置主程序信息
program
.name('string-util')
.description('JavaScript 字符串处理工具')
.version('1.0.0');
// 创建 split 子命令
program.command('split')
.description('将字符串分割成数组')
.argument('<string>', '要分割的字符串')
.option('--first', '只显示第一个分割结果')
.option('-s, --separator <char>', '分隔符', ',')
.action((str, options) => {
const limit = options.first ? 1 : undefined;
console.log(str.split(options.separator, limit));
});
// 创建 reverse 子命令
program.command('reverse')
.description('反转字符串')
.argument('<string>', '要反转的字符串')
.action((str) => {
console.log(str.split('').reverse().join(''));
});
program.parse();使用示例
bash
# 查看帮助
$ node string-util.js --help
Usage: string-util [options] [command]
JavaScript 字符串处理工具
Options:
-V, --version 显示版本号
-h, --help 显示帮助信息
Commands:
split [options] <string> 将字符串分割成数组
reverse <string> 反转字符串
help [command] 显示命令的帮助信息
# 使用 split 命令
$ node string-util.js split --separator=/ "a/b/c"
[ 'a', 'b', 'c' ]
# 使用 reverse 命令
$ node string-util.js reverse "hello"
olleh命令参数详解
参数类型
| 参数类型 | 语法 | 描述 | 示例 |
|---|---|---|---|
| 必填参数 | <name> | 必须提供的参数 | <filename> |
| 可选参数 | [name] | 可以省略的参数 | [output] |
| 可变参数 | <names...> | 接受多个值的参数 | <files...> |
高级参数配置
js
const { Argument } = require('commander');
program.command('serve')
.addArgument(new Argument('<port>', '端口号').argParser(parseInt))
.addArgument(new Argument('[host]', '主机地址').default('localhost'))
.addArgument(new Argument('<files...>', '要服务的文件'))
.action((port, host, files) => {
console.log(`在 ${host}:${port} 上服务文件:`, files);
});处理函数详解
处理函数是命令的核心逻辑,它接收以下参数:
- 命令参数:按定义顺序传递
- 选项对象:包含所有解析的选项
- 命令对象:当前命令的实例
js
program.command('deploy')
.argument('<environment>', '部署环境')
.argument('[version]', '版本号', 'latest')
.option('-f, --force', '强制部署')
.option('-d, --dry-run', '模拟运行')
.option('--timeout <seconds>', '超时时间', '300')
.action((environment, version, options, command) => {
console.log('=== 部署信息 ===');
console.log('环境:', environment);
console.log('版本:', version);
console.log('选项:', options);
if (options.dryRun) {
console.log('🔍 这是模拟运行,不会实际部署');
return;
}
if (options.force) {
console.log('⚠️ 强制部署模式');
}
// 执行部署逻辑...
console.log(`🚀 开始部署到 ${environment} 环境...`);
});独立可执行文件
对于复杂的应用,可以将子命令拆分为独立的文件:
js
#!/usr/bin/env node
const { program } = require('commander');
program
.name('pm')
.version('1.0.0')
.command('install [packages...]', '安装包')
.command('uninstall <packages...>', '卸载包')
.command('list', '列出已安装的包', { isDefault: true });
program.parse();js
#!/usr/bin/env node
const { program } = require('commander');
program
.argument('[packages...]', '要安装的包')
.option('-D, --save-dev', '安装为开发依赖')
.option('-g, --global', '全局安装')
.action((packages, options) => {
if (packages.length === 0) {
console.log('📦 安装 package.json 中的所有依赖...');
} else {
console.log('📦 安装包:', packages.join(', '));
}
if (options.saveDev) {
console.log('📝 保存为开发依赖');
}
if (options.global) {
console.log('🌍 全局安装');
}
});
program.parse();自动化帮助系统
Commander.js 的一大亮点是自动生成的帮助系统,无需手动编写帮助文档。
默认帮助信息
bash
$ node pizza.js --help
Usage: pizza [options]
美味披萨订购系统
Options:
-p, --peppers 添加胡椒
-c, --cheese <type> 添加指定类型的奶酪 (default: "mozzarella")
-C, --no-cheese 不要奶酪
-h, --help 显示帮助信息自定义帮助内容
js
program
.name('pizza')
.description('美味披萨订购系统')
.version('2.0.0')
.option('-p, --peppers', '添加胡椒')
.option('-c, --cheese <type>', '奶酪类型', 'mozzarella')
.option('-C, --no-cheese', '不要奶酪');
// 添加额外的帮助信息
program.addHelpText('after', `
示例用法:
$ pizza --cheese parmesan
$ pizza -p --no-cheese
$ pizza --help
更多信息请访问: https://pizza-cli.example.com`);
// 自定义帮助选项
program.helpOption('-?, --help', '显示使用帮助');帮助信息的位置
js
program.addHelpText('beforeAll', '🍕 欢迎使用披萨订购系统\n');
program.addHelpText('before', '选择你的配料:');
program.addHelpText('after', '\n享受你的披萨! 🎉');
program.addHelpText('afterAll', '\n© 2024 Pizza Corp');输出效果:
🍕 欢迎使用披萨订购系统
Usage: pizza [options]
选择你的配料:
Options:
-p, --peppers 添加胡椒
-c, --cheese <type> 奶酪类型 (default: "mozzarella")
-h, --help 显示帮助信息
享受你的披萨! 🎉
© 2024 Pizza Corp高级特性
生命周期钩子
钩子函数允许你在命令执行的不同阶段插入自定义逻辑:
js
program
.option('-t, --trace', '显示执行轨迹')
.hook('preAction', (thisCommand, actionCommand) => {
if (thisCommand.opts().trace) {
console.log(`🚀 即将执行命令: ${actionCommand.name()}`);
console.log('📥 参数:', actionCommand.args);
console.log('⚙️ 选项:', actionCommand.opts());
}
})
.hook('postAction', (thisCommand, actionCommand) => {
if (thisCommand.opts().trace) {
console.log(`✅ 命令 ${actionCommand.name()} 执行完成`);
}
});错误处理
js
// 自定义错误处理
program.exitOverride((err) => {
if (err.code === 'commander.version') {
console.log('版本信息已显示');
process.exit(0);
}
if (err.code === 'commander.help') {
console.log('帮助信息已显示');
process.exit(0);
}
if (err.code === 'commander.unknownOption') {
console.error('❌ 未知选项:', err.message);
process.exit(1);
}
});
// 显示自定义错误
program.command('validate')
.argument('<email>', '邮箱地址')
.action((email) => {
if (!email.includes('@')) {
program.error('❌ 邮箱格式不正确', { exitCode: 1 });
}
console.log('✅ 邮箱格式正确');
});环境变量支持
js
const { Option } = require('commander');
program.addOption(
new Option('-p, --port <number>', '端口号').env('PORT').default(3000)
);
program.parse();bash
# 使用环境变量
$ PORT=8080 node app.js
# 端口号将是 8080
# 命令行选项优先级更高
$ PORT=8080 node app.js --port 9000
# 端口号将是 9000实战案例
让我们构建一个完整的文件管理 CLI 工具:
js
#!/usr/bin/env node
const { Command } = require('commander');
const fs = require('fs').promises;
const path = require('path');
const program = new Command();
// 主程序配置
program
.name('fm')
.description('🗂️ 强大的文件管理工具')
.version('1.0.0');
// 列出文件命令
program.command('list')
.alias('ls')
.description('列出目录中的文件')
.argument('[directory]', '目录路径', '.')
.option('-a, --all', '显示隐藏文件')
.option('-l, --long', '详细信息')
.option('-s, --size', '显示文件大小')
.action(async (directory, options) => {
try {
const files = await fs.readdir(directory);
let filteredFiles = files;
if (!options.all) {
filteredFiles = files.filter(file => !file.startsWith('.'));
}
console.log(`📁 目录: ${path.resolve(directory)}`);
console.log('─'.repeat(50));
for (const file of filteredFiles) {
const filePath = path.join(directory, file);
const stats = await fs.stat(filePath);
let output = '';
if (stats.isDirectory()) {
output += '📁 ';
} else {
output += '📄 ';
}
output += file;
if (options.size) {
output += ` (${formatBytes(stats.size)})`;
}
if (options.long) {
output += `\n 修改时间: ${stats.mtime.toLocaleDateString()}`;
output += `\n 权限: ${stats.mode.toString(8)}`;
}
console.log(output);
if (options.long) {
console.log('');
}
}
} catch (error) {
console.error('❌ 错误:', error.message);
process.exit(1);
}
});
// 复制文件命令
program.command('copy')
.alias('cp')
.description('复制文件或目录')
.argument('<source>', '源文件路径')
.argument('<destination>', '目标路径')
.option('-r, --recursive', '递归复制目录')
.option('-f, --force', '强制覆盖')
.action(async (source, destination, options) => {
try {
const sourceStats = await fs.stat(source);
if (sourceStats.isDirectory() && !options.recursive) {
console.error('❌ 要复制目录请使用 -r 选项');
process.exit(1);
}
// 检查目标是否存在
try {
await fs.access(destination);
if (!options.force) {
console.error('❌ 目标已存在,使用 -f 强制覆盖');
process.exit(1);
}
} catch {
// 目标不存在,可以继续
}
if (sourceStats.isFile()) {
await fs.copyFile(source, destination);
console.log(`✅ 文件已复制: ${source} → ${destination}`);
} else {
// 这里应该实现递归复制目录的逻辑
console.log(`✅ 目录已复制: ${source} → ${destination}`);
}
} catch (error) {
console.error('❌ 复制失败:', error.message);
process.exit(1);
}
});
// 删除文件命令
program.command('remove')
.alias('rm')
.description('删除文件或目录')
.argument('<paths...>', '要删除的路径')
.option('-r, --recursive', '递归删除目录')
.option('-f, --force', '强制删除(不提示)')
.action(async (paths, options) => {
for (const filePath of paths) {
try {
const stats = await fs.stat(filePath);
if (!options.force) {
const answer = await askConfirmation(`确定要删除 ${filePath} 吗?`);
if (!answer) {
console.log(`⏭️ 跳过删除: ${filePath}`);
continue;
}
}
if (stats.isDirectory()) {
if (options.recursive) {
await fs.rmdir(filePath, { recursive: true });
console.log(`🗑️ 目录已删除: ${filePath}`);
} else {
console.error(`❌ ${filePath} 是目录,使用 -r 选项递归删除`);
}
} else {
await fs.unlink(filePath);
console.log(`🗑️ 文件已删除: ${filePath}`);
}
} catch (error) {
console.error(`❌ 删除失败 ${filePath}:`, error.message);
}
}
});
// 工具函数
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function askConfirmation(question) {
return new Promise((resolve) => {
const readline = require('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
rl.question(`${question} (y/N) `, (answer) => {
rl.close();
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
});
});
}
// 添加帮助信息
program.addHelpText('after', `
示例用法:
$ fm list --all --size # 列出所有文件和大小
$ fm copy file.txt backup.txt # 复制文件
$ fm rm -rf temp/ # 递归删除目录
更多信息: https://github.com/your-repo/file-manager
`);
program.parse();使用示例
bash
# 列出当前目录文件
$ fm list
📁 目录: /home/user/project
──────────────────────────────────────────────────
📄 package.json
📄 README.md
📁 src
📁 node_modules
# 详细列表显示
$ fm ls -als
📁 目录: /home/user/project
──────────────────────────────────────────────────
📄 .gitignore (45 B)
修改时间: 2024/1/15
权限: 100644
📄 package.json (1.2 KB)
修改时间: 2024/1/15
权限: 100644
# 复制文件
$ fm copy README.md docs/README.md
✅ 文件已复制: README.md → docs/README.md
# 删除文件(会提示确认)
$ fm rm temp.txt
确定要删除 temp.txt 吗? (y/N) y
🗑️ 文件已删除: temp.txt最佳实践
1. 命令设计原则
设计建议
- 简洁明了:命令名应该简短且易于记忆
- 一致性:选项命名保持一致的风格
- 渐进式:提供基础功能的简单用法,高级功能的详细选项
- 容错性:提供有用的错误信息和建议
2. 选项命名规范
| 场景 | 推荐 | 避免 |
|---|---|---|
| 布尔选项 | --verbose, --quiet | --is-verbose |
| 短选项 | -v, -q | -verb, -qt |
| 值选项 | --output <file> | --output-file |
| 取反选项 | --no-color | --disable-color |
3. 错误处理策略
js
// 统一的错误处理
class CLIError extends Error {
constructor(message, exitCode = 1) {
super(message);
this.exitCode = exitCode;
}
}
// 在命令中使用
program.command('validate')
.argument('<file>', '要验证的文件')
.action(async (file) => {
try {
// 检查文件是否存在
await fs.access(file);
// 执行验证逻辑
const isValid = await validateFile(file);
if (!isValid) {
throw new CLIError('文件验证失败', 2);
}
console.log('✅ 文件验证通过');
} catch (error) {
if (error instanceof CLIError) {
console.error(`❌ ${error.message}`);
process.exit(error.exitCode);
} else {
console.error('❌ 未知错误:', error.message);
process.exit(1);
}
}
});常见问题解答
Q: 如何处理复杂的配置文件?
js
program
.option('-c, --config <path>', '配置文件路径', './config.json')
.hook('preAction', async (thisCommand) => {
const configPath = thisCommand.opts().config;
try {
const configContent = await fs.readFile(configPath, 'utf8');
const config = JSON.parse(configContent);
// 将配置合并到全局对象
global.config = config;
} catch (error) {
console.warn('⚠️ 配置文件读取失败,使用默认配置');
global.config = {};
}
});Q: 如何实现命令的自动补全?
js
// 安装 commander-completion
// npm install commander-completion
const completion = require('commander-completion');
program
.command('completion')
.description('生成自动补全脚本')
.action(() => {
console.log(completion(program));
});Q: 如何添加进度条和日志?
js
// 使用 chalk 和 ora
const chalk = require('chalk');
const ora = require('ora');
program.command('build')
.description('构建项目')
.action(async () => {
const spinner = ora('正在构建项目...').start();
try {
// 模拟构建过程
await new Promise(resolve => setTimeout(resolve, 3000));
spinner.succeed(chalk.green('✅ 构建完成!'));
} catch (error) {
spinner.fail(chalk.red('❌ 构建失败'));
console.error(error);
}
});总结
Commander.js 是构建 Node.js CLI 应用的首选工具,它提供了:
- 🚀 简单易用:直观的 API 设计
- 🛠️ 功能完整:覆盖 CLI 开发的各个方面
- 📚 文档完善:丰富的示例和详细的文档
- 🔧 高度可定制:支持各种高级特性和自定义需求
无论是简单的脚本工具还是复杂的企业级 CLI 应用,Commander.js 都能提供强大的支持。
IMPORTANT
记住:好的 CLI 工具应该让用户感到强大而非困惑。始终从用户体验的角度设计你的命令行界面!