1. webpack serve
在webpack 搭建的项目中,会使用 webpack serve 命令来启动项目,但同时我们也需要安装 webpack-cli,众所周知webpack 实际内部使用 cli 来启动
"scripts": {
"dev": "webpack serve"
},
下面是webpack 中的 package.json 文件
{
"name": "webpack",
"version": "5.93.0",
"author": "Tobias Koppers @sokra",
"description": "Packs ECMAScript/CommonJs/AMD modules for the browser. Allows you to split your codebase into multiple bundles, which can be loaded on demand. Supports loaders to preprocess files, i.e. json, jsx, es7, css, less, ... and your custom stuff.",
"license": "MIT",
"main": "lib/index.js",
"bin": {
"webpack": "bin/webpack.js"
}
"types": "types.d.ts",
......
}
2. webpack/bin
我们在项目中使用 webpack 来启动,实际上执行的是 bin 目录下面对应的指令
我们可以看到 bin/webpack.js 会检查是否安装 webpack-cli,并且会执行 runCli 这个函数
#!/usr/bin/env node
// 省略一些辅助函数
const cli = {
name: "webpack-cli",
package: "webpack-cli",
binName: "webpack-cli",
installed: isInstalled("webpack-cli"),
url: "https://github.com/webpack/webpack-cli"
};
if (!cli.installed) {
// 省略代码
runCommand(
installOptions.concat(cli.package)
)
.then(() => {
runCli(cli);
})
.catch(err => {
console.error(err);
process.exitCode = 1;
});
});
} else {
runCli(cli);
}
通过 isInstalled 这个函数来判断是否安装了 webpack-cli
- 没有安装则会提示用户安装,会捕获命令行的输入,如果用户同意安装则会通过 node子进程帮助用户安装,否则需要用户手动安装
/** 用来检测一个指定的 npm 包是否已经安装在当前环境中 */
function isInstalled(packageName) {
// 如果环境是使用 Yarn PnP,函数立即返回 true,不进行进一步检查
// 因为 PnP 不使用 node_modules 来管理依赖
if (process.versions.pnp) {
return true;
}
const path = require("path");
const fs = require("graceful-fs");
/**获取当前文件所在的目录路径 */
let dir = __dirname;
console.log("dir", dir);
// 从当前目录一直往上递归,直到文件系统的根目录
do {
try {
if (
// 在每一层目录中,函数都会检查 node_modules 目录下是否存在指定的 packageName 文件夹
// 如果存在并且是一个目录,则返回 true,表示该包已安装
fs.statSync(path.join(dir, "node_modules", packageName)).isDirectory()
) {
return true;
}
} catch (error) {}
} while (dir !== (dir = path.dirname(dir)));
// 如果上述步骤未找到包,函数还会检查 require("module").globalPaths 中的全局路径
// 这里就是在检查全局安装的包里面是否存在需要查找的包
for (const internalPath of require("module").globalPaths) {
try {
if (fs.statSync(path.join(internalPath, packageName)).isDirectory()) {
return true;
}
} catch (_error) {
// Nothing
}
}
// 所有步骤都未能找到该包,则最终返回 false,表示该包未安装
return false;
}
这里检测webpack-cli 是否安装会有两种:
- 首先会从当前目录递归向上寻找 node_modules 中是否存在 webpack-cli 的目录
- 如果第一步没有找到,则通过全局安装的依赖包中去寻找
- 已经安装过,会直接执行 runCli 函数
看到这里发现 webpack 的启动必须依赖于 webpack-cli,我们接着看 runCli 函数里面实际做了些什么
const runCli = cli => {
const path = require("path");
const pkgPath = require.resolve(`${cli.package}/package.json`);
const pkg = require(pkgPath);
if (pkg.type === "module" || /\.mjs/i.test(pkg.bin[cli.binName])) {
import(path.resolve(path.dirname(pkgPath), pkg.bin[cli.binName])).catch(
err => {
console.error(err);
process.exitCode = 1;
}
);
} else {
require(path.resolve(path.dirname(pkgPath), pkg.bin[cli.binName]));
}
};
这里我们可以看到会根据模块类型的不同,使用不同的方式加载并执行 CLI 工具:
- ESM 方式 使用 import 加载模块
- 否则统一使用 CJS 方式 使用 require 加载模块
这里可以看到webpack 内部去调用了 webpack-cli 中 bin 目录下面的对应的指令
3. webpack-cli/bin
这里webpack-cli 采用 monorepo 管理,其下有几个子包,我们的关注点也主要放在 webpack-cli 和 serve
{
"name": "webpack-cli",
"version": "5.1.4",
"description": "CLI for webpack & friends",
"bin": {
"webpack-cli": "./bin/cli.js"
}
}
我们可以看到 webpack 种执行了 runCLI 后,会执行 webpack-cli 中 bin 目录下面的js
我们看看 webpack-cli/bin/cli.js 做了哪些事
const importLocal = require("import-local");
const runCLI = require("../lib/bootstrap");
if (!process.env.WEBPACK_CLI_SKIP_IMPORT_LOCAL) {
// Prefer the local installation of `webpack-cli`
if (importLocal(__filename)) {
return;
}
}
process.title = "webpack";
runCLI(process.argv);
这里主要目的是优先使用本地安装的 webpack-cli,否则会执行 bootstrap 模块中的 runCLI 函数
bootstrap 中的 runCLI 方法,主要是为 创建一个 webpack-cli 实例,并且尝试执行 run 方法
4. webpack-cli
我们来看 WebpackCLI 这个类做了哪些处理,以及 run 方法的调用做了些什么。
webpack 的构造函数只是简单的初始化处理
class WebpackCLI {
constructor() {
// 处理颜色
this.colors = this.createColors();
// 处理日志
this.logger = this.getLogger();
// Initialize program
this.program = program;
this.program.name("webpack");
// 配置 program 对象的输出行为,用于定制错误输出的格式和处理方式
this.program.configureOutput({
writeErr: this.logger.error,
outputError: (str, write) =>
write(
`Error: ${this.capitalizeFirstLetter(
str.replace(/^error:/, "").trim()
)}`
),
});
}
}
我们来看看 run 方法
class WebpackCLI {
constructor() {}
// run 方法的代码很多,会省略一些
async run(){
// 下面代码定义了一些命令行工具的内置命令选项
const buildCommandOptions = {}
const watchCommandOptions = {}
const versionCommandOptions = {}
const helpCommandOptions = {}
// 下面代码定义了一些命令行工具的外部内置命令
const externalBuiltInCommandsInfo = [
{
name: "serve [entries...]",
alias: ["server", "s"],
pkg: "@webpack-cli/serve",
},
{
name: "info",
alias: "i",
pkg: "@webpack-cli/info",
},
// 省略....
]
const knownCommands = [
buildCommandOptions,
watchCommandOptions,
versionCommandOptions,
helpCommandOptions,
...externalBuiltInCommandsInfo,
];
// 定义了一些辅助函数
// .......
// 通过 program 处理一些用户交互
// this.program....
this.program.action(async (options, program) => {
// 省略.....
}
// 异步解析命令行参数
await this.program.parseAsync(args, parseOptions);
/*
@example
program
.option('--mode <type>', 'set mode', 'development') // 定义命令行选项
.action((options) => {
console.log(`Mode: ${options.mode}`); // 打印用户输入的模式
});
await program.parseAsync(args); // 异步解析命令行参数
用户输入 node cli.js --mode production
解析这个输入,并执行相应的命令,输出 Mode: production
*/
}
}
重点看 this.program.action 这个里面回调做的事情
this.program.action(async (options, program) => {
if (!isInternalActionCalled) {
// 防止命令行工具在同一个命令中多次执行操作(例如重复调用默认动作)
isInternalActionCalled = true;
}
else {
this.logger.error("No commands found to run");
process.exit(2);
}
// Command and options
const { operands, unknown } = this.program.parseOptions(program.args);
// 获取默认要执行的命令名称,默认命令为 build
const defaultCommandToRun = getCommandName(buildCommandOptions.name);
const hasOperand = typeof operands[0] !== "undefined";
const operand = hasOperand ? operands[0] : defaultCommandToRun;
// 省略 帮助和版本的处理.....
let commandToRun = operand;
let commandOperands = operands.slice(1);
// 检查用户输入的命令是否为 Webpack 已知的命令
if (isKnownCommand(commandToRun)) {
// 加载并执行这个命令
await loadCommandByName(commandToRun, true);
}
else {
// 检查 operand 是否是一个有效的文件路径
const isEntrySyntax = fs.existsSync(operand);
if (isEntrySyntax) {
// 如果是文件路径,则使用默认命令处理
commandToRun = defaultCommandToRun;
commandOperands = operands;
await loadCommandByName(commandToRun);
}
else {
// 输出未知命令的错误信息,并使用 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);
}
}
// 再次解析传入的命令行参数,并执行相应的命令
await this.program.parseAsync([commandToRun, ...commandOperands, ...unknown], {
from: "user",
});
});
根据action 处理逻辑,重点放在 loadCommandByName 这个函数上,用来执行对应的命令。
这个
loadCommandByName函数用于加载 Webpack CLI 的命令。它根据命令名称决定要加载的内置命令或外部扩展命令,并处理命令的安装和执行逻辑。这个函数支持异步加载命令,如果命令不存在还可以提示用户安装所需的包。
/**
* 处理了根据用户输入的命令名称加载并执行相应的命令
* @param {*} commandName 用户输入的命令名称
* @param {*} allowToInstall true 允许尝试安装缺失的包
* @returns
*/
const loadCommandByName = async (commandName, allowToInstall = false) => {
const isBuildCommandUsed = isCommand(commandName, buildCommandOptions);
const isWatchCommandUsed = isCommand(commandName, watchCommandOptions);
// 检查是否使用了 build 或 watch 命令
if (isBuildCommandUsed || isWatchCommandUsed) {
await this.makeCommand(isBuildCommandUsed ? buildCommandOptions : watchCommandOptions, async () => {
this.webpack = await this.loadWebpack();
return this.getBuiltInOptions();
}, async (entries, options) => {
if (entries.length > 0) {
options.entry = [...entries, ...(options.entry || [])];
}
await this.runWebpack(options, isWatchCommandUsed);
});
}
else if (isCommand(commandName, helpCommandOptions)) {
// help 命令
this.makeCommand(helpCommandOptions, [], () => { });
}
else if (isCommand(commandName, versionCommandOptions)) {
// version 命令
this.makeCommand(versionCommandOptions, this.getInfoOptions(), async (options) => {
const info = await cli.getInfoOutput(options);
cli.logger.raw(info);
});
}
else {
// 处理外部命令 (如 @webpack-cli/serve)
// 尝试在 externalBuiltInCommandsInfo 中查找与 commandName 匹配的命令
// 这里的匹配逻辑不仅检查命令名称是否相同,还会检查命令的别名(alias)
const builtInExternalCommandInfo = externalBuiltInCommandsInfo.find((externalBuiltInCommandInfo) => getCommandName(externalBuiltInCommandInfo.name) === commandName ||
(Array.isArray(externalBuiltInCommandInfo.alias)
? externalBuiltInCommandInfo.alias.includes(commandName)
: externalBuiltInCommandInfo.alias === commandName));
// 确定要使用的包名
let pkg;
// 如果找到了相应的外部命令信息,则从中提取 pkg(包名)
if (builtInExternalCommandInfo) {
({ pkg } = builtInExternalCommandInfo);
}
else {
// 没有找到匹配的内置外部命令,则假设 commandName 就是包名
pkg = commandName;
}
// 首先,排除 webpack-cli,因为这是 CLI 的核心包,必然存在
// 检查该包是否已经安装
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 {
// 通过 require 或 import 动态加载命令模块
loadedCommand = await this.tryRequireThenImport(pkg, false);
}
catch (error) {
// Ignore, command is not installed
return;
}
// 实例化并执行命令
let command;
try {
// 实例化命令对象
command = new loadedCommand();
// 调用命令对象的 apply 方法,并将 this 作为参数传递,执行该命令的逻辑
await command.apply(this);
}
catch (error) {
// 命令执行时出错,CLI 会输出错误信息并以错误代码 2 退出程序
this.logger.error(`Unable to load '${pkg}' command`);
this.logger.error(error);
process.exit(2);
}
}
};
看到这里我们可以发现对于 不是 build、watch、help、version 的命令会尝试加载内置的外部命令
如果依赖没有找到会提示用户安装这个包
对于这个外部命令的包,需要提供一个 对象(类),且需要有一个 apply 方法
还记得开头我们执行的 webpack 命令吗?
webpack serve
这个包是 webpack-cli 的运行依赖,所以我们不需要安装这个包
5. @webpack-cli/serve
@webpack-cli/serve是 Webpack CLI 的一个子包,用于启动 Webpack 开发服务器(即webpack-dev-server)。它主要负责为 Webpack CLI 提供serve命令的功能,使得开发者能够通过命令行运行 Webpack 的开发服务器来进行开发工作。
@webpack-cli/serve 做了以下几件事:
- 启用开发服务器:
@webpack-cli/serve为webpack-cli提供了webpack serve命令,该命令实际上启动了webpack-dev-server,用于在本地提供打包后的文件,并启用热模块替换(HMR)等开发时功能 - CLI 参数解析: 该包会解析命令行参数,并将它们传递给
webpack-dev-server以配置服务器的运行行为。例如,你可以通过命令行设置端口、启用 HTTPS、配置代理等 - 与 Webpack 配置集成: 当你使用
webpack serve命令时,@webpack-cli/serve会自动读取你的webpack.config.js配置文件,并根据其中的devServer选项来配置服务器的行为。例如,它可以读取你在配置文件中的port、proxy等设置,并传递给开发服务器。
我们可以看到这里 webpack-cli 中的 run 方法会去加载 @webpack-cli/serve,而这个包(后面会去启动 webpack-dev-server)
tryRequireThenImport 会根据模块类型(esm、cjs)加载一个依赖
可以看到@webpack-cli/serve 这个包导出了index.js,我们看看里面做了什么
我们可以看到有个 apply 方法,webpack-cli 会去调用这个 apply 方法
我们回到 webpack-cli 中的 run 方法,可以看到在加载 @webpack-cli/server 后,会创建实例,并调用实例身上的 apply 方法,并且把当前 webpack-cli 的实例传递进去
我们来看看这个插件(@webpack-cli/serve)做了什么
我们可以看到这个插件为 serve 命令添加了一些处理,这样就能匹配上一开始项目启动中的命令 webpack serve
此时这里会去加载 webpack,cli 实例上就会有webpack导出的内容,后续过程中会用到,这里不在启动流程中详细说
cli.webpack = await cli.loadWebpack();
cli.loadWebpack 这个方法实际上去加载 webpack
async loadWebpack(handleError = true) {
return this.tryRequireThenImport(WEBPACK_PACKAGE, handleError);
}
webpack.js 导出的是一个函数,这个后续我们再看
执行 serve 命令后,会走到这里的处理函数,会根据配置创建编译器实例(compiler),后面再详细说,本章我们只关注整个启动流程
创建完编译器实例(compiler)后,经过一些处理会去加载 webpack-dev-server 这个包
这里我们可以看到根据配置和编译器实例,创建一个 server,然后调用 start 启动一个开发服务器
现在梳理一下调用流程:
执行 webpack serve ------> webpack/bin ------> webpack-cli/bin -----> new webpack-cli & run -------> 加载 @webpack-cli/serve --------> webpack-dev-server
webpack 启动的大致流程就是这样,使用的依赖包分别有:
- webpack-cli
- webpack-cli/serve
- webpack-dev-server
下一章会讲 webpack-dev-server 是如何开始调用编译器执行打包的