Skip to content

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 模块现代 JavaScriptimport { 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:8080

WARNING

如果忘记为需要参数的选项提供值,Commander.js 会报错并提示用户。

选项的高级特性

1. 选项组合

多个短选项可以组合使用:

bash
# 这三种写法是等价的
$ app -d -s -p 3000
$ app -ds -p 3000  
$ app -dsp 3000

2. 取反选项

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)详解

命令允许你创建具有不同功能的子命令,就像 gitgit addgit 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);
  });

处理函数详解

处理函数是命令的核心逻辑,它接收以下参数:

  1. 命令参数:按定义顺序传递
  2. 选项对象:包含所有解析的选项
  3. 命令对象:当前命令的实例
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 工具应该让用户感到强大而非困惑。始终从用户体验的角度设计你的命令行界面!


参考资源