node 命令行工具

示例

简单的命令行工具一般按照以下流程编写:

  1. package.json 文件中添加 bin 属性,指定命令的名称,如 “bin”: { “create-app”: “bin/create-app.js” }。
  2. bin/create-app.js 文件中添加 #!/usr/bin/env node,指定这是一个使用 node 执行的命令行工具。
  3. 使用 commander.js 编写 bin/create-app.js 脚本内容。
  4. 通过 npm link 或 yarn link 将软件包链接到全局空间,即可以调用 create-app directory 命令了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#!/usr/bin/env node
const program = require('commander');
const inquirer = require('inquirer');

program
.command('create-app <directory>')
.action((directory) => {
inquirer.prompt([{
type: 'list',
name: 'type',
message: 'which framework do you want to use?',
choices: ['react', 'vue', 'angular']
}, {
type: 'input',
name: 'name',
message: "what is your app's name?",
default: directory
}, {
type: 'input',
name: 'version',
message: "what is your app's version?",
default: 'daily/0.0.1'
}, {
type: 'input',
name: 'description',
message: "what is your app's description?"
}]).then(answers => {
// 创建前端项目
})
})
.parse(process.argv);

命令行工具

适用于编写命令行工具的模块有:

  1. commander.js 编写 node 命令。
  2. yargs 编写 node 命令。
  3. common-bin 在 yargs 基础上,以类语法形式编写 node 命令。
  4. Inquirer.js 编写终端交互式面板。
  5. enquirer 编写终端交互式面板。

概要地讲,commander.js 首先注册命令行参数处理规则,随后通过甄别用户输入(node 环境中的 progress.argv),再从已注册的处理规则中选取其一并应用之。

命令行参数通常被抽象称为子命令、选项。选项部分功能包含设置短标识、默认值、描述,特殊选项为版本号信息。子命令是一类处理操作 action handler 的抽象,其功能包含设置必选参数、可选参数、选项、描述等。选项、子命令构成 help 内容提示。选项和命令是对常规如 git push –force 命令的抽象。更为灵活的是通过 commander.js、yargs 解析用户输入,由开发者决定如何使用这些参数。当然,直接对 progress.argv 作处理也是可选的方案。这是 commander.js 模块所提供的核心功能。

commander.js 本质是非交互式的,在用户输入完成后调用 commander.js 模块进行处理,无需监听键盘事件。Inquirer.js、enquirer 是交互式的,需要监听用户侧的键盘输入,以实现单选、多选、密码等交互动作。它们借助 readline 模块处理标准输入 process.stdin、标准输出 process.stdout。

以下仅介绍 commander.js、Inquirer.js 的实现,因为 yargs、common-bin 与 commander.js 功能相同,enquirer 与 Inquirer.js 功能相同。

commander.js

commander.js 抽象了 Commander 命令模型、Option 选项模型两个类。为使 –help 操作能打印父命令包含的子命令信息,command 实例持有 parent 属性指向父命令,commands 属性存储下属命令,以及 _noHelp 等属性限定打印的帮助信息内容。一个 command 实例可以分为三阶段:注册阶段、执行阶段、打印帮助信息阶段。这里仅说明注册阶段、执行阶段的核心方法。

注册阶段主要方法

  • command、addCommand:注册命令。command 方法的参数为命令行输入模板,如 ‘clone [destination]’,返回值为新注册的子命令;addCommand 方法的参数为 Commander 实例,返回值为父命令。
  • action:注册命令的处理操作 action handler。命令是可执行的,除了注册处理操作以外,还可以通过 command 方法指定执行文件 _executableFile(默认为工程目录中的 program-command 文件)。
  • _parseExpectedArgs:用于为命令解析必选、可选参数,存入 Commander 实例的 _args 属性中。
  • option、requiredOption:用于为命令添加选项,Option 实例,存入 Commander 实例的 options 属性中。选项同样可以用 <>、[] 指定其为必选还是可选选项。选项可设置短标识、描述、默认值、选项值的匹配函数或正则。选项的值通过事件处理函数设置。特殊的,–no- 起始的选项会设置默认值为 true。

执行阶段主要方法

  • parse、parseAsync: 解析用户输入,随后选用命令并执行。命令行输入分为三种情况,库本身通过 npm link 注册可调用命令、或使用 node 调用可执行性模块、或在 electron 环境下执行。其处理流程包含:通过 parseOptions 方法解析选项;找到匹配的命令并执行,或打印命令的帮助信息。执行命令有两种,一种为通过 action 方法注册了可执行函数,另一种通过 _executableFile 指定了可执行文件,可参阅源码中的 _parseCommand_executeSubCommand 部分。

commander.arguments 方法游离在主流程之外,其目的在于解析命令行输入,由开发者自行决定如何处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const program = require('commander');

// 1. action handler
program
.command('rm <dir>')
.option('-r, --recursive', 'Remove recursively')
.action(function (dir, cmdObj) {
console.log('remove ' + dir + (cmdObj.recursive ? ' recursively' : ''))
})
.parse(process.argv);

