Node.js 中如何收集和解析命令行参数

9,457 阅读8分钟

前言

  在开发 CLI(Command Line Interface)工具的业务场景下,离不开命令行参数的收集和解析。

  接下来,本文介绍如何收集和解析命令行参数。

收集命令行参数

  在 Node.js 中,可以通过 process.argv 属性收集进程被启动时传入的命令行参数:

  // ./example/demo.js
  process.argv.slice(2);

  // 命令行执行如下命令
  node ./example/demo.js --name=xiaoming --age=20 man

  // 得到的结果
  '--name=xiaoming''--age=20''man' ]

  由上述示例可以发现,Node.js 在处理命令行参数时,只是简单地通过空格来分割字符串。

  对于这样的参数数组,无法很方便地获取到每个参数对应的值,所以需要再进行一次解析操作。

命令行参数风格

  在解析命令行参数之前,需要了解一些常见的命令行参数风格:

  • Unix 风格:参数以「-」(连字符)开头
  • GNU 风格:参数以「--」(双连字符)开头
  • BSD 风格:参数以空格分割

  Unix 参数风格有一个特殊的注意事项:「-」后面紧邻的每一个字母都表示一个参数名

  ls -al

  上述命令用来显示当前目录下所有的文件、文件夹并且显示它们的详细信息,等同于:

  ls -a -l

  GNU 风格的参数以 「--」开头,一般后面会跟上一个单词或者短语,例如熟悉的 npm 安装依赖的命令:

  npm install --save koa

对于两个单词的情况,在 GNU 参数风格中,会通过「-」来连接,例如 npm 安装仅用于开发环境的依赖:

  npm install --save-dev webpack

  BSD 是加州大学伯克利分校开发的一个 Unix 版本。其与 Unix 的区别主要在于参数前面没有 「-」,个人感觉这样很难区别参数和参数值。

注意事项:-- 后面紧邻空格时,表示后面的字符串不需要解析。

解析命令行参数

function parse(args = []) {
  // _ 属性用来保留不需要处理的参数字符串
  const output = { _: [] };

  for (let index = 0; index < args.length; index++) {
    const arg = args[index];
    
    if (isIgnoreFollowingParameters(output, args, index, arg)) {
      break;
    }
    
    if (!isParameter(arg)) {
      output._.push(arg);
      continue;
    }

    ...
  }

  return output;
}

parse(process.argv.slice(2));

  接收到命令行参数数组之后,需要遍历数组,处理每一个参数字符串。

  isIgnoreFollowingParameters 方法主要用来判断单个「--」的场景,后续的参数字符串不再需要处理:

function isIgnoreFollowingParameters(output, args, index, arg) {
  if (arg !== '--') {
    return false;
  }
  output._ = output._.concat(args.slice(++index));
  return true;
}

  接下来,如果参数字符串不以「-」开头,同样也不需要处理,参数的形式以 Unix 和 GNU 风格为主:

function isParameter(arg) {
  return arg.startsWith('-');
}

  参数的表现形式主要分为以下几种:

  • "--name=xiaoming": 参数名为 name,参数值为 xiaoming
  • "-abc=10": 参数名为 a,参数值为 true;参数名为 b,参数值为 true;参数名为 c,参数值为 10
  • "--save-dev": 参数名为 save-dev,参数值为 true
  • "--age 20":参数名为 age,参数值为 20
  let hyphensIndex;
  for (hyphensIndex = 0; hyphensIndex < arg.length; hyphensIndex++) {
    if (arg.charCodeAt(hyphensIndex) !== 45) {
      break;
    }
  }

  let assignmentIndex;
  for (assignmentIndex = hyphensIndex + 1; assignmentIndex < arg.length; assignmentIndex++) {
    if (arg[assignmentIndex].charCodeAt(0) === 61) {
      break;
    }
  }

  利用 Unicode 码点值找出连字符和等号的下标值,从而根据下标分割出参数名和参数值:

  const name = arg.substring(hyphensIndex, assignmentIndex);

  let value;
  const assignmentValue = arg.substring(++assignmentIndex);

  处理参数值时,需要考虑参数赋值的四种场景:

  if (assignmentValue) {
    value = assignmentValue; // --name=xiaoming or -abc=10
  } else if (index + 1 === args.length) {
    value = true; // --save-dev
  } else if (('' + args[index + 1]).charCodeAt(0) !== 45) {
    value = args[++index]; // --age 20
  } else {
    value = true; // 缺省情况
  }

  由于 Unix 风格中每一个字母都代表一个参数,并且手动传递的参数值应该赋值给最后一个参数,所以还需针对该场景进行适配:

  // 「-」or「--」
  const arr = hyphensIndex === 2 ? [name] : name;
  for (let keyIndex = 0; keyIndex < arr.length; keyIndex++) {
    const _key = arr[keyIndex];
    const _value = keyIndex + 1 < arr.length || value;
    handleKeyValue(output, _key, _value);
  }

  最后针对参数的赋值操作,需要考虑到多次赋值的情况:

