启动 webpack
本文将通过 webpack5 的入口文件源码,解读 webpack 的启动过程。
寻找入口
如下所示的 package.json
文件中,当我们执行 npm run build
命令时,实际执行了后面的 webpack
指令:
json
{
// ...
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"serve": "webpack serve",
"build": "webpack"
},
// ...
}
此时 npm 会去寻找 node_modules/.bin
目录下是否存在 webpack.sh
或者 webpack.cmd
文件,最终实际找到 node_modules/webpack/bin/webpack.js
文件作为入口文件去执行。
检查 webpack-cli 是否安装
node_modules/webpack/bin/webpack.js
文件源码如下,首先首行 #!/usr/bin/env node
会告诉系统以用户的环境变量中的 node 去执行这个文件,然后封装了三个方法,初始化了一个 cli
对象,根据 cli.installed
执行不同流程:
js
#!/usr/bin/env node
// 执行命令
const runCommand = (command, args) => {
// ...
};
// 检查一个包是否安装
const isInstalled = (packageName) => {
// ...
};
// 运行 webpack-cli
const runCli = (cli) => {
// ...
};
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) {
// ...
} else {
// ...
}
cli.installed
是执行了 isInstalled('webpack-cli')
方法。我们看一下 isInstalled
,它用于判断一个包是否安装。它接收一个 packageName 参数,当处于 pnp 环境时,直接返回 true 表示已安装;否则从当前目录开始向父级目录遍历 node_modules 文件夹下是否有 packageName 对应的文件夹,有则返回 true 表示已安装;直至遍历到顶层目录还未找到则返回 false 表示未安装。
js
/**
* @param {string} packageName name of the package
* @returns {boolean} is the package installed?
*/
const isInstalled = (packageName) => {
// process.versions.pnp 为 true 时,表示处于 Pnp 环境
// 提供了 npm 包即插即用的环境而不必安装,所以直接返回 true
if (process.versions.pnp) {
return true;
}
const path = require('path');
const fs = require('graceful-fs');
let dir = __dirname;
// 逐层向上遍历父级目录,看对应的 package 名的文件夹是否存在,从而判断包是否安装
do {
try {
if (
fs.statSync(path.join(dir, 'node_modules', packageName)).isDirectory()
) {
return true;
}
} catch (_error) {
// Nothing
}
} while (dir !== (dir = path.dirname(dir)));
return false;
};
未安装
若 cli.installed
为 false,说明 webpack-cli
未安装,提示用户必须安装 webpack-cli,然后让用户输入 y/n 选择是否安装:用户输入 n 则直接报错退出;用户输入 y 则直接通过上面的 runCommand
方法安装 webpack-cli,安装完毕后通过 runCli
方法引入 webpack-cli 的入口文件执行 webpack-cli:
js
if (!cli.installed) {
// webpack-cli 未安装
const path = require('path');
const fs = require('graceful-fs');
const readLine = require('readline');
// 提示 webpack-cli 必须安装
const notify =
'CLI for webpack must be installed.\n' + ` ${cli.name} (${cli.url})\n`;
console.error(notify);
let packageManager;
if (fs.existsSync(path.resolve(process.cwd(), 'yarn.lock'))) {
packageManager = 'yarn';
} else if (fs.existsSync(path.resolve(process.cwd(), 'pnpm-lock.yaml'))) {
packageManager = 'pnpm';
} else {
packageManager = 'npm';
}
const installOptions = [packageManager === 'yarn' ? 'add' : 'install', '-D'];
console.error(
`We will use "${packageManager}" to install the CLI via "${packageManager} ${installOptions.join(
' '
)} ${cli.package}".`
);
// 询问用户是否安装 webpack-cli
const question = `Do you want to install 'webpack-cli' (yes/no): `;
const questionInterface = readLine.createInterface({
input: process.stdin,
output: process.stderr,
});
// In certain scenarios (e.g. when STDIN is not in terminal mode), the callback function will not be
// executed. Setting the exit code here to ensure the script exits correctly in those cases. The callback
// function is responsible for clearing the exit code if the user wishes to install webpack-cli.
process.exitCode = 1;
questionInterface.question(question, (answer) => {
questionInterface.close();
const normalizedAnswer = answer.toLowerCase().startsWith('y');
// 用户选择不安装,报错退出
if (!normalizedAnswer) {
console.error(
"You need to install 'webpack-cli' to use webpack via CLI.\n" +
'You can also install the CLI manually.'
);
return;
}
process.exitCode = 0;
console.log(
`Installing '${
cli.package
}' (running '${packageManager} ${installOptions.join(' ')} ${
cli.package
}')...`
);
// 用户选择安装,通过 runCommand 安装 webpack-cli
runCommand(packageManager, installOptions.concat(cli.package))
.then(() => {
// 安装完毕后引入 webpack-cli 的入口文件执行
runCli(cli);
})
.catch((error) => {
console.error(error);
process.exitCode = 1;
});
});
} else {
// ...
}
已安装
若已安装 webpack-cli
,则直接通过 runCli
方法引入 webpack-cli 的入口文件执行 webpack-cli:
js
if (!cli.installed) {
// ...
} else {
// 若已安装,直接引入 webpack-cli 的入口文件执行
runCli(cli);
}
可见,webpack 的启动过程最终是去找到 webpack-cli
并执行。
启动 webpack-cli
入口文件
runCli
会找到 webpack-cli
的 package.json
中的 bin
所指向的文件引入执行,其对应的文件为 webpack-cli/bin/cli.js
,下面我们看一下这个文件的内容:
js
#!/usr/bin/env node
"use strict";
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/lib/bootstrap.js
文件中的 runCLI
函数,并将 process.argv
(即执行 webpack 命令时 webpack xxx
对应的 xxx 参数) 传入去执行。
runCLI
函数中代码如下,总共做了两件事情,首先通过 new WebpackCLI()
创建了一个 WebpackCLI 实例,然后 cli.run(args)
调用了实例的 run
方法:
js
const WebpackCLI = require("./webpack-cli");
const runCLI = async (args) => {
// Create a new instance of the CLI object
const cli = new WebpackCLI();
try {
await cli.run(args);
} catch (error) {
cli.logger.error(error);
process.exit(2);
}
};
module.exports = runCLI;
创建 WebpackCLI 实例
看下 WebpackCLI 这个类, new WebpackCLI()
创建 WebpackCLI 类实例时会执行其构造函数,设置了控制台的打印颜色和各种打印信息,最主要的是从 commander
包中引入了 program,将其挂载到了 this 上,稍后会讲到 Command 类的作用:
js
const { program, Option } = require("commander");
// ...
class WebpackCLI {
constructor() {
// 设置控制台打印颜色
this.colors = this.createColors();
// 设置各种类型控制台打印信息(error)
this.logger = this.getLogger();
// 将 Command 实例挂载到 this 上
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())}`),
});
}
// ...
async run(args, parseOptions) {
// ...
}
// ...
}
解析 webpack 指令参数
cli.run(args)
方法中,首先将各个 webpack 命令添加到了数组中,然后解析 webpack xxx
指令中的 xxx 参数,将其挂载到 this.program.args
上。然后根据不同的参数,调用 loadCommandByName
方法执行不同的 webpack 指令:
js
async run(args, parseOptions) {
// 执行打包
const buildCommandOptions = {
name: "build [entries...]",
alias: ["bundle", "b"],
description: "Run webpack (default command, can be omitted).",
usage: "[entries...] [options]",
dependencies: [WEBPACK_PACKAGE],
};
// 运行 webpack 并监听文件改变
const watchCommandOptions = {
name: "watch [entries...]",
alias: "w",
description: "Run webpack and watch for files changes.",
usage: "[entries...] [options]",
dependencies: [WEBPACK_PACKAGE],
};
// 查看 webpack、webpack-cli 和 webpack-dev-server 的版本
const versionCommandOptions = {
name: "version [commands...]",
alias: "v",
description:
"Output the version number of 'webpack', 'webpack-cli' and 'webpack-dev-server' and commands.",
};
// 输出 webpack 各项命令
const helpCommandOptions = {
name: "help [command] [option]",
alias: "h",
description: "Display help for commands and options.",
};
// 其他的内置命令
const externalBuiltInCommandsInfo = [
// 启动 dev server
{
name: "serve [entries...]",
alias: ["server", "s"],
pkg: "@webpack-cli/serve",
},
// 输出相关信息,包括当前系统、包管理工具、运行中的浏览器版本以及安装的 webpack loader 和 plugin
{
name: "info",
alias: "i",
pkg: "@webpack-cli/info",
},
// 生成一份 webpack 配置
{
name: "init",
alias: ["create", "new", "c", "n"],
pkg: "@webpack-cli/generators",
},
// 生成一份 webpack loader 代码
{
name: "loader",
alias: "l",
pkg: "@webpack-cli/generators",
},
// 生成一份 webpack plugin 代码
{
name: "plugin",
alias: "p",
pkg: "@webpack-cli/generators",
},
// 进行 webpack 版本迁移
{
name: "migrate",
alias: "m",
pkg: "@webpack-cli/migrate",
},
// 验证一份 webpack 的配置是否正确
{
name: "configtest [config-path]",
alias: "t",
pkg: "@webpack-cli/configtest",
},
];
// 初始化已知的命令数组
const knownCommands = [
buildCommandOptions,
watchCommandOptions,
versionCommandOptions,
helpCommandOptions,
...externalBuiltInCommandsInfo,
];
// ...
// Register own exit
// ...
// Default `--color` and `--no-color` options
// ...
// Make `-v, --version` options
// Make `version|v [commands...]` command
// ...
// Default action
this.program.usage("[options]");
this.program.allowUnknownOption(true);
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);
const defaultCommandToRun = getCommandName(buildCommandOptions.name);
const hasOperand = typeof operands[0] !== "undefined";
// 若传入参数,则将 operand 赋值为 webpack 命令后面跟的第一个参数,否则设置为默认的 build 命令
const operand = hasOperand ? operands[0] : defaultCommandToRun;
const isHelpOption = typeof options.help !== "undefined";
// 如果是已知的命令,isHelpCommandSyntax 为 true;否则为 false
const isHelpCommandSyntax = isCommand(operand, helpCommandOptions);
if (isHelpOption || isHelpCommandSyntax) {
// 如果不是已知命令,则输出如何获取 webpack help 信息
let isVerbose = false;
if (isHelpOption) {
if (typeof options.help === "string") {
if (options.help !== "verbose") {
this.logger.error("Unknown value for '--help' option, please use '--help=verbose'");
process.exit(2);
}
isVerbose = true;
}
}
this.program.forHelp = true;
const optionsForHelp = []
.concat(isHelpOption && hasOperand ? [operand] : [])
// Syntax `webpack help [command]`
.concat(operands.slice(1))
// Syntax `webpack help [option]`
.concat(unknown)
.concat(
isHelpCommandSyntax && typeof options.color !== "undefined"
? [options.color ? "--color" : "--no-color"]
: [],
)
.concat(
isHelpCommandSyntax && typeof options.version !== "undefined" ? ["--version"] : [],
);
await outputHelp(optionsForHelp, isVerbose, isHelpCommandSyntax, program);
}
const isVersionOption = typeof options.version !== "undefined";
const isVersionCommandSyntax = isCommand(operand, versionCommandOptions);
if (isVersionOption || isVersionCommandSyntax) {
// 如果是版本命令,则输出版本相关信息
const optionsForVersion = []
.concat(isVersionOption ? [operand] : [])
.concat(operands.slice(1))
.concat(unknown);
await outputVersion(optionsForVersion, program);
}
let commandToRun = operand;
let commandOperands = operands.slice(1);
if (isKnownCommand(commandToRun)) {
// 是已知的 webpack 命令,调用 loadCommandByName 函数执行相关命令
await loadCommandByName(commandToRun, true);
} else {
const isEntrySyntax = fs.existsSync(operand);
if (isEntrySyntax) {
// webpack xxx 其中 xxx 文件夹存在,则对 xxx 文件夹下面的内容进行打包
commandToRun = defaultCommandToRun;
commandOperands = operands;
await loadCommandByName(commandToRun);
} else {
// webpack xxx 的 xxx 不是已知命令且不是文件夹则报错
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",
});
});
// 解析指令参数挂载到 this.program.args 上
await this.program.parseAsync(args, parseOptions);
}
执行打包指令
loadCommandByName
方法中,主要是根据不同的 webpack 指令执行不同的功能,我们主要关注 webpack 打包过程,执行 webpack 打包相关的命令时(build 或 watch),最终运行了 runWebpack
方法:
js
const loadCommandByName = async (commandName, allowToInstall = false) => {
const isBuildCommandUsed = isCommand(commandName, buildCommandOptions);
const isWatchCommandUsed = isCommand(commandName, watchCommandOptions);
if (isBuildCommandUsed || isWatchCommandUsed) {
await this.makeCommand(
isBuildCommandUsed ? buildCommandOptions : watchCommandOptions,
// ...
async (entries, options) => {
if (entries.length > 0) {
options.entry = [...entries, ...(options.entry || [])];
}
await this.runWebpack(options, isWatchCommandUsed);
},
);
}
// ...
};
创建 compiler
看一下 runWebpack
的代码,里面主要做的事情就是调用 createCompiler
方法创建 compiler
(这是贯穿了 webpack 后续打包过程中的一个重要的对象,后面会详细讲到):
js
async runWebpack(options, isWatchCommand) {
// eslint-disable-next-line prefer-const
let compiler;
let createJsonStringifyStream;
// ...
// 创建 compiler
compiler = await this.createCompiler(options, callback);
// ...
}
运行 webpack
createCompiler
方法中,解析 options 中的各项打包配置,然后又回到了引入 webpack
包,运行其 main 入口文件,开始执行打包:
js
async createCompiler(options, callback) {
if (typeof options.nodeEnv === "string") {
process.env.NODE_ENV = options.nodeEnv;
}
let config = await this.loadConfig(options);
config = await this.buildConfig(config, options);
let compiler;
try {
// 运行 webpack
compiler = this.webpack(
config.options,
callback
? (error, stats) => {
if (error && this.isValidationError(error)) {
this.logger.error(error.message);
process.exit(2);
}
callback(error, stats);
}
: callback,
);
} catch (error) {
if (this.isValidationError(error)) {
this.logger.error(error.message);
} else {
this.logger.error(error);
}
process.exit(2);
}
// TODO webpack@4 return Watching and MultiWatching instead Compiler and MultiCompiler, remove this after drop webpack@4
if (compiler && compiler.compiler) {
compiler = compiler.compiler;
}
return compiler;
}
总结
总结一下 webpack5 中执行打包命令时, webpack 和 webpack-cli 的启动过程:
- 启动 webpack
- 检查 webpack-cli 是否安装
- 启动 webpack-cli
- 解析 webpack 指令参数
- 执行打包指令
- 创建 compiler
- 运行 webpack 主文件