前言
最近正在做一个前端脚手架,这其中就要包含很多 cli 工具来方便开发,之前一直都是在写网站或者客户端小程序之类的,这次是我第一次写命令行上的交互。为了学习一下,所以我没有用社区现在流行常用的 commander.js,而是自己实现了一个简单的命令行工具框架。这个框架可以注册和执行命令,支持命令的嵌套,支持命令的参数解析,支持命令的帮助信息输出。同时还包含了一些常用的命令行开发工具包如字体着色、log 输出、spinner 等。下面来讲讲设计的过程和遇到的一些坑点。
命令行框架设计
如上面这张图所示,命令行工具主要分成 Command Manager
还有 Create
两个部分。
-
命令管理器 (Command Manager) 主要提供 CLI 能力:
- 注册器 (Register):用于注册不同的命令,这些命令被执行器 (Executor) 所调用。
- 执行器 (Executor):负责执行已注册的命令。
- 日志器 (Logger):用于以统一格式输出命令执行的日志信息与用户交互。
-
模板生成器 (Create) 主要提供模板生成能力:
- 预生成回调 (Pre Generate Callback):在生成模板之前执行的回调函数。
- 变量管理器 (Variable Manager):管理项目中的变量。
- 模板管理器 (Template Manager):管理模板的生成过程。
- 后生成回调 (Post Generate Callback):在模板生成之后执行的回调函数。
模板生成器主要负责项目代码层面的快速初始化,标准页面/组件的一键生成等功能。整个流程的步骤可以保存在一个中央仓库中,从中提取最新的 JSON 数据,然后选择并填充模板,插入变量,复制文件并执行生成前和生成后的回调。
本文主要聚焦在 Command Manager
的设计与实现。另外一个部分是参考了 shadcn 的灵感设计来的,属于脚手架的能力,这里不做过多展开。
下面还有几篇文章会讲讲其它的一些附加能力,比如说 logger、spinner 还有框架内置的一些命令。
命令行框架实现
首先我们要定义一下一个命令的组成部分:
- 一个命令会包含一个命令的名称,比如说
create
。 - 这个命令会有一段描述,以方便用户知道这个命令是用来干嘛的。
- 命令可能会传入一些参数,这些参数也会有不同的形式
- 比如说可能是
dew create web
这个web
就是一个参数 - 还有可能是
dew create --name web
这个--name
就是一个参数,并且这个参数还有一个值web
- 另外还有一种可能是
dew create --dry-run
这个--dry-run
就是一个开关参数,它的值应该是 boolean - 上面提到的参数都可能会有简写比如说
dew create -n web
这个-n
就是--name
的简写,它的功能和--name
是一样的
- 比如说可能是
- 命令会有执行的函数,这个函数会接收到参数,然后执行一些操作
命令对象
根据我们上面的定义,就可以简单写出一个 Command 类:
/**
* 命令对象
* @template T 命令行选项
*
* @example
* ```ts
* const command = new Command("test", "test command", {
* name: {
* description: "command name"
* type: "string"
* },
* age: {
* description: "command age"
* type: "number"
* }
* }, async (args) => {
* console.log(args.name, args.age);
* });
*/
export class Command<T extends CommandOptions> {
/**
* 命令名称
*/
#name: string;
/**
* 命令描述
*/
#description: string;
/**
* 命令行选项
*/
#options: T;
/**
* 命令执行回调
*/
#action: (args: InferredArgs<T>) => Promise<void>;
/**
* 子命令
*/
#subCommands?: Command<T>[];
/**
* 命令初始化
* @param name 命令关键词
* @param description 命令描述
* @param options 命令参数选项
* @param action 命令执行函数
*/
constructor(
name: string,
description: string,
options: T,
action: (args: InferredArgs<T>) => Promise<void>
) {
this.#name = name;
this.#description = description;
this.#options = options;
this.#action = action;
}
get name() {
return this.#name;
}
get description() {
return this.#description;
}
get options() {
return this.#options;
}
get action() {
return this.#action;
}
}
值得注意的小设计能够让我们在写 action 回调的时候知道参数是什么以及是什么类型,所以我在这里用了一个泛型,这个泛型扩展了下面的 CommandOptions
类型,它是一个对象,对象的 key 是参数的名称,value 是参数的描述,定义如下:
export type CommandOptions = {
[key: string]: {
// 参数是什么类型的
type: "boolean" | "string" | "number" | "array";
// 参数的描述
description: string;
// 参数的别名
alias?: string;
// 这个参数的值有多少个,比如说 --name web native 这个 name 参数就有两个值
argCount?: number;
};
};
然后通过一个 InferredArgs
类型来推断出参数的类型:
export type InferredArgs<T extends CommandOptions> = {
[K in keyof T]: T[K]["type"] extends "boolean"
? boolean
: T[K]["type"] extends "string"
? string
: T[K]["type"] extends "number"
? number
: T[K]["type"] extends "array"
? string[]
: never;
} & { commandOptionArg: string };
这样我们在定义一个 Command
的 Action 的时候就知道参数是什么类型了,开发体验会更好:
命令管理器
在上面我们定义好了一个命令对象,接下来我们要定义一个命令管理器,这个管理器就是一个管家,负责注册命令,执行命令,输出命令的帮助信息。
管理器对象
下面是一个管理器对象,我们在里面存放所有命令和提取命令:
class CommandManager {
#commands: Map<string, Command<CommandOptions>> = new Map();
#commandPath: Map<string, string> = new Map();
public registerCommand<T extends CommandOptions>(
command: Command<T>,
pluginPath: string
) {
this.#commands.set(command.name, command as Command<CommandOptionsExtends>);
this.#commandPath.set(command.name, pluginPath);
}
get getCommands() {
return this.#commands;
}
public getCommandPath(name: string) {
return this.#commandPath.get(name);
}
public getCommand(name: string) {
return this.#commands.get(name);
}
}
在程序启动的时候,会实例化一个这样的管理器对象,然后 export 出去给其它模块使用,充当一个单例。
命令注册器
命令注册本身的逻辑其实非常简单,就是调用管理器对象的注册方法,然后注册进去,在这里我们可以再封装一个方法来完成这个操作
手动注册
/**
* 注册命令
* @param {Command<T>} program 命令行程序对象
*/
export const registCommand = <T extends CommandOptions>(
program: Command<T>,
pluginPath: string
) => {
if (program.name && program.description && program.options) {
commander.registerCommand(program, pluginPath);
}
};
自动注册
但是为了让用户用起来更加方便,我希望可以自动注册命令,用户只需要通过 npm 的方式安装插件,然后就能自动将插件注册好,具体是下面的流程:
读取 package.json => 拿到插件名称 => 得到插件路径 => 动态加载插件 => 注册插件
读取 package.json
const readPackageJson = async () => {
const packageJsonPath = path.resolve(process.cwd(), "package.json");
if (fs.existsSync(packageJsonPath)) {
const packageJsonContent = await fs.promises.readFile(
packageJsonPath,
"utf-8"
);
try {
return JSON.parse(packageJsonContent);
} catch (error) {
logger.error("Failed to parse package.json:\n", error as string);
return null;
}
}
return null;
};
直接读取本路径下面的 package.json 文件,然后返回一个对象。
得到插件名称
首先我们要知道这边哪些是我们的插件,我们可以约定一个名称,比如在这里的名称就是 @dew/plugin-
开头的,然后我们就可以通过这个名称来找到插件的路径。
const getDewPlugins = (dependencies: Record<string, string>) => {
return Object.keys(dependencies).filter(dep =>
dep.startsWith("@dew/plugin-")
);
};
动态加载插件
通过 import
的方式来动态加载插件,在这里有一个坑点,因为用户使用的时候是通过类似 npm -g 的形式来安装工具的,所以在 node resolve 的时候也会在全局的 node_modules 里面找,所以我们要通过 require.resolve
来找到插件的真正路径。
const importPlugin = async (pluginName: string) => {
const modulePath = require.resolve(pluginName, { paths: [process.cwd()] });
const loadedModule = await import(modulePath).catch(error => {
logger.error(
`Failed to import plugin ${pluginName}:`,
"If target package has not been built yet ?\n",
error
);
return null;
});
return {
loadedModule,
modulePath,
};
};
上面的 path
参数就指定了我们要在当前路径下面找,这样就能找到我们的插件了。同时也可以记录一下插件的路径,方便后面调试的时候知道插件的载入来源。
批量注册插件
在别的模块中都通过 default 的方式导出一个或者多个插件,这样我们就可以根据它有多少个来自动注册插件了。
export const registerPlugins = async (): Promise<void> => {
// 注册内置命令
for (const [_, command] of Object.entries(BuiltIns)) {
registCommand(command as Command<CommandOptionsExtends>, "built-in");
}
// 注册外部插件命令
const packageJson = await readPackageJson();
if (packageJson?.dependencies || packageJson?.devDependencies) {
const dewPlugins = getDewPlugins({
...packageJson.dependencies,
...packageJson.devDependencies,
});
await Promise.allSettled(
dewPlugins.map(async pluginName => {
const moduleInfo = await importPlugin(pluginName).catch(error => {
logger.error(`Failed to import plugin ${pluginName}:`, error);
});
if (!moduleInfo) {
logger.error(`Failed to resolve plugin ${pluginName}`);
return;
}
const program = moduleInfo.loadedModule.default;
if (program.name && program.description && program.options) {
// check if plugin has default export
registCommand(moduleInfo.loadedModule.default, moduleInfo.modulePath);
} else {
for (const command of Object.values(
moduleInfo.loadedModule.default
) as Command<CommandOptions>[]) {
registCommand(command, moduleInfo.modulePath);
}
}
})
);
}
};
通过上面这样的设计,当用户通过 npm 安装插件之后就能自动载入插件,使用起来就更加方便了。
命令执行器
命令执行器根据用户输入的命令,解析命令和命令中包含的参数,通过命令管理器找到命令对象,通过刚刚解析出来的参数执行里面的回调函数。
/**
* 执行指定的命令。
* @param {string} name 命令名称。
* @param {string[]} args 命令行参数数组。
*/
export const exec = (name: string, args: string[]) => {
const command = commander.getCommand(name);
if (!command) {
console.log("Command not found");
return;
}
const commandArgs = argTransformer(args, command.options);
command.action(
commandArgs as unknown as InferredArgs<typeof command.options>
);
};
在这里我们通过 argTransformer
函数来解析命令行参数,这个函数会将命令行参数数组转换为键值对对象,这样我们就能知道用户输入的参数是什么了。由于上面提到的参数条件非常多,所以下面的函数也有很多的 if 条件分支来判断。但是总体也很好理解,可以看看下面的代码。
/**
* 将命令行参数数组转换为键值对对象的函数。
* @param {string[]} args 命令行参数数组,例如:["--name=123", "--age=18"] 或 ["-h"] 或 ["--help"] 或 ["-s", "proxy", "http://127.0.0.1:8888", "--name=123"]。
* @returns 返回一个对象,其中包含了从命令行参数解析出的键值对。
*/
const argTransformer = (args: string[], options: CommandOptions) => {
const result: CommandArgs = {
commandOptionArg: "",
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg.startsWith("--")) {
// 如果参数以 "--" 开头,表示是一个完整的键值对
const [key, value] = arg.split("=");
const keyName = key.slice(2);
if (options[keyName]) {
if (options[keyName].argCount && options[keyName].argCount > 1) {
// indicate that next [argCount] args are values
// e.g. --name=123 456 789 -> {name: [123, 456, 789]}
const values = [];
for (let j = 0; j < options[keyName].argCount; j++) {
values.push(args[++i]);
}
result[keyName] = values;
} else if (value) {
// 如果参数有值,直接使用该值
result[keyName] = value;
} else {
// 如果参数没有值,使用 true
result[keyName] = true;
}
} else {
logger.error(`Unknown option: ${keyName}`);
process.exit(1);
}
} else if (arg.startsWith("-")) {
// 如果参数以 "-" 开头,表示是一个标志或键值对的键
const key = arg.slice(1);
// 检查是否设置了别名,构建一个反向映射
const aliasMap = Object.entries(options).reduce(
(acc, [key, value]) => {
if (value.alias) {
acc[value.alias] = key;
}
return acc;
},
{} as Record<string, string>
);
if (aliasMap[key]) {
const argCount = options[aliasMap[key]].argCount;
if (argCount && argCount > 1) {
// indicate that next [argCount] args are values
// e.g. -n 123 456 789 -> {n: [123, 456, 789]}
const values = [];
for (let j = 0; j < argCount; j++) {
values.push(args[++i]);
}
result[aliasMap[key]] = values;
} else {
if (args[i + 1] && !args[i + 1].startsWith("-")) {
// 如果下一个参数存在且不以 "-" 开头,表示当前键有对应的值
const value = args[i + 1];
result[aliasMap[key]] = value;
i++;
} else {
// 如果下一个参数不存在或以 "-" 开头,表示当前键没有对应的值
result[aliasMap[key]] = true;
}
}
} else {
logger.error(`Unknown option: ${key}`);
process.exit(1);
}
} else {
if (i === 0) {
// 如果第一个参数不是以 "-" 开头,表示是命令名称
result.commandOptionArg = arg;
}
}
}
return result;
};
命令行工具入口
在上面定义好了管理器、注册器、执行器之后,可以建一个 cli 的入口文件,然后在这里面注册命令,然后执行命令。
const args = process.argv.slice(2);
if (!args.length) {
logger.info("使用 help 或 -h 查看帮助");
process.exit(0);
}
(async () => {
registCommand(
new Command("help", "show help info", {}, async () => {
exec("list", args);
}),
"built-in"
);
await registerPlugins();
exec(args[0], args.slice(1));
})();
其中上面的 process.argv
是一个数组,它包含了用户在命令行中输入的所有参数,我们通过 slice(2)
来去掉前面两个参数,这样就能得到用户输入的命令和参数了。
命令帮助生成
命令肯定是需要帮助的,所以我们要在命令行中输入 --help
或者 -h
的时候能够输出命令的帮助信息,这个帮助信息包含了命令的名称、描述、参数的描述等等。这个能力当然可以通过开发者自己在定义 Command
的时候写好,但是我们也可以通过代码来自动生成这个帮助信息,这样开发起来就更加方便了。
export function generateHelpText<T extends CommandOptions>(
command: Command<T>
): string {
let helpText = `Command: ${command.name}\n`;
helpText += `Description: ${command.description}\n\n`;
helpText += "Options:\n";
for (const [key, value] of Object.entries(command.options)) {
helpText += ` ${value.alias ? `-${value.alias}, ` : ""}--${key}\t${value.description}\n`;
}
helpText += " -h, --help\tShow help information\n";
return helpText;
}
上面的函数根据命令对象,获取命令的基本信息,然后根据命令的参数选项来生成带有格式的帮助信息,这样就能在用户输入 --help
的时候输出这个帮助信息了。
总结
本文介绍了一下最近怎么从零开始设计和实现 Node.js 命令行工具框架。从命令对象的定义开始,一步步构建了命令管理器、命令注册器和命令执行器。如果你最近也再写类似的东西,希望这篇文章对你有帮助,之后会继续讲一下怎么利用框架开发插件、框架的扩展功能以及脚手架的设计。