function handleKeyValue(output, key, value) {
  const oldValue = output[key];
  if (Array.isArray(oldValue)) {
    output[key] = oldValue.concat(value);
    return;
  }

  if (oldValue) {
    output[key] = [oldValue, value];
    return;
  }

  output[key] = value;
}

  到此,命令行参数的解析功能就完成了,上述方法执行的效果如下:

  # 命令行执行
  node ./example/step1.js --name=xiaoming --age 20 --save-dev -abc=10 -c=20  -- --ignore

  # 解析结果
  {
    _: [ '--ignore' ],
    name: 'xiaoming',
    age: '20',
    'save-dev'true,
    a: true,
    b: true,
    c: [ '10''20' ]
  }

别名机制

  比较优秀的 CLI 工具在参数的解析上都支持参数的别名设置,例如使用 npm 安装开发环境依赖时,你可以选择这种完整的写法:

  npm install --save-dev webpack

  你也可以使用下面这种别名方式:

  npm install -D webpack

  从使用上来说 -D 和 --save-dev 是两种方式,但是从 CLI 工具的开发者来说,最终处理逻辑时只能以一个参数名为标准,所以对于一个命令行参数解析库来说,其结果需要包含所有的情况:

  npm install --save-dev webpack

  # 解析的结果
  { 'save-dev'true'D'true }

  以上文的解析方法为例,需要添加额外的选项参数,加入 alias 属性来声明别名属性的对应关系:

  parse(process.argv.slice(2), {
    alias: {
      'save-dev''S'
    }
  })

  上述方式符合正常的理解:设置参数对应的别名。但这是一个单向查找关系,需要转化为:

  "alias": {
    "save-dev": ["s"],
    "s": ["save-dev"]
  }

  因为对于使用者来说,只会选择一种方式传递参数。对于开发者的话需要根据任意一个别名找到其相关联的别名:

function parse(args = [], options = {}) {
  const output = { _: [] };

  const { alias } = options;

  const hasAlias = alias !== void 666;

  if (hasAlias) {
    Object.keys(alias).forEach(key => {
      alias[key] = toArr(alias[key]);
      alias[key].forEach((item, index) => {
        (alias[item] = alias[key].concat(key)).splice(index, 1);
      })
    })
  }

  // 省略解析代码
  ...

 if (hasAlias) {
    Object.keys(output).forEach(key => {
      const arr = alias[key] || [];
      arr.forEach(sub => output[sub] = output[key])
    })
 }

  return output;
}

  除了别名之外,还可以在参数解析之后做如下优化:

  • 参数值的类型约束
  • 参数的默认值设定

成熟的解析库

  针对一些成熟的命令行参数解析库可以采用基准测试查看它们的解析效率:

const nopt = require('nopt');
const mri = require('mri');
const yargs = require('yargs-parser');
const minimist = require('minimist');
const { Suite } = require('benchmark');

const bench = new Suite();
const args = ['--name=xiaoming''-abc''10''--save-dev''--age''20'];

bench
 .add('minimist     '() => minimist(args))
 .add('mri          '() => mri(args))
 .add('nopt         '() => nopt(args))
 .add('yargs-parser '() => yargs(args))
 .on('cycle'e => console.log(String(e.target)))
 .run();

  本文的内容主要参考解析效率最高的 mri 库的源码,感兴趣的同学可以学习其源码实现。(顺便吐槽一下:嵌套三元操作符可读性真的很差。。)

  虽然上述基准测试中 minimist 效率并不很好,但是其覆盖了比较全的参数输入场景。(以上测试用例覆盖的场景有限)

写在最后

  最后,如果本文对您有帮助,欢迎关注(公众号【漫谈大前端】)、点赞、转发 ε=ε=ε=┏(゜ロ゜;)┛。