Node.js 的 Shell 腳本
您可以購買這本書的離線版本(HTML、PDF、EPUB、MOBI),並支持免費的線上版本。
(廣告,請不要封鎖。)

16 使用 util.parseArgs() 分析命令列參數



在本章中,我們探討如何使用模組 node:util 中的 Node.js 函式 parseArgs() 來分析命令列參數。

16.1 本章中隱含的匯入

本章中的每個範例都隱含以下兩個匯入

import * as assert from 'node:assert/strict';
import {parseArgs} from 'node:util';

第一個匯入是我們用於檢查值的測試斷言。第二個匯入是本章的主題,函式 parseArgs()

16.2 處理命令列參數所涉及的步驟

處理命令列參數涉及以下步驟

  1. 使用者輸入文字字串。
  2. Shell 將字串分析成一系列的字詞和運算子。
  3. 如果呼叫命令,它會將零個或多個字詞作為參數。
  4. 我們的 Node.js 程式碼透過儲存在 process.argv 中的陣列接收字詞。 process 是 Node.js 上的全球變數。
  5. 我們使用 parseArgs() 將該陣列轉換成更方便處理的內容。

讓我們使用以下包含 Node.js 程式碼的 Shell 腳本 args.mjs 來查看 process.argv 的樣子

#!/usr/bin/env node
console.log(process.argv);

我們從一個簡單的命令開始

% ./args.mjs one two
[ '/usr/bin/node', '/home/john/args.mjs', 'one', 'two' ]

如果我們透過 npm 在 Windows 上安裝命令,則相同的命令會在 Windows 命令殼層中產生以下結果

[
  'C:\\Program Files\\nodejs\\node.exe',
  'C:\\Users\\jane\\args.mjs',
  'one',
  'two'
]

不論我們如何呼叫 shell 腳本,process.argv 總會以用於執行我們程式碼的 Node.js 二進位檔路徑開頭。接下來是我們的腳本路徑。陣列以傳遞給腳本的實際參數作結。換句話說:腳本的參數總是從索引 2 開始。

因此,我們將我們的腳本變更為如下所示

#!/usr/bin/env node
console.log(process.argv.slice(2));

讓我們嘗試更複雜的參數

% ./args.mjs --str abc --bool home.html main.js
[ '--str', 'abc', '--bool', 'home.html', 'main.js' ]

這些參數包含

使用參數的兩種樣式很常見

寫成 JavaScript 函式呼叫,前一個範例會如下所示(在 JavaScript 中,選項通常放在最後)

argsMjs('home.html', 'main.js', {str: 'abc', bool: false});

16.3 剖析命令列參數

16.3.1 基礎知識

如果我們要讓 parseArgs() 剖析包含參數的陣列,我們首先需要告訴它我們的選項如何運作。讓我們假設我們的腳本有

我們向 parseArgs() 描述這些選項如下

const options = {
  'verbose': {
    type: 'boolean',
    short: 'v',
  },
  'color': {
    type: 'string',
    short: 'c',
  },
  'times': {
    type: 'string',
    short: 't',
  },
};

只要 options 的屬性金鑰是有效的 JavaScript 識別碼,是否要加上引號由您決定。兩者都有優缺點。在本章中,它們總是加上引號。這樣,具有非識別碼名稱的選項(例如 my-new-option)看起來與具有識別碼名稱的選項相同。

options 中的每個項目可以具有下列屬性(透過 TypeScript 類型定義)

type Options = {
  type: 'boolean' | 'string', // required
  short?: string, // optional
  multiple?: boolean, // optional, default `false`
};

下列程式碼使用 parseArgs()options 來剖析帶有參數的陣列

assert.deepEqual(
  parseArgs({options, args: [
    '--verbose', '--color', 'green', '--times', '5'
  ]}),
  {
    values: {__proto__:null,
      verbose: true,
      color: 'green',
      times: '5'
    },
    positionals: []
  }
);

儲存在 .values 中的物件原型為 null。這表示我們可以使用 in 運算子來檢查屬性是否存在,而不用擔心繼承的屬性,例如 .toString

如前所述,數字 5 是 --times 的值,會被視為字串處理。

我們傳遞給 parseArgs() 的物件具有下列 TypeScript 類型

type ParseArgsProps = {
  options?: {[key: string], Options}, // optional, default: {}
  args?: Array<string>, // optional
    // default: process.argv.slice(2)
  strict?: boolean, // optional, default `true`
  allowPositionals?: boolean, // optional, default `false`
};

