arg.js 源码分析
最近在读 nextjs
源码,看到命令行参数解析工具用的 arg.js
,用法比较简单,源码也只有 100 多行,相对于command.js
来说,更加容易阅读和理解,于是便精读每一行代码,并试图了解其原理,本篇文章也主要用于解析arg.js
原理。
arg.js 源码链接:arg github 地址 (opens in a new tab)
为何要写这一篇文章?原因在于:
- 记录笔记、想写博客
- 深入理解解析命令行的方式,在我们写 cli 的过程中会更加得心应手
- 自己在命令行解析这部分一直容易被卡住,便想把把经验分享给大家
arg.js
麻雀虽小五脏俱全,源码只有 100 来行,但实现了很多功能,用了很多技巧
说明:
- 以单
-
开头的,我称其为短命令 - 以双
-
开头的,我称其为长命令
规则:
- 长命令支持
=
赋值或者空格赋值,而短命令只支持空格赋值 - 只有短命令可以进行缩写: 比如
-vvt
===-v -v -t
- 除了
Boolean
类型或者用flagSymbol
标识的类型都需要传递参数
分析思路
arg.js
仅包含一个函数,用于解析命令行参数,并返回一个包含解析命令的结果的对象
下面分析主要实现步骤:
- 定义返回对象的结构:result
- 处理预设置的命令,进行分类,和对预设置的命令进行规范校验
- 循环处理命令行传递过来的参数,对参数添加校验,并把校验通过的参数按规则存入 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,希望本篇文章可以帮助到你。
如果有不对或者解释不合理的地方欢迎指出,谢谢!