// 2. executable file
program
.command('install [name]', 'install one or more packages')
.command('search [query]', 'search with optional query')
.command('update', 'update installed packages', {executableFile: 'myUpdateSubCommand'})
.command('list', 'list packages installed', {isDefault: true})
.parse(process.argv);

// 3. arguments
program
.version('0.1.0')
.arguments('<cmd> [env]')
.action(function (cmd, env) {
cmdValue = cmd;
envValue = env;
})
.parse(process.argv);

Inquirer.js

作为一个使用 leran 打造的工程,Inquirer.js 由如下模块构成:

在传统模式中,Inquirer.js 通过 StateManager 制作 input 等模块。有趣的是,Inquirer.js 像 react 一样实现了 hooks 机制,input 等模块也改由 hooks 实现了。

StateManager

StateManager 类命令行界面交互及渲染逻辑。借助 readline 监听命令行输入,即键盘事件(回车、返回作为单次交互的终止符)。在逻辑上,它首先会根据初始化输入渲染命令行输出;然后通过监听键盘事件,它会把命令行输入存入内部状态中;同时在变更内部状态期间,它会重新渲染输出;直到用户点击回车或返回,交互行为才会宣告结束,它会驱动执行最终回调。在以上过程中,用户侧的键盘输入并不会直接转化成命令行输出,这借助 mute-stream 模块使输出面板变得静默(不会即时响应用户输入),而需要经由状态值变更来驱动输出面板的重绘。

关于交互事件的监听,readline 模块提供了 readline.createInterface 方法用于创建 readline.Interface 实例(以 rl 指代)。通过 rl.on(‘line’, handler) 可以监听 line 事件;通过 rl.input.on(‘keypress’) 可以监听键盘事件(当按键为回车或返回键时,这个事件同时会触发 line 事件绑定函数)。关于输出面板的渲染,Inquirer.js 借助 readline.output.write 渲染输出面板;readline.setPrompt 设置提示。在 screen-manager 模块中,Inquirer.js 首先会基于待渲染内容调用 readline.setPrompt 设置提示,然后基于 cli-width 模块为渲染内容分行,再行调用 readline.output.write 绘制输出面板,随后基于 ansi-escapes 模块调整光标的位置。

对于 input、select 等模块的交互和渲染差异,Inquirer.js 基于不同的 StateManager 实例呈现多态。在创建 StateManager 实例过程中,它允许 input 模块注册 onKeypress、onLine 等钩子,以对命令行输入作出不同的响应;同时设置 render 状态渲染函数,以便在命令行输出中绘制不同的内容。input 等模块提供的状态渲染函数仅需要将状态值转变为待渲染的内容,由 screen-manager 模块进行绘制。

以 input 模块为例,单个 StateManager 实例的总执行流程为:

  1. [内部]input 模块通过 createPrompt 注册 config.onKeypress 等配置,render 状态渲染函数。
  2. [调用者]通过 input 接口设定 initialState 初始状态、最终回调 cb,[内部]基于初始状态渲染命令行输出面板。
  3. [内部]StateManager 实例内监听键盘行为,基于状态变更重绘输出面板。
  4. [调用者]点击 enter 按键,[内部]StateManager 实例驱动执行最终回调 cb,[调用者]消费最终状态。

以下是 StateManager 摘要性源码(剔除了状态值校验、异步逻辑等):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
class StateManager {
constructor(configFactory, initialState, render) {
this.config = _.isFunction(configFactory) ? configFactory(this.rl) :configFactory;

this.initialState = initialState;
this.render = render;
this.currentState = {
loadingIncrement: 0,
value: '',
status: 'idle'
};

const input = process.stdin;
const output = new MuteStream();
output.pipe(process.stdout);

// http://nodejs.cn/api/readline.html#readline_readline_createinterface_options
this.rl = readline.createInterface({
terminal: true,
input,
output
});
this.screen = new ScreenManager(this.rl);
}

async execute(cb) {
let { message } = this.getState();
this.cb = cb;

this.setState({ message, status: 'idle' });

this.rl.input.on('keypress', this.onKeypress);
this.rl.on('line', this.handleLineEvent);
}

// 将键盘输入记录到 state 中,并触发 onKeypress
// enter、return 按键由 line 事件处理
onKeypress(value, key) {
const { onKeypress = _.noop } = this.config;
if (key.name === 'enter' || key.name === 'return') return;

this.setState({ value: this.rl.line, error: null });
onKeypress(this.rl.line, key, this.getState(), this.setState);
}

// enter、return 按键,触发 onLine 完成 submit 等操作
handleLineEvent() {
const { onLine = defaultOnLine } = this.config;
onLine(this.getState(), {
submit: this.onSubmit,
setState: this.setState
});
}

// 剔除对 value 的校验、filter 处理后代码
async onSubmit() {
const state = this.getState();
const { mapStateToValue = defaultMapStateToValue } = this.config;
let value = mapStateToValue(state);

this.rl.pause();
// 状态值清空,解绑事件,执行 this.cb 回调
this.onDone(value);
this.rl.resume();
}

setState(partialState) {
this.currentState = Object.assign({}, this.currentState, partialState);
this.onChange(this.getState());
}

// 状态变更时,重绘输出
onChange(state) {
const { status, message, value, transformer } = this.getState();

let error;
if (state.error) {
error = `${chalk.red('>>')} ${state.error}`;
}

const renderState = Object.assign(
{
prefix: this.getPrefix()
},
state,
{
// Only pass message down if it's a string. Otherwise we're still in init state
message: _.isFunction(message) ? 'Loading...' : message,
value: transformer(value, { isFinal: status === 'done' }),
validate: undefined,
filter: undefined,
transformer: undefined
}
);
this.screen.render(this.render(renderState, this.config), error);
}
}

const createPrompt = (config, render) => {
const run = initialState =>
new Promise(resolve => {
const prompt = new StateManager(config, initialState, render);
prompt.execute(resolve);
});

run.render = render;
run.config = config;

return run;
};

hooks

有趣的是,hooks 包括 useState、useEffect、useRef、useKeypress 等。其实现机制是创建一个 readline.Interface 实例对交互行为进行监听,以变更 state,并触发重绘流程,执行 effect 等。input 等基于 hooks 制作的模块主要在于变更 state,基于 state 获取输出面板上的待渲染内容。以下是 hooks 部分源码及 confirm 模块对 hooks 的应用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
let sessionRl;
let hooks = [];// 存储 hooks
let hooksCleanup = [];// 存储 hooks 清理函数
let index = 0;
let handleChange = () => {};

// 清理 hooks
const cleanupHook = index => { /* ... */ };

exports.useState = defaultValue => {
const _idx = index;
const value = _idx in hooks ? hooks[_idx] : defaultValue;

index++;

return [
value,
newValue => {
hooks[_idx] = newValue;
handleChange();// 驱动重绘
}
];
};

exports.useKeypress = userHandler => {
const _idx = index;
const prevHandler = hooks[_idx];
const handler = (input, event) => {
userHandler(event, sessionRl);
};

if (prevHandler !== handler) {
cleanupHook(_idx);

sessionRl.input.on('keypress', handler);
hooks[_idx] = handler;
hooksCleanup[_idx] = () => {
sessionRl.input.removeListener('keypress', handler);
};
}

index++;
};

exports.useEffect = (cb, depArray) => {
const _idx = index;

const oldDeps = hooks[_idx];
let hasChanged = true;
if (oldDeps) {
hasChanged = depArray.some((dep, i) => !Object.is(dep, oldDeps[i]));
}
if (hasChanged) {
cleanupHook(_idx);
hooksCleanup[_idx] = cb();
}
hooks[_idx] = depArray;

index++;
};

exports.useRef = val => {
return exports.useState({ current: val })[0];
};

exports.createPrompt = view => {
return options => {
const input = process.stdin;
const output = new MuteStream();
output.pipe(process.stdout);
const rl = readline.createInterface({
terminal: true,
input,
output
});
const screen = new ScreenManager(rl);

return new Promise((resolve, reject) => {
sessionRl = rl;

const done = value => {
let len = cleanupHook.length;
while (len--) {
cleanupHook(len);
}
screen.done();

hooks = [];
index = 0;
sessionRl = undefined;

resolve(value);
};

hooks = [];
const workLoop = config => {
index = 0;
handleChange = () => workLoop(config);// 将最新的状态值交给 handleChange
screen.render(...[view(config, done)].flat().filter(Boolean));
};

// getPromptConfig 用于将 options 用户配置项转换为 config
getPromptConfig(options).then(workLoop, reject);
});
};
};

// confirm 模块
module.exports = createPrompt((config, done) => {
const [status, setStatus] = useState('pending');
const [value, setValue] = useState('');
const prefix = usePrefix();

// 监听键盘事件
useKeypress((key, rl) => {
if (isEnterKey(key)) {
const answer = value ? /^y(es)?/i.test(value) : config.default !== false;
setValue(answer ? 'yes' : 'no');
setStatus('done');
done(answer);
} else {
setValue(rl.line);
}
});

// 重新计算熏染内容
let formattedValue = value;
let defaultValue = '';
if (status === 'done') {
formattedValue = chalk.cyan(value ? 'yes' : 'no');
} else {
defaultValue = chalk.dim(config.default === false ? ' (y/N)' : ' (Y/n)');
}

const message = chalk.bold(config.message);
return `${prefix} ${message}${defaultValue} ${formattedValue}`;
});

常用工具

shelljs 用于执行 shell 脚本,常用如执行 git 操作。
rimraf 移除文件夹或文件。
chalk 在终端上带色彩打印内容。