Javascript

命令行解析工具arg.js源码解读

本文主要是介绍命令行解析工具arg.js源码解读,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

最近在读 nextjs 源码,看到命令行参数解析工具用的 arg.js,用法比较简单,源码也只有 100 多行,相对于command.js来说,更加容易阅读和理解,于是便精读每一行代码,并试图了解其原理,本篇文章也主要用于解析arg.js原理。

源码链接:arg github 地址

为何要写这一篇文章?原因在于:

  • 记录笔记、想写博客
  • 深入理解解析命令行的方式,在我们写 cli 的过程中会更加得心应手
  • 自己在命令行解析这部分一直容易被卡住,便想把把经验分享给大家
  • arg.js麻雀虽小五脏俱全,源码只有100来行,但实现了很多功能,用了很多技巧

说明:

  • 以单-开头的,我称其为短命令
  • 以双-开头的,我称其为长命令

规则:

  • 长命令支持=赋值或者空格赋值,而短命令只支持空格赋值
  • 只有短命令可以进行缩写: 比如-vvt === -v -v -t
  • 除了 Boolean 类型或者用flagSymbol标识的类型都需要传递参数

分析思路

arg.js 仅包含一个函数,用于解析命令行参数,并返回一个包含解析命令的结果的对象

下面分析主要实现步骤:

  1. 定义返回对象的结构:result
  2. 处理预设置的命令,进行分类,和对预设置的命令进行规范校验
  3. 循环处理命令行传递过来的参数,对参数添加校验,并把校验通过的参数按规则存入 result

具体实现

定义一个 flagSymbol 的标志(标识了类型,后面叫做 flagSymbol 类型),用于给自定义类型添加一个标志,以便于在处理参数的时候分类进行处理。

const flagSymbol = Symbol('arg flag')
复制代码

定义函数主体

/**
 * 解析命令行参数
 * @param {Object} opts 预定义命令规则
 * @param {{ argv = process.argv.slice(2), permissive = false, stopAtPositional = false }} options
 * argv: 命令行参数,默认wei为process.argv.slice(2)
 * permissive: 是否规避错误,默认有错误直接抛出异常
 * stopAtPositional: 是否设置在第一个参数处停止解析
 * @return {Object} result { _: [] } 解析成功的命令直接存入result作为属性,不规范命令放入`result._`数组
 */
function arg(
  opts,
  {
    argv = process.argv.slice(2),
    permissive = false,
    stopAtPositional = false,
  } = {}
) {
  // 具体实现
}

// 仅仅用于给自定义类型添加一个标志
arg.flag = fn => {
  fn[flagSymbol] = true
  return fn
}

// 自定义类型实例,且标志为了flagSymbol类型
arg.COUNT = arg.flag((v, name, existingCount) => (existingCount || 0) + 1)

module.exports = arg
复制代码

函数内部实现

个人喜欢源码注释的方式进行讲解,内部实现过程上面已经简单描述了。下面直接上代码了:

function arg(
  opts,
  {
    argv = process.argv.slice(2),
    permissive = false,
    stopAtPositional = false,
  } = {}
) {
  // 必须传入预定义命令规则,否则抛出异常
  if (!opts) {
    throw new Error('Argument specification object is required')
  }

  // 1. 定义返回对象
  const result = { _: [] }

  // 2. 处理预设置的命令、包含对命令设置的规则校验
  const aliases = {} // 存放与handlers中存在的命令的key的对应关系,就是为了复用命令,类似webpack中配置alias,给已有命令添加别名。
  const handlers = {} // 存放需要执行的命令。
  for (const key of Object.keys(opts)) {
    // 2.1 命令key不能为空字符串
    if (!key) {
      throw new TypeError('Argument key cannot be an empty string')
    }

    // 2.2 命令key必须以'-'开头
    if (key[0] !== '-') {
      throw new TypeError(
        `Argument key must start with '-' but found: '${key}'`
      )
    }

    // 2.3 命令key长度等于1时,代表只有'-',不允许这样的命令存在
    if (key.length === 1) {
      throw new TypeError(
        `Argument key must have a name; singular '-' keys are not allowed: ${key}`
      )
    }

    // 2.4 如果命令key对应的值是字符串,则放入aliases,并且代表不会是一个新的命令,用于复用以定义的命令,直接继续下一个循环,但并不意味着aliases的命令必须定义在handlers之后
    if (typeof opts[key] === 'string') {
      aliases[key] = opts[key]
      continue
    }

    let type = opts[key] // 获取命令类型
    let isFlag = false // 用于标识type是Boolean或者通过flagSymbol定义的类型
    // 2.5 分类型进行设置isFlag
    if (
      Array.isArray(type) &&
      type.length === 1 &&
      typeof type[0] === 'function'
    ) {
      // 处理类型为数组情况,比如 "--tag": [Number]
      const [fn] = type
      // 改写type,可以让命令行中有出现多次同一个命令的时候,把结果存入数组中进行保存
      type = (value, name, prev = []) => {
        prev.push(fn(value, name, prev[prev.length - 1]))
        return prev
      }
      // 判断是Boolean类型还是通过flagSymbol定义的类型
      isFlag = fn === Boolean || fn[flagSymbol] === true
    } else if (typeof type === 'function') {
      // 处理类型为function的情况
      // 判断是Boolean类型还是通过flagSymbol定义的类型
      isFlag = type === Boolean || type[flagSymbol] === true
    } else {
      // 类型必须是一个方法,否则抛出异常
      throw new TypeError(
        `Type missing or not a function or valid array type: ${key}`
      )
    }

    // 2.6 当type长度大于2时,第二个参数如果不是'-',则type不符合规范
    // 说明一下key的规范(也是这里限制的规范):单'-'后面只能有一个字符串,也就是最大长度为0
    // 长度大于2,三个字符串以上的时候,第二个字符就必须为'-',否则就会在这里报错
    if (key[1] !== '-' && key.length > 2) {
      throw new TypeError(
        `Short argument keys (with a single hyphen) must have only one character: ${key}`
      )
    }

    // 2.7 把符合规范的命令以及isFlag存入handlers
    handlers[key] = [type, isFlag]
  }

  // 3. 循环处理命令行传递过来的参数,并对参数添加校验
  for (let i = 0, len = argv.length; i < len; i++) {
    const wholeArg = argv[i]

    // 3.1 当 stopAtPositional 为true 时,且result._已经存入了第一个参数,后面的参数就合并到result._中,并且跳出循环
    // 也就是说:当 stopAtPositional 为true 时,命令行参数列表中间有一个不以'-'开头的参数,就会导致该参数后面的参数都不会被正确解析
    if (stopAtPositional && result._.length > 0) {
      result._ = result._.concat(argv.slice(i))
      break
    }

    // 3.2 如果参数为'--' ,则不处理剩余参数,直接把后面的参数就合并到result._中,并终止循环
    if (wholeArg === '--') {
      result._ = result._.concat(argv.slice(i + 1))
      break
    }
    // 3.3 判断参数是否合法(合法标志,第一个字符串必须为'-',且长度大于1)
    if (wholeArg.length > 1 && wholeArg[0] === '-') {
      // 在这里进行分类处理
      // a. wholeArg[1] === '-' 说明是双'-'开头,表示长命令
      // b. wholeArg.length === 2 表示短命令,因为只有短命令长度才会为2
      // 也就是说长命令或者正常的短命令都符合a和b条件,separatedArguments值就为[wholeArg]
      // 那什么情况下会走另外一种方式呢?答案:当短命令后面还跟有字符串的时候
      // 这里就是让因为短命令支持短命令缩写,也就是,比如:`-v`可以写成`-vvvv`
      // `-vvvv`会被解析成 ['-v','-v','-v','-v'],就会连续触发四次'-v',在type为arg.COUNT时有效果
      // 比如`-vt` 会被解析成 ['-v','-t'],如果后面跟有参数,参数会被认为是最后一个命令的(这个后面进行处理)
      const separatedArguments =
        wholeArg[1] === '-' || wholeArg.length === 2
          ? [wholeArg]
          : wholeArg
              .slice(1)
              .split('')
              .map(a => `-${a}`)
      // 正常来说separatedArguments的长度为1,只有短命令连续时缩写或者短命令用=赋值才会大于1(注意:短命令用=赋值是不允许的)
      for (let j = 0; j < separatedArguments.length; j++) {
        const arg = separatedArguments[j]
        // 长命令会解析=,也就是在这里进行支持长命令`=`赋值,而短命令不支持`=`赋值,这也是因为短命令需要支持缩写与这个有冲突
        // originalArgName 为命令行的key,argStr 为`=`后面部分
        const [originalArgName, argStr] =
          arg[1] === '-' ? arg.split(/=(.*)/, 2) : [arg, undefined]

        let argName = originalArgName
        // 判断argName是否在aliases中,在则让argName指向设置的对应命令,比如-n指向--name,这里就会把-n转换为--name
        while (argName in aliases) {
          argName = aliases[argName]
        }

        // 检查命令是否在预定义命令中,不在就会根据permissive的设置来进行处理
        // permissive 为 false 抛出异常 ---默认
        // permissive 为 true 终止当前命令处理,继续下一个命令解析
        if (!(argName in handlers)) {
          if (permissive) {
            result._.push(arg)
            continue
          } else {
            const err = new Error(
              `Unknown or unexpected option: ${originalArgName}`
            )
            err.code = 'ARG_UNKNOWN_OPTION'
            throw err
          }
        }

        const [type, isFlag] = handlers[argName]

        // 短命令时,如果不是Boolean类型或者flagSymbol类型,缩写时该命令不是最后一个命令就会抛出异常
        // 分析什么情况下会抛出该异常,命令可以分为三种类型,
        // a. Boolean 不需要参数,且isFlag为true,不会走到这里来
        // b. flagSymbol类型,isFlag为true,不会走到这里来(暂时这是设定的,自定义类型只有arg.COUNT)
        // c. 其他类型,其他类型时都需要传递参数,短命令其他类型也一样,再加上短命令只能通过`-n xxx`来进行赋值,所以,
        // 如果其他类型这时候后面还跟有命令,就会走入判断条件,抛出异常
        // 比如:`-vnv`,被解析成['-v', '-n', '-v'] 因为'-n'需要参数,就会抛出异常
        if (!isFlag && j + 1 < separatedArguments.length) {
          throw new TypeError(
            `Option requires argument (but was followed by another short argument): ${originalArgName}`
          )
        }
        // 如果是Boolean类型或者flagSymbol类型
        if (isFlag) {
          // 把命令行的key和val存入result中
          // Boolean类型时,val 为true,只用到第2个参数
          // flagSymbol类型 时,执行自定义的方法
          // 比如 arg.COUNT就是每次读取之前的结果传入自定义函数进行累加,只用到第3个参数
          result[argName] = type(true, argName, result[argName])
        } else if (argStr === undefined) {
          // 既不是Boolean类型或者flagSymbol类型时,就只有其他类型,
          // argStr 为 undefined说明未用`=`赋值,这时argv数组中下一个参数就是当前参数的值

          // 第一部分 argv.length < i + 2 是用来判断是否最后一个参数,是最后一个参数则进入判断条件内部
          // 第二部分 用于判断下一个参数的正确性,判断下一个参数是一个命令而不是Number类型或者BigInt的情况,是一个命令则进入判断条件内部
          if (
            argv.length < i + 2 ||
            (argv[i + 1].length > 1 &&
              argv[i + 1][0] === '-' &&
              !(
                argv[i + 1].match(/^-?\d*(\.(?=\d))?\d*$/) &&
                (type === Number ||
                  // eslint-disable-next-line no-undef
                  (typeof BigInt !== 'undefined' && type === BigInt))
              ))
          ) {
            // 进入该判断条件内部,说明其他类型没有传递给命令有效的值,则抛出异常

            // 根据正常命令错误还是alias命令出现的错误,抛出不同的异常错误提示
            const extended =
              originalArgName === argName ? '' : ` (alias for ${argName})`
            throw new Error(
              `Option requires argument: ${originalArgName}${extended}`
            )
          }
          // 把argv数组中下一个参数赋值给当前命令
          result[argName] = type(argv[i + 1], argName, result[argName])
          // ++i的原因是因为下一个参数被赋值给了当前命令,就需要跳过下一个参数进行解析
          ++i
        } else {
          // 走到这里也只有其他类型,且argStr有值
          // 这时直接把值传递给type,进行类型转换
          result[argName] = type(argStr, argName, result[argName])
        }
      }
    } else {
      // 命令行不合法,比如第一参数第一个字符串不为'-',或者后面除了赋值的参数,有其他参数第一个字符串不为'-'的情况会走到这里来
      result._.push(wholeArg)
    }
  }
  return result
}
复制代码

最后

如果你正在学习 nodejs,或者正在编写 cli,希望本篇文章可以帮助到你。

如果有不对或者解释不合理的地方欢迎指出,谢谢!

这篇关于命令行解析工具arg.js源码解读的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!