CAC源码阅读

245 阅读6分钟

CAC:源码仓库地址

目录说明

  • CAC功能介绍
  • 源码目录分析
  • 程序流程图
  • 执行流程举例分析
  • FAQ
  • 总结

CAC功能介绍

解析

使用CAC做参数解析

examples/basic-usage.js

展示帮助信息和版本

examples/help.js

Command-specific Options

可以附加options到命令行

const cli = require('cac')() cli .command('rm <dir>', 'Remove a dir') .option('-r, --recursive', 'Remove recursively') .action((dir, options) => { console.log('remove ' + dir + (options.recursive ? ' recursively' : '')) }) cli.help() cli.parse()

action回调函数第一个参数dir为

的参数,options为接受到 的如通过-r, --recursive附加的参数,如果在使用命令行时使用了unknown options,会报错。如果不定义action,传入unknown options不会报错,如果需要使用unknown options,请使用command.allowUnknownOptions

Option中的-

option中所有短横线属性都会被映射成对应小驼峰属性,比如: --clear-screen--clearScreen都对应options.clearScreen

括号

  • command中的<>为必选参数,[]为可选参数
  • option中,<>参数的值可以是字符串或者是数字,[]参数的值可以是字符串,数字,不传的时候值为boolean类型的true

注意:如果传入false,false会被转成字符串的false

Negated Options

  • 如果需要一个option的值为false,不是'false',我们需要手动指定一个no-xx的参数,比如:
  • --no-clear-screen 默认不传参数,clearScreen值为true
  • --no-clear-screen false 指定clearScreenfalse

强调:如果我们需要指定一个值为false,需要通过类-no-xx的参数去指定,通过xx false指定的false会转变为'false'

command中的可选参数

command里面的最后一个参数是可选的,类似es6的扩展运算符,比如:

  • .command('build <entry> [...otherFiles]', 'Build your app')
  • node examples/variadic-arguments.js build a.js b.js c.js --foo a.js会被映射到entry, b.js c.js属于剩余参数,会被全部映射到otherFiles

点嵌套option

类似'--env.API_SECRET xxx',我们可以在env对象里面添加自定义属性,比如:

  • node examples/dot-nested-options.js build --env.foo foo --env.bar bar
  • option.env={foo:"foo",bar:"bar"}

默认Command

当未匹配到Command时,使用默认Command。 在CACparse方法中,可以看到name不存在时,会尝试获取执行命令的第二个参数,否则name默认为cli

if (!this.name) {
  this.name = argv[1] ? getFileName(argv[1]) : 'cli'
}

数组作为option参数

node cli.js --include project-a --include project-b 
# option{ include: ['project-a', 'project-b'] }

错误处理

使用try..catch

文件目录分析

  • examples。用于存放代码例子文件,可直接命令行运行。对应
  • scripts。存放脚本文件
  • readme中所举的功能实例
  • src。存放源码实现文件和测试文件
  • test。存放测试文件
  • .editorconfig。帮助开发人员在不同的编辑器和IDE之间定义和维护一致的编码样式
  • .gitattributes
  • .gitignore git提交忽略文件
  • .prettierrc prettier配置文件
  • circle.yml
  • index-compat.js
  • jest.config.js jest测试配置文件
  • LICENSE 开源协议
  • mod_test.ts
  • package.json npm配置文件
  • rollup.config.js roollup打包配置文件
  • tsconfig.json ts配置文件
  • yarn.lock

程序流程图

CAC.png

执行流程举例分析

// 实例化CAC实例cli
// 示例化GlobalCommand,globalCommand继承自Command
const cli = require('../src/index').cac()

