likes
comments
collection
share

“多快好省哪一个?”,webpack-cli:“我都要!”

作者站长头像
站长
· 阅读数 4

1. 前文回顾

这几篇小作文我们一直讨论 webpack-cli 的优秀实践范式,现在我们先来看看,上午的内容!

上文讨论了 webpack 源码部分的第一个环节,webpack-cli 的启动,webpack 相当于是一把钥匙,用来启动 webpack 这辆大车的钥匙;

本文详细讨论了 webpack-cli 相关作用及部分实现,主要包含以下内容:

  1. webpack-cli 的安装及用法;

  2. 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 工作流程:

“多快好省哪一个?”,webpack-cli:“我都要!”

2.1 webpack-cli/bin/cli.js

cli.js 内部就做了一件事,导入 ../lib/bootstrap 模块,并且执行该模块的导入传入 process.argv 进程参数;

#!/usr/bin/env node  
const runCLI = require("../lib/bootstrap");  
  
process.title = "webpack";  
  
runCLI(process.argv);  

2.2 webpack-cli/lib/bootstrap.js

该模块导出了一个函数 runCLI:

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 包在运行时解析用户输入创建并执行相应命令。

class WebpackCLI {  
constructor () {}  
run (args, parsOptions) {}  
}  
module.exports = WebpackCLI  

webpack-cli 内置了以下四个命令,这些命令开箱即用:

  1. build (default):运行 webpack(默认命令,可用输出文件)
  2. watch:运行 webpack 并且监听文件变化
  3. version:显示已安装的 package 以及子 package 的版本
  4. 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 实例之上:

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 中,有些命令可能是运行时生成的,所以不能直接退出,需要做一些拦截动作,然后自定义退出过程。

this.program.exitOverride(async (error) => {....})  
2. 注册 color/no-color options
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
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).

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
this.program.action(async (options, program) => {})  

action handler 是 webpack-cli 生成式 CLI 的大脑,在 action handler 内部主要做了以下工作:

5.1 解析进程参数获取 operands, options
// 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 方法输出帮助信息!

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 相关信息

const isVersionOption = typeof options.version !== "undefined";  
const isVersionCommandSyntax = isCommand(operand, versionCommandOptions);  
if (isVersionOption || isVersionCommandSyntax) {  
await outputVersion(optionsForVersion);  
}  
5.4 处理非 help 或 version 的语法
let commandToRun = operand;  
let commandOperands = operands.slice(1);  

operand 在前面判断过,如果没有传递则默认使用 build 命令:

const defaultCommandToRun = getCommandName(buildCommandOptions.name);  
const operand = hasOperand ? operands[0] : defaultCommandToRun;  

commandOperands 则是webpack 子命令的操作数;

5.5 判断 commandToRun 是否为已知命令

所谓已知命令就是前面提到的 "knownCommands",如果是则直接进行加载并执行的的动作

if (isKnownCommand(commandToRun)) {  
await loadCommandByName(commandToRun, true);  
}  

loadingCommandByName 方法用于加载并创建命令,然后执行执行命令,该方法将外部传入的已知命名分为以下四种情况处理:

  • commandToRun 是 build 或者 watch 命令
  • commandToRun 是 help 命令
  • commandToRun 是 version 命令
  • commandToRun 是 externalBuiltIn 命令
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 为例看下:

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 来说,命令确实是支持了不额外扩展,这所谓“内建”,但是真真正正实现这个命令的脚本文件在另一个依赖包中,这就是“外置”。

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 语法:

$ npx webpack <entry> --output-path <output-path>  

处理 webpack enry 语法时首先检测传入的 入口文件是否存在,若存在则按照webpack 的默认命令 build 进行加载。

5.6.2 错误命令

如果是未知命令切不是入口语法的情况下,webpack CLI 认定我们的输入有误,CLI 此时会尝试查找与输入单词最接近的命令并提示到命令行; webpack-cli 内部使用 fastest-levenshtein 找到与输入最接近的命令;

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 执行新创建的命令
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.函数工作流
  1. 判断是否是已经加载过的命令,若已加载则不再 make
  2. 调用 program.comman() 注册新的子命令
  3. 注册 command.description() 描述信息
  4. 注册 command.usage() 用法信息
  5. 注册 command.alias() 别名信息
  6. 检查命令的依赖包的安装信息
  7. 为新增的 command 注册传入的 options
  8. 最后为新 command 注册 aciton handler
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 的实现思路异曲同工,不再展开,大致工作如下:

  1. 获取包管理器
  2. 创建 REPL 引导用户输入
  3. 创建子进程执行安装命令
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 这个模块的实现部分,本文主要做了以下工作:

  1. 有关 npm 注册 webpack bin 命令的过程——package.json.bin 字段,全局安装时可以全局调用;
  2. webpack-cli 提供的命令类型——内建命令+外置内建命令;
  3. WebpackCLI 构造函数的工作 —— 通过 Commander 初始化命令解析程序 this.program;
  4. 最走进入到 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 的语法
  5. 判断 commandToRun 是否为已知命令;
  6. 处理未知命令:特殊情况 entry 语法,否则为错误命令;
  7. 调用 program.parseAsyanc 执行新创建的命令;
  8. 我们还讨论另一个重要方法 makeCommand 方法:
      1. 判断是否是已经加载过的命令,若已加载则不再 make
      1. 调用 program.comman() 注册新的子命令
      1. 注册 command.description() 描述信息
      1. 注册 command.usage() 用法信息
      1. 注册 command.alias() 别名信息
      1. 检查命令的依赖包的安装信息
      1. 为新增的 command 注册传入的 options
      1. 最后为新 command 注册 aciton handler
转载自:https://juejin.cn/post/7326100957766795298
评论
请登录