1. 前文回顾
这几篇小作文我们一直讨论 webpack-cli 的优秀实践范式,现在我们先来看看,上午的内容!
上文讨论了 webpack 源码部分的第一个环节,webpack-cli 的启动,webpack 相当于是一把钥匙,用来启动 webpack 这辆大车的钥匙;
本文详细讨论了 webpack-cli 相关作用及部分实现,主要包含以下内容:
-
webpack-cli 的安装及用法;
-
webpack-cli 中的实现模块 webpack.js 的实现原理:
-
2.1 通过 isInstalled 方法检测 webpack-cli 安装情况;
-
2.2 针对安装情况做出不同处理:
-
2.2.1 已安装时则直接调用 webpack-cli;
-
2.2.2 未安装时则有针对性的进行引导,具体的实现重点讲述了
- 1)包管理的检测,如 npm/yarn 等;
- 2)通过 readline 模块创建 创建 REPL 接口征询用户意见;
- 3)用户同意安装后通过创建子进程的方式调用安装命令;
- 4) 最后再得到 webpack-cli 后进行调用;
-
-
今天我们的核心关注点是 webpack 如何实现的动态生成CLI的!
2. webpack-cli 实现
结合上面的 webpack 实现中可以得知,webpack 脚本最后通过 runCli 方法 加载 webpack-cli/bin/cli.js。
下面我们来看 webpack-cli 工作流程:

2.1 webpack-cli/bin/cli.js
cli.js 内部就做了一件事,导入 ../lib/bootstrap 模块,并且执行该模块的导入传入 process.argv 进程参数;
js
#!/usr/bin/env node
const runCLI = require("../lib/bootstrap");
process.title = "webpack";
runCLI(process.argv);
2.2 webpack-cli/lib/bootstrap.js
该模块导出了一个函数 runCLI:
js
const WebpackCLI = require("./webpack-cli");
const runCLI = async (args) => {
const cli = new WebpackCLI();
try {
await cli.run(args);
}
catch (error) {
}
};
module.exports = runCLI;
runCLI 函数内部创建 WebpackCLI 实例 cli,然后调用 cli.run() 方法。run 方法是 WebpackCLI 类型的入口方法。
2.3 webpack-cli/lib/webpack-cli.js
该模块是整个 webpack CLI 界面实现核心部分,这个类型使用 comamnder 包在运行时解析用户输入创建并执行相应命令。
js
class WebpackCLI {
constructor () {}
run (args, parsOptions) {}
}
module.exports = WebpackCLI
webpack-cli 内置了以下四个命令,这些命令开箱即用:
- build (default):运行 webpack(默认命令,可用输出文件)
- watch:运行 webpack 并且监听文件变化
- version:显示已安装的 package 以及子 package 的版本
- help:列出命令行可用的基础命令和 flag
此外,剩下的命令,webpack-cli 做了"特殊处理",即 webpack-cli 里面的 "exteralBuildInCommands" 即 【外置内建命令】,这些命令包括:
- serve:运行 webpack 开发服务器
- info:输出你的系统信息
- init:用于初始化一个新的 webpack 项目
- loader:初始化一个 loader
- plugin:初始化一个插件
- migrate:这个命令文档未列出[npm]
- configtest:校验 webpack 配置
以上命令在 webpack-cli 内部称为 "knownCommands "【已知命令】
2.3.1 contructor
构造函数内部通过 commander 创建了 program 对象并挂在到 WebpackCLI 实例之上:
js
constructor() {
this.colors = this.createColors();
this.logger = this.getLogger();
// Initialize program
this.program = program;
this.program.name("webpack");
this.program.configureOutput({
writeErr: this.logger.error,
outputError: (str, write) => write(`Error: ${this.capitalizeFirstLetter(str.replace(/^error:/, "").trim())}`),
});
}
2.3.2 run 方法
run 方法是 WebpackCLI 的主入口
1. exitOverride 改写退出
这是由于 comander 在声明式的命令行有一些默认的退出机制,比如没有找到命令等情况,但是在 webpack-cli 这种生成式的 CLI 中,有些命令可能是运行时生成的,所以不能直接退出,需要做一些拦截动作,然后自定义退出过程。
js
this.program.exitOverride(async (error) => {....})
2. 注册 color/no-color options
js
this.program.option("--color", "Enable colors on console.");
this.program.on("option:color", function () {
// @ts-expect-error shadowing 'this' is intended
const { color } = this.opts();
cli.isColorSupportChanged = color;
cli.colors = cli.createColors(color);
});
this.program.option("--no-color", "Disable colors on console.");
this.program.on("option:no-color", function () {
// @ts-expect-error shadowing 'this' is intended
const { color } = this.opts();
cli.isColorSupportChanged = color;
cli.colors = cli.createColors(color);
});
让 webpack 命令行的输出五颜六色的,这些不作为重点讨论!
3. 注册 version option
js
const outputVersion = async (options) => {})
this.program.option("-v, --version", "Output the version number of 'webpack', 'webpack-cli' and 'webpack-dev-server' and commands.");
这里有个 outputVersion 方法,该方法内部输出 webpack 包的版本信息;
4. 处理 help option
注意,webpack-cli 同样静默了 commander 默认的 help 命令支持;
.addHelpCommand()
A help command is added by default if your command has subcommands. You can explicitly turn on or off the implicit help command with .addHelpCommand() and .addHelpCommand(false).
js
this.program.helpOption(false);
this.program.addHelpCommand(false);
this.program.option("-h, --help [verbose]", "Display help for commands and options.");
在稍后的生成式命令中,webpack-cli 自己处理 help 命令具体动作。
5. 注册 action handler
js
this.program.action(async (options, program) => {})
action handler 是 webpack-cli 生成式 CLI 的大脑,在 action handler 内部主要做了以下工作:
5.1 解析进程参数获取 operands, options
js
// Command and options
const { operands, unknown } = this.program.parseOptions(program.args);
const defaultCommandToRun = getCommandName(buildCommandOptions.name);
const hasOperand = typeof operands[0] !== "undefined";
const operand = hasOperand ? operands[0] : defaultCommandToRun;
5.2 判断是否是 help
判断如果是 --help 及相关 help 语法则调用前文注册的 outputHelp 方法输出帮助信息!
js
const isHelpOption = typeof options.help !== "undefined";
const isHelpCommandSyntax = isCommand(operand, helpCommandOptions);
if (isHelpOption || isHelpCommandSyntax) {
await outputHelp(optionsForHelp, isVerbose, isHelpCommandSyntax, program);
}
5.3 判断是否是 version
与上面 help 类似,如果是 --version 类似语法,则输出 version 相关信息
js
const isVersionOption = typeof options.version !== "undefined";
const isVersionCommandSyntax = isCommand(operand, versionCommandOptions);
if (isVersionOption || isVersionCommandSyntax) {
await outputVersion(optionsForVersion);
}
5.4 处理非 help 或 version 的语法
js
let commandToRun = operand;
let commandOperands = operands.slice(1);
operand 在前面判断过,如果没有传递则默认使用 build 命令:
js
const defaultCommandToRun = getCommandName(buildCommandOptions.name);
const operand = hasOperand ? operands[0] : defaultCommandToRun;
commandOperands 则是webpack 子命令的操作数;
5.5 判断 commandToRun 是否为已知命令
所谓已知命令就是前面提到的 "knownCommands",如果是则直接进行加载并执行的的动作
js
if (isKnownCommand(commandToRun)) {
await loadCommandByName(commandToRun, true);
}
loadingCommandByName 方法用于加载并创建命令,然后执行执行命令,该方法将外部传入的已知命名分为以下四种情况处理:
- commandToRun 是 build 或者 watch 命令
- commandToRun 是 help 命令
- commandToRun 是 version 命令
- commandToRun 是 externalBuiltIn 命令
js
const loadCommandByName = async (commandName, allowToInstall = false) => {
const isBuildCommandUsed = isCommand(commandName, buildCommandOptions);
const isWatchCommandUsed = isCommand(commandName, watchCommandOptions);
if (isBuildCommandUsed || isWatchCommandUsed) {
// 处理 webpack build/watch
} else if (isCommand(commandName, helpCommandOptions)) {
// 处理 help
this.makeCommand(helpCommandOptions, [], () => { });
} else if (isCommand(commandName, versionCommandOptions)) {
// 处理 version
this.makeCommand(versionCommandOptions, [], () => { });
} else {
// 处理 externalBuiltInCommand loading
}
};
前三种直接调用 WebpackCLI.prototype.makeCommand 创建本次要运行的子命令(详情见下面 4.3.3 makeCommand);makeCommand 结束后,需要运行的命令就生成,静待触发。
这里以 webpack build/watch 为例看下:
js
this.makeCommand(isBuildCommandUsed ? buildCommandOptions : watchCommandOptions, async () => {
// 这个函数是 webpack 运行所支持的 options
this.webpack = await this.loadWebpack(); // 加载 webpack 包
return isWatchCommandUsed
? this.getBuiltInOptions().filter((option) => option.name !== "watch")
: this.getBuiltInOptions();
}, async (entries, options) => {
// 这个就是 webpack build/watch 的 action handler 函数
// 当用户执行 npx webpack watch 就会执行这个命令
if (entries.length > 0) {
options.entry = [...entries, ...(options.entry || [])];
}
await this.runWebpack(options, isWatchCommandUsed);
});
除了上面的三种情况快,最后一种就是 externalBuiltInCommand 加载过程。这个名字着实让人很迷惑,为什么叫"外置",还"内建"?
所谓外置,是因为 webpack-cli 这个包只包含了基础的 build/watch/help/version 的实现,剩下命令的实现被拆分到了其他的包中。
对于webpack-cli 来说,命令确实是支持了不额外扩展,这所谓"内建",但是真真正正实现这个命令的脚本文件在另一个依赖包中,这就是"外置"。
js
const builtInExternalCommandInfo = externalBuiltInCommandsInfo.find((externalBuiltInCommandInfo) => getCommandName(externalBuiltInCommandInfo.name) === commandName ||
(Array.isArray(externalBuiltInCommandInfo.alias)
? externalBuiltInCommandInfo.alias.includes(commandName)
: externalBuiltInCommandInfo.alias === commandName));
let pkg;
if (builtInExternalCommandInfo) {
({ pkg } = builtInExternalCommandInfo);
} else {
pkg = commandName;
}
if (pkg !== "webpack-cli" && !this.checkPackageExists(pkg)) {
if (!allowToInstall) {
return;
}
pkg = await this.doInstall(pkg, {
preMessage: () => {
this.logger.error(`For using this command you need to install: '${this.colors.green(pkg)}' package.`);
},
});
}
let loadedCommand;
try {
loadedCommand = await this.tryRequireThenImport(pkg, false);
} catch (error) {
// Ignore, command is not installed
return;
}
let command;
try {
command = new loadedCommand();
await command.apply(this);
} catch (error) {
this.logger.error(`Unable to load '${pkg}' command`);
this.logger.error(error);
process.exit(2);
}
5.6 处理未知命令
前文中介绍过 webpack 内置了 11 个命令,除此之外的都算作未知命令。处理未知命令有两种情况:
5.6.1 entry 语法
webpack CLI 支持 entry 语法:
shell
$ npx webpack <entry> --output-path <output-path>
处理 webpack enry 语法时首先检测传入的 入口文件是否存在,若存在则按照webpack 的默认命令 build 进行加载。
5.6.2 错误命令
如果是未知命令切不是入口语法的情况下,webpack CLI 认定我们的输入有误,CLI 此时会尝试查找与输入单词最接近的命令并提示到命令行;
webpack-cli 内部使用 fastest-levenshtein 找到与输入最接近的命令;
js
this.logger.error(`Unknown command or entry '${operand}'`);
const levenshtein = require("fastest-levenshtein"); // 这个库用于计算两个词之间的差别
const found = knownCommands.find((commandOptions) => levenshtein.distance(operand, getCommandName(commandOptions.name)) < 3);
if (found) {
this.logger.error(`Did you mean '${getCommandName(found.name)}' (alias '${Array.isArray(found.alias) ? found.alias.join(", ") : found.alias}')?`);
}
this.logger.error("Run 'webpack --help' to see available commands and options");
process.exit(2);
5.7 调用 program.parseAsyanc 执行新创建的命令
js
await this.program.parseAsync([commandToRun, ...commandOperands, ...unknown], {
from: "user",
});
下面我们简单了解一下 webpack-cli 用于动态生成命令行的方法 makeCommand 以及 cli 内部自动安装的 doInstall 方法!
2.3.3 makeCommand
1.签名
- commandOptions:创建命令所需 option
- options:命令执行所需 options
- action:处理命令的 action handler
2.函数工作流
- 判断是否是已经加载过的命令,若已加载则不再 make
- 调用 program.comman() 注册新的子命令
- 注册 command.description() 描述信息
- 注册 command.usage() 用法信息
- 注册 command.alias() 别名信息
- 检查命令的依赖包的安装信息
- 为新增的 command 注册传入的 options
- 最后为新 command 注册 aciton handler
js
async
makeCommand(commandOptions, options, action)
{
// 校验是否已经 make 过
const alreadyLoaded = this.program.commands.find((command) => command.name() === commandOptions.name.split(" ")[0] ||
command.aliases().includes(commandOptions.alias));
if (alreadyLoaded) {
// 已经 make 过了终止
return;
}
// 注册新命令
const command = this.program.command(commandOptions.name, {
noHelp: commandOptions.noHelp,
hidden: commandOptions.hidden,
isDefault: commandOptions.isDefault,
});
// 注册 options
if (commandOptions.description) {
command.description(commandOptions.description, commandOptions.argsDescription);
}
// 注册 usase
if (commandOptions.usage) {
command.usage(commandOptions.usage);
}
// 注册别名
if (Array.isArray(commandOptions.alias)) {
command.aliases(commandOptions.alias);
} else {
command.alias(commandOptions.alias);
}
if (commandOptions.pkg) {
command.pkg = commandOptions.pkg;
} else {
command.pkg = "webpack-cli";
}
const { forHelp } = this.program;
let allDependenciesInstalled = true;
// 检查依赖并安装缺失依赖
if (commandOptions.dependencies && commandOptions.dependencies.length > 0) {
for (const dependency of commandOptions.dependencies) {
// 校验依赖是否在 webpack 目录下存在
const isPkgExist = this.checkPackageExists(dependency);
if (isPkgExist) {
continue;
} else if (!isPkgExist && forHelp) {
allDependenciesInstalled = false;
continue;
}
let skipInstallation = false;
// Allow to use `./path/to/webpack.js` outside `node_modules`
if (dependency === WEBPACK_PACKAGE && fs.existsSync(WEBPACK_PACKAGE)) {
skipInstallation = true;
}
// Allow to use `./path/to/webpack-dev-server.js` outside `node_modules`
if (dependency === WEBPACK_DEV_SERVER_PACKAGE && fs.existsSync(WEBPACK_PACKAGE)) {
skipInstallation = true;
}
if (skipInstallation) {
continue;
}
// 自动安装缺失的依赖
await this.doInstall(dependency, {
preMessage: () => { 输出警告信息 },
});
}
}
if (options) {
// 注册 options
}
// 注册 action handler
command.action(action);
return command;
}
2.3.4 doInstall
doInstall 方法和前面的 webpack 脚本引导安装 webpack-cli 的实现思路异曲同工,不再展开,大致工作如下:
- 获取包管理器
- 创建 REPL 引导用户输入
- 创建子进程执行安装命令
js
async
doInstall(packageName, options = {})
{
// 获取包管理器i
const packageManager = this.getDefaultPackageManager();
if (!packageManager) {
this.logger.error("Can't find package manager");
process.exit(2);
}
if (options.preMessage) {
options.preMessage();
}
// 创建 REPL
const prompt = ({ message, defaultResponse, stream }) => {
const readline = require("readline");
const rl = readline.createInterface({
input: process.stdin,
output: stream,
});
return new Promise((resolve) => {
rl.question(`${message} `, (answer) => {
// Close the stream
rl.close();
const response = (answer || defaultResponse).toLowerCase();
// Resolve with the input response
if (response === "y" || response === "yes") {
resolve(true);
} else {
resolve(false);
}
});
});
};
// yarn uses 'add' command, rest npm and pnpm both use 'install'
const commandArguments = [packageManager === "yarn" ? "add" : "install", "-D", packageName];
const commandToBeRun = `${packageManager} ${commandArguments.join(" ")}`;
let needInstall;
try {
needInstall = await prompt({
message: `[webpack-cli] Would you like to install '${this.colors.green(packageName)}' package? (That will run '${this.colors.green(commandToBeRun)}') (${this.colors.yellow("Y/n")})`,
defaultResponse: "Y",
stream: process.stderr,
});
} catch (error) {
this.logger.error(error);
process.exit(error);
}
if (needInstall) {
// 子进程执行安装命令
const { sync } = require("cross-spawn");
try {
sync(packageManager, commandArguments, { stdio: "inherit" });
} catch (error) {
this.logger.error(error);
process.exit(2);
}
return packageName;
}
process.exit(2);
}
3.总结
本文是 webpack 启动过车有关 webpack-cli 这个模块的实现部分,本文主要做了以下工作:
- 有关 npm 注册 webpack bin 命令的过程------package.json.bin 字段,全局安装时可以全局调用;
- webpack-cli 提供的命令类型------内建命令+外置内建命令;
- WebpackCLI 构造函数的工作 ------ 通过 Commander 初始化命令解析程序 this.program;
- 最走进入到 WebpackCLI 的入口方法 WebpackCLI.prototype.run 方法,该方法主要做了以下工作:
- 4.1 exitOverride 改写退出
- 4.2 注册 color/no-color options
- 4.3 注册 version option
- 4.4 处理 help option
- 4.5 注册 action handler:
- 1)解析进程参数获取 operands, options
- 2)判断是否是 help 命令;
- 3)判断是否是 version 命令;
- 4)处理非 help 或 version 的语法
- 判断 commandToRun 是否为已知命令;
- 处理未知命令:特殊情况 entry 语法,否则为错误命令;
- 调用 program.parseAsyanc 执行新创建的命令;
- 我们还讨论另一个重要方法 makeCommand 方法:
*- 判断是否是已经加载过的命令,若已加载则不再 make
-
- 调用 program.comman() 注册新的子命令
-
- 注册 command.description() 描述信息
-
- 注册 command.usage() 用法信息
-
- 注册 command.alias() 别名信息
-
- 检查命令的依赖包的安装信息
-
- 为新增的 command 注册传入的 options
-
- 最后为新 command 注册 aciton handler