cli
  // 实例化Command,将globalCommand挂在command.globalCommand上,并将其存储在CAC实例的commands上,返回command实例本身,以=实现链式调用
  .command('build', 'desc')
  // 实例化Option,并将解析好的option存储在CAC实例的options上,返回command实例,实现链式调用
  // '--env <env>'解析为option对象,option中属性为选项name为'env',required为true,negated为false,该option属性名称为env,是必选参数,不是no-参数
  .option('--env <env>', 'Set envs')
  // '--foo-bar <value>'解析为option对象,name为fooBar,required为true,negated为false
  .option('--foo-bar <value>', 'Set foo bar')
  // 存储'--env.API_SECRET xxx'command的examples,提示我们env可以传入属性,可以是一个对象,返回command实例,实现链式调用
  .example('--env.API_SECRET xxx')
  // 将回调函数存储在command实例上commandAction,以便后续调用
  .action((options) => {
    console.log(options)
  })

// 当执行命令出现-h, --help时展示help信息
cli.help()

// 解析参数
cli.parse()

new Command

实例化Command。定义command的规则,能够接受什么命令,命令名称是什么,带什么样的参数,参数名称是什么,是必选参数,还是可选参数,还是不确定的多个参数

    this.options = []
    this.aliasNames = []
    // 去掉尖括号或者方括号后面的内容,获取command的名称。比如rawName为build <cli>,name会被处理为build
    // 这里传入build,name为build
    this.name = removeBrackets(rawName)
    // 找到所有的括号,解析为一个数组,每个数组中包含三个属性,required是否必选,由是否是尖括号决定,value属性名称,variadic,包含...为variadic,可包括多个不确定的参数,最后一个参数才可以是多选参数
    // rawName为build,args为空
    this.args = findAllBrackets(rawName)
    this.examples = []

new Option

实例化Option。记录选项参数的规则,选项参数的名称,是否可选,必选,多选参数。--a-b的参数会被转换为aB小驼峰形式。

    this.config = Object.assign({}, config)

    // 因为可能存在 '--env.* [value]'的情况,将参数放在env的属性里面。替换掉`.*`,留下--env[value]
    // You may use cli.option('--env.* [value]', 'desc') to denote a dot-nested option
    rawName = rawName.replace(/\.\*/g, '')

    this.negated = false
    // 去掉括号后的内容
    this.names = removeBrackets(rawName)
      .split(',')
      .map((v: string) => {
      // 去掉--或者-
        let name = v.trim().replace(/^-{1,2}/, '')
        if (name.startsWith('no-')) {
          this.negated = true
          // 去掉开头的no-
          name = name.replace(/^no-/, '')
        }

        // 转换为小驼峰
        return camelcaseOptionName(name)
      })
      // 长的放后面。如:'-h,--help'
      .sort((a, b) => (a.length > b.length ? 1 : -1)) // Sort names

     // 取最长的。取到help
    // Use the longest name (last one) as actual option name
    this.name = this.names[this.names.length - 1]

    if (this.negated && this.config.default == null) {
      this.config.default = true
    }
   
    if (rawName.includes('<')) {
      // 尖括号判断
      this.required = true
    } else if (rawName.includes('[')) {
      // 方括号判断
      this.required = false
    } else {
      // No arg needed, it's boolean flag
      // 没有参数时,默认为布尔值true
      this.isBoolean = true
    }

cli.help()

在globalCommand中实例化-h, --help的option规则,可传入回调,标记showHelpOnExit为true。后续在parse时会将全局option和各个子command的option进行数组合并,所以全局option可以得到处理。

  help(callback?: HelpCallback) {
  // 解析'-h, --help'存储在globalCommand的options中
    this.globalCommand的options中.option('-h, --help', 'Display this message')
    // 存储helpCallback
    this.globalCommand.helpCallback = callback
    // 标记showHelpOnExit为true
    this.showHelpOnExit = true
    // 返回
    return this
  }

cli.parse()

接受命令行参数,同command和option进行匹配对比