這是 parseArgs() 結果的類型

type ParseArgsResult = {
  values: {[key: string]: ValuesValue}, // an object
  positionals: Array<string>, // always an Array
};
type ValuesValue = boolean | string | Array<boolean|string>;

使用兩個連字號來指選項的長版本。使用一個連字號來指短版本

assert.deepEqual(
  parseArgs({options, args: ['-v', '-c', 'green']}),
  {
    values: {__proto__:null,
      verbose: true,
      color: 'green',
    },
    positionals: []
  }
);

請注意,.values 包含選項的長名稱。

我們透過剖析與選用參數混合的位置參數來結束此小節

assert.deepEqual(
  parseArgs({
    options,
    allowPositionals: true,
    args: [
      'home.html', '--verbose', 'main.js', '--color', 'red', 'post.md'
    ]
  }),
  {
    values: {__proto__:null,
      verbose: true,
      color: 'red',
    },
    positionals: [
      'home.html', 'main.js', 'post.md'
    ]
  }
);

16.3.2 多次使用選項

如果我們多次使用選項,預設只有最後一次計算。它會覆寫所有先前的發生。

const options = {
  'bool': {
    type: 'boolean',
  },
  'str': {
    type: 'string',
  },
};

assert.deepEqual(
  parseArgs({
    options, args: [
      '--bool', '--bool', '--str', 'yes', '--str', 'no'
    ]
  }),
  {
    values: {__proto__:null,
      bool: true,
      str: 'no'
    },
    positionals: []
  }
);

但是,如果我們在選項定義中將 .multiple 設為 trueparseArgs() 會在陣列中提供我們所有選項值

const options = {
  'bool': {
    type: 'boolean',
    multiple: true,
  },
  'str': {
    type: 'string',
    multiple: true,
  },
};

assert.deepEqual(
  parseArgs({
    options, args: [
      '--bool', '--bool', '--str', 'yes', '--str', 'no'
    ]
  }),
  {
    values: {__proto__:null,
      bool: [ true, true ],
      str: [ 'yes', 'no' ]
    },
    positionals: []
  }
);

16.3.3 更多使用長選項和短選項的方法

考慮下列選項

const options = {
  'verbose': {
    type: 'boolean',
    short: 'v',
  },
  'silent': {
    type: 'boolean',
    short: 's',
  },
  'color': {
    type: 'string',
    short: 'c',
  },
};

以下是使用多個布林值選項的簡潔方法

assert.deepEqual(
  parseArgs({options, args: ['-vs']}),
  {
    values: {__proto__:null,
      verbose: true,
      silent: true,
    },
    positionals: []
  }
);

我們可以直接透過等號附加長字串選項的值。這稱為內嵌值

assert.deepEqual(
  parseArgs({options, args: ['--color=green']}),
  {
    values: {__proto__:null,
      color: 'green'
    },
    positionals: []
  }
);

短選項不能有內嵌值。

16.3.4 引用值

到目前為止,所有選項值和位置值都是單字。如果我們想要使用包含空格的值,我們需要引用它們,使用雙引號或單引號。不過,後者並非所有 shell 都支援。

16.3.4.1 shell 如何解析帶引號的值

要檢查 shell 如何解析帶引號的值,我們再次使用指令碼 args.mjs

#!/usr/bin/env node
console.log(process.argv.slice(2));

在 Unix 上,雙引號和單引號之間的差異如下

以下互動展示了使用雙引號和單引號的選項值

% ./args.mjs --str "two words" --str 'two words'
[ '--str', 'two words', '--str', 'two words' ]

% ./args.mjs --str="two words" --str='two words'
[ '--str=two words', '--str=two words' ]

% ./args.mjs -s "two words" -s 'two words'
[ '-s', 'two words', '-s', 'two words' ]

在 Windows 命令殼層中,單引號在任何方面都不是特殊的

>node args.mjs "say \"hi\"" "\t\n" "%USERNAME%"
[ 'say "hi"', '\\t\\n', 'jane' ]

>node args.mjs 'back slash\' '\t\n' '%USERNAME%'
[ "'back", "slash\\'", "'\\t\\n'", "'jane'" ]

Windows 命令殼層中的帶引號選項值

>node args.mjs --str 'two words' --str "two words"
[ '--str', "'two", "words'", '--str', 'two words' ]

>node args.mjs --str='two words' --str="two words"
[ "--str='two", "words'", '--str=two words' ]

>>node args.mjs -s "two words" -s 'two words'
[ '-s', 'two words', '-s', "'two", "words'" ]

在 Windows PowerShell 中,我們可以使用單引號引號,變數名稱不會在引號內插入,且無法跳脫單引號

> node args.mjs "say `"hi`"" "\t\n" "%USERNAME%"
[ 'say hi', '\\t\\n', '%USERNAME%' ]
> node args.mjs 'backtick`' '\t\n' '%USERNAME%'
[ 'backtick`', '\\t\\n', '%USERNAME%' ]
16.3.4.2 parseArgs() 如何處理帶引號的值

以下是 parseArgs() 處理帶引號值的方式

const options = {
  'times': {
    type: 'string',
    short: 't',
  },
  'color': {
    type: 'string',
    short: 'c',
  },
};

// Quoted external option values
assert.deepEqual(
  parseArgs({
    options,
    args: ['-t', '5 times', '--color', 'light green']
  }),
  {
    values: {__proto__:null,
      times: '5 times',
      color: 'light green',
    },
    positionals: []
  }
);

// Quoted inline option values
assert.deepEqual(
  parseArgs({
    options,
    args: ['--color=light green']
  }),
  {
    values: {__proto__:null,
      color: 'light green',
    },
    positionals: []
  }
);

// Quoted positional values
assert.deepEqual(
  parseArgs({
    options, allowPositionals: true,
    args: ['two words', 'more words']
  }),
  {
    values: {__proto__:null,
    },
    positionals: [ 'two words', 'more words' ]
  }
);

16.3.5 選項終止符

parseArgs() 支援所謂的選項終止符:如果 args 的元素之一是雙破折號 (--),則其餘引數都將視為位置引數。

選項終止符在哪裡需要?有些可執行檔會呼叫其他可執行檔,例如:node 可執行檔。然後可以使用選項終止符將呼叫者的引數與被呼叫者的引數分開。

以下是 parseArgs() 處理選項終止符的方式

const options = {
  'verbose': {
    type: 'boolean',
  },
  'count': {
    type: 'string',
  },
};

assert.deepEqual(
  parseArgs({options, allowPositionals: true,
    args: [
      'how', '--verbose', 'are', '--', '--count', '5', 'you'
    ]
  }),
  {
    values: {__proto__:null,
      verbose: true
    },
    positionals: [ 'how', 'are', '--count', '5', 'you' ]
  }
);

16.3.6 嚴格的 parseArgs()

如果選項 .stricttrue(這是預設值),則在發生以下情況之一時,parseArgs() 會擲回例外

以下程式碼示範了每個案例

const options = {
  'str': {
    type: 'string',
  },
};

// Unknown option name
assert.throws(
  () => parseArgs({
      options,
      args: ['--unknown']
    }),
  {
    name: 'TypeError',
    message: "Unknown option '--unknown'",
  }
);

// Wrong option type (missing value)
assert.throws(
  () => parseArgs({
      options,
      args: ['--str']
    }),
  {
    name: 'TypeError',
    message: "Option '--str <value>' argument missing",
  }
);

// Unallowed positional
assert.throws(
  () => parseArgs({
      options,
      allowPositionals: false, // (the default)
      args: ['posarg']
    }),
  {
    name: 'TypeError',
    message: "Unexpected argument 'posarg'. " +
      "This command does not take positional arguments",
  }
);

16.4 parseArgs 權杖

parseArgs() 分兩個階段處理 args 陣列

如果我們將 config.tokens 設定為 true,我們可以存取令牌。然後,parseArgs() 回傳的物件會包含一個 .tokens 屬性,其中包含令牌。

這些是令牌的屬性

type Token = OptionToken | PositionalToken | OptionTerminatorToken;

interface CommonTokenProperties {
    /** Where in `args` does the token start? */
  index: number;
}

interface OptionToken extends CommonTokenProperties {
  kind: 'option';

  /** Long name of option */
  name: string;

  /** The option name as mentioned in `args` */
  rawName: string;

  /** The option’s value. `undefined` for boolean options. */
  value: string | undefined;

  /** Is the option value specified inline (e.g. --level=5)? */
  inlineValue: boolean | undefined;
}

interface PositionalToken extends CommonTokenProperties {
  kind: 'positional';

  /** The value of the positional, args[token.index] */
  value: string;
}

interface OptionTerminatorToken extends CommonTokenProperties {
  kind: 'option-terminator';
}

16.4.1 令牌範例

作為範例,請考慮下列選項

const options = {
  'bool': {
    type: 'boolean',
    short: 'b',
  },
  'flag': {
    type: 'boolean',
    short: 'f',
  },
  'str': {
    type: 'string',
    short: 's',
  },
};

布林選項的令牌看起來像這樣

assert.deepEqual(
  parseArgs({
    options, tokens: true,
    args: [
      '--bool', '-b', '-bf',
    ]
  }),
  {
    values: {__proto__:null,
      bool: true,
      flag: true,
    },
    positionals: [],
    tokens: [
      {
        kind: 'option',
        name: 'bool',
        rawName: '--bool',
        index: 0,
        value: undefined,
        inlineValue: undefined
      },
      {
        kind: 'option',
        name: 'bool',
        rawName: '-b',
        index: 1,
        value: undefined,
        inlineValue: undefined
      },
      {
        kind: 'option',
        name: 'bool',
        rawName: '-b',
        index: 2,
        value: undefined,
        inlineValue: undefined
      },
      {
        kind: 'option',
        name: 'flag',
        rawName: '-f',
        index: 2,
        value: undefined,
        inlineValue: undefined
      },
    ]
  }
);

請注意,選項 bool 有三個令牌,因為它在 args 中被提及三次。然而,由於解析的第二階段,.valuesbool 只有單一屬性。

在下列範例中,我們將字串選項解析成令牌。.inlineValue 現在有布林值(對於布林選項,它總是 undefined

assert.deepEqual(
  parseArgs({
    options, tokens: true,
    args: [
      '--str', 'yes', '--str=yes', '-s', 'yes',
    ]
  }),
  {
    values: {__proto__:null,
      str: 'yes',
    },
    positionals: [],
    tokens: [
      {
        kind: 'option',
        name: 'str',
        rawName: '--str',
        index: 0,
        value: 'yes',
        inlineValue: false
      },
      {
        kind: 'option',
        name: 'str',
        rawName: '--str',
        index: 2,
        value: 'yes',
        inlineValue: true
      },
      {
        kind: 'option',
        name: 'str',
        rawName: '-s',
        index: 3,
        value: 'yes',
        inlineValue: false
      }
    ]
  }
);

最後,這是解析位置引數和選項終止符的範例

assert.deepEqual(
  parseArgs({
    options, allowPositionals: true, tokens: true,
    args: [
      'command', '--', '--str', 'yes', '--str=yes'
    ]
  }),
  {
    values: {__proto__:null,
    },
    positionals: [ 'command', '--str', 'yes', '--str=yes' ],
    tokens: [
      { kind: 'positional', index: 0, value: 'command' },
      { kind: 'option-terminator', index: 1 },
      { kind: 'positional', index: 2, value: '--str' },
      { kind: 'positional', index: 3, value: 'yes' },
      { kind: 'positional', index: 4, value: '--str=yes' }
    ]
  }
);

16.4.2 使用令牌來實作子指令

預設情況下,parseArgs() 不支援子指令,例如 git clonenpm install。然而,透過令牌,可以相對容易地實作此功能。

這是實作

function parseSubcommand(config) {
  // The subcommand is a positional, allow them
  const {tokens} = parseArgs({
    ...config, tokens: true, allowPositionals: true
  });
  let firstPosToken = tokens.find(({kind}) => kind==='positional');
  if (!firstPosToken) {
    throw new Error('Command name is missing: ' + config.args);
  }

  //----- Command options

  const cmdArgs = config.args.slice(0, firstPosToken.index);
  // Override `config.args`
  const commandResult = parseArgs({
    ...config, args: cmdArgs, tokens: false, allowPositionals: false
  });

  //----- Subcommand

  const subcommandName = firstPosToken.value;

  const subcmdArgs = config.args.slice(firstPosToken.index+1);
  // Override `config.args`
  const subcommandResult = parseArgs({
    ...config, args: subcmdArgs, tokens: false
  });

  return {
    commandResult,
    subcommandName,
    subcommandResult,
  };
}

這是 parseSubcommand() 的實際運作

const options = {
  'log': {
    type: 'string',
  },
  color: {
    type: 'boolean',
  }
};
const args = ['--log', 'all', 'print', '--color', 'file.txt'];
const result = parseSubcommand({options, allowPositionals: true, args});

const pn = obj => Object.setPrototypeOf(obj, null);
assert.deepEqual(
  result,
  {
    commandResult: {
      values: pn({'log': 'all'}),
      positionals: []
    },
    subcommandName: 'print',
    subcommandResult: {
      values: pn({color: true}),
      positionals: ['file.txt']
    }
  }
);