parse(
    argv = processArgs,
    {
      /** Whether to run the action for matched command */
      run = true,
    } = {}
  ): ParsedArgv {
    this.rawArgs = argv
    // 在new CAC时如果有传递,则等于注册的command名称,或者获取argv第二个参数作为名称
    if (!this.name) {
      this.name = argv[1] ? getFileName(argv[1]) : 'cli'
    }

    let shouldParse = true

    // Search sub-commands
    // 遍历已注册的command
    for (const command of this.commands) {
    // 获取到解析后的命令args的值和option的值
      const parsed = this.mri(argv.slice(2), command)

      // 取第一个参数为command名称
      const commandName = parsed.args[0]
      // 和提前注册好的command的名称做对比,如果能匹配上
      if (command.isMatched(commandName)) {
        shouldParse = false
        // 截取args名称后面的参数
        const parsedInfo = {
          ...parsed,
          args: parsed.args.slice(1),
        }
        // 将解析好args,option,command,commandName存储在CAC,方便后续返回
        this.setParsedInfo(parsedInfo, command, commandName)
        // 分发通知
        this.emit(`command:${commandName}`, command)
      }
    }

    if (shouldParse) {
      // 如有默认command执行默认command匹配解析逻辑
      // Search the default command
      for (const command of this.commands) {
        if (command.name === '') {
          shouldParse = false
          const parsed = this.mri(argv.slice(2), command)
          this.setParsedInfo(parsed, command)
          this.emit(`command:!`, command)
        }
      }
    }

    if (shouldParse) {
      // 最后,执行默认解析和存储
      const parsed = this.mri(argv.slice(2))
      this.setParsedInfo(parsed)
    }

    // 如果option传入--help -h 则打印帮助信息 
    if (this.options.help && this.showHelpOnExit) {
      this.outputHelp()
      run = false
      this.unsetMatchedCommand()
    }

    // 如果option传入--version -v 则打印版本信息
    if (this.options.version && this.showVersionOnExit && this.matchedCommandName == null) {
      this.outputVersion()
      run = false
      this.unsetMatchedCommand()
    }

    const parsedArgv = { args: this.args, options: this.options }

    if (run) {
      // 执行注册好的对应command的action
      this.runMatchedCommand()
    }

    if (!this.matchedCommand && this.args[0]) {
     // 未匹配到,分发通知
      this.emit('command:*')
    }

   // 返回解析好的args和options
    return parsedArgv
  }

CAC.mri

获取解析完成的命令args参数值和option参数值,核心是需要通过mri库进行转换,额外功能添加了嵌套属性支持和类型配置转换支持。

private mri(
    argv: string[],
    /** Matched command */ command?: Command
  ): ParsedArgv {
    // All added options
    // 合并全局option
    const cliOptions = [      ...this.globalCommand.options,      ...(command ? command.options : []),
    ]
    // 获取别名和布尔配置
    const mriOptions = getMriOptions(cliOptions)

    // Extract everything after `--` since mri doesn't support it
    let argsAfterDoubleDashes: string[] = []
    const doubleDashesIndex = argv.indexOf('--')
    if (doubleDashesIndex > -1) {
      argsAfterDoubleDashes = argv.slice(doubleDashesIndex + 1)
      argv = argv.slice(0, doubleDashesIndex)
    }

    // 通过mri解析参数
    let parsed = mri(argv, mriOptions)
    parsed = Object.keys(parsed).reduce(
      (res, name) => {
        return {
          ...res,
          // 转换成小驼峰
          [camelcaseOptionName(name)]: parsed[name],
        }
      },
      { _: [] }
    )

    const args = parsed._

    const options: { [k: string]: any } = {
      '--': argsAfterDoubleDashes,
    }

    // Set option default value
    const ignoreDefault =
      command && command.config.ignoreOptionDefaultValue
        ? command.config.ignoreOptionDefaultValue
        : this.globalCommand.config.ignoreOptionDefaultValue

    let transforms = Object.create(null)

    for (const cliOption of cliOptions) {
      if (!ignoreDefault && cliOption.config.default !== undefined) {
        for (const name of cliOption.names) {
          options[name] = cliOption.config.default
        }
      }

      // If options type is defined
      // 如果option定义了类型,则将类型shouldTransform置为true,存储transformFunction为转换函数
      if (Array.isArray(cliOption.config.type)) {
        if (transforms[cliOption.name] === undefined) {
          transforms[cliOption.name] = Object.create(null)

          transforms[cliOption.name]['shouldTransform'] = true
          transforms[cliOption.name]['transformFunction'] =
            cliOption.config.type[0]
        }
      }
    }

    // Set option values (support dot-nested property name)
    for (const key of Object.keys(parsed)) {
      if (key !== '_') {
        const keys = key.split('.')
        // 支持属性嵌套
        setDotProp(options, keys, parsed[key])
        // 执行参数类型转换
        setByType(options, transforms)
      }
    }

    return {
      args,
      options,
    }
  }

setDotProp

构造嵌套属性

export const setDotProp = (
  obj: { [k: string]: any },
  keys: string[],
  val: any
) => {
  let i = 0
  let length = keys.length
  let t = obj
  let x
  for (; i < length; ++i) {
    x = t[keys[i]]
    t = t[keys[i]] =
      i === length - 1
        ? val
        : x != null
        ? x
        : !!~keys[i + 1].indexOf('.') || !(+keys[i + 1] > -1)
        ? {}
        : []
  }
}

setByType

类型转换

export const setByType = (
  obj: { [k: string]: any },
  transforms: { [k: string]: any }
) => {
  for (const key of Object.keys(transforms)) {
    const transform = transforms[key]

    if (transform.shouldTransform) {
      obj[key] = Array.prototype.concat.call([], obj[key])

      if (typeof transform.transformFunction === 'function') {
        obj[key] = obj[key].map(transform.transformFunction)
      }
    }
  }
}

FAQ

Brackets 应该如何使用

方括号和尖括号?

  • 方括号为可选参数,尖括号为必选参数。
  • option中,<>参数的值可以是字符串或者是数字,[]参数的值可以是字符串,数字,不传的时候值为boolean类型的true

Negated Options 是如何实现的?

Option类中constructor函数,将rawName中括号去掉后,对name进行遍历,如果name是以no-开头的,则将name替换成不包含no-的的name,比如'no-config'对应options中的config参数。并且将option.negated=true,以便后续option在其他地方被使用

    this.names = removeBrackets(rawName)
      .split(',')
      .map((v: string) => {
        let name = v.trim().replace(/^-{1,2}/, '')
        if (name.startsWith('no-')) {
          this.negated = true
          name = name.replace(/^no-/, '')
        }

        return camelcaseOptionName(name)
      })
      .sort((a, b) => (a.length > b.length ? 1 : -1))

如何实现连续调用的api

  cli
    // 返回command实例
    .command('cook <...food>', 'Cook some good')
    // 返回command实例
    .option('--bar <bar>', 'Bar is a boolean option')
     // 返回command实例
    .action((food, options) => {
      console.log(food, options)
    })

如何理解command

  • 执行命令的对象
  • 包含了命令的名称,参数,参数的性质
  • 我们可以用于自定义command

如何理解option

  • option参数。
  • 收集了option对象的属性和值,支持了多种类型
  • 可以用于自定义option方便后续程序处理

如何理解action

  • action可以简单理解为当这个command被执行的回调
  • 通过command.action收集一个回调函数,存储在对应command,当parse解析命令行参数,匹配中该command时,会执行这个command.action

总结

  • CAC就是一个帮助你解析命令行参数的库。
  • 我们可以根据已有规则定义我们的自定义命令command以及选项参数options,在命中定义好的command后会帮我们将参数和值解析好输出,action就是命中以及解析的回调,在action里面我们拿到args和options可以做我们后续想做的其他处理。