这是我参与8月更文挑战的第9天,活动详情查看:8月更文挑战
注:文中所用 webpack 为 v4.x 版本
每次打包都是调用 npm run build 之类的命令,今天我们就来聊聊,调用 npm run build 之后都发生了什么;
一、npm 找到 webpack
我们之所以可以调用 npm run build,是因为我们在 package.json 中的 scripts 配置了 build 命令;如下:
{
"name": "webpack-demo",
"scripts": {
"build": "webpack",
"debug": ".... --watch"
},
"author": "",
"license": "ISC",
"devDependencies": {
}
}
从上面的代码可以看出,scripts.build 对应的是一个 webpack 的命令,那么这个 webpack 又是从哪里找到的呢?答案是 npm 会找到 node_modules/.bin/webpack;这个 .bin 目录存放的是可执行文件(文件权限 rwx 中的 x 就是可以执行),这个时候又得说说这个 .bin/webpack 是怎么来的。
根据 npm 的规则,当你的 npm 包的 package.json 配置了 bin 字段,且有效的指向一个脚本,当这个包被安装的时候,就会在 node_modules/.bin 注册 bin 字段对应的可执行文件,比如这是 webpack 包里面的 package.json 文件;
{
// ... other fields
"bin": {
"webpack": "bin/webpack.js"
},
"bugs": {
}
}
我们可以打开这两个文件, node_modules/.bin/webpack 和 node_modules/webpack/bin/webpack.js, 你会发现这俩文件是一样的。所以当你在命令行里执行 npm run build 这条命令时,最终 npm 会找到这个 node_modules/.bin/webpack 可执行文件,然后执行他,你的过山车之旅就开始了。
二、可执行文件 webpack
整个 node_modules/.bin/webpack 就做了处理了两件事:
- 检查
webpack安装的webpack cli - 根据第一步的检测结果,若已经安装了 webpack cli 就调用它,而未安装时引导用户安装
webpack-cli;
个人觉得引导用户安装这一部分还是很精彩的,它用短短的几行代码实现了一个交互式的 CLI ,又用简单的几行代码开启子进程去安装 webpack-cli。这对于那些写独立脚本的同学简直是个福音,可以大大方方的说 0 依赖,接下来我们展开看看这个两件事都是咋实现的;
2.1 检查 webpack-cli 的安装情况
webpack 检查以下两个 cli 的安装情况:webpack-cli 和 webpack-command。webpack 定义了一个描述这两个 cli 的数组,即常量 CLIs,
const CLIs = [
{
name: "webpack-cli",
package: "webpack-cli",
binName: "webpack-cli",
alias: "cli",
installed: isInstalled("webpack-cli"),
recommended: true,
url: "https://github.com/webpack/webpack-cli",
description: "The original webpack full-featured CLI."
},
{
name: "webpack-command",
package: "webpack-command",
binName: "webpack-command",
alias: "command",
installed: isInstalled("webpack-command"),
recommended: false,
url: "https://github.com/webpack-contrib/webpack-command",
description: "A lightweight, opinionated webpack CLI."
}
];
在每个描述 cli CLI 描述对象中有一个属性是描述是否安装的:installed,这个值是通过调用 isInstalled 方法得到的。这个方法接收包名,然后返回这个包是否安装了,核心通过是通过 require.resolve() 实现的;
/**
* @param {string} packageName name of the package
* @returns {boolean} is the package installed?
*/
const isInstalled = packageName => {
try {
require.resolve(packageName);
return true;
} catch (err) {
return false;
}
};
有了描述对象的 installed 属性,然后在调用数组的 filter 方法就可以得到已经安装过的 cli;即 installedClis,代码如下:
const installedClis = CLIs.filter(cli => cli.installed);
2.2 处理已经安装过 cli 和 未安装 cli 的情况
前面已经通过 filter 得到了检查结果 installedClis,判断安装情况只需要判断 installedClis.length 值的情况即可。
installedClis.length为0,表示一个都没有安装;installed.length为1,表示安装了某一个;installed.length为2,说明两个都安装了;
简化代码如下:
if (installedClis.length === 0) {
// none
} else if (installedClis.length === 1) {
// one of the CLIs
} else {
// both
}
2.2.1 installedClis.length 为 0 时引导用户安装 webpack-cli
引导用户去安装主要通过以下几个步骤实现的:
- 选择包管理器,即
yarn还是npm - 通过
readline创建交互式的命令行,询问用户是否接受自动安装webpack-cli; - 通过
child_process.spawn来创建子进程,执行安装webpack-cli的命令,成功后则会继续加载webpack-cli;
2.2.1.1 选择包管理器,即 yarn 还是 npm
判断是否是 yarn,是通过判断当前目录下是否存在 yarn.lock 这个 lockfile;若存在,则认定该项目应该使用 yarn 管理依赖的,同时得出对应的安装命令,即 yarn add 或者 npm install -D;示例代码如下:
const isYarn = fs.existsSync(path.resolve(process.cwd(), "yarn.lock"));
const packageManager = isYarn ? "yarn" : "npm";
const installOptions = [isYarn ? "add" : "install", "-D"];
2.2.1.2. 通过 readline 创建交互式的命令行,引导自动安装
通过 readline 这个 node.js 的核心包 的 createInterface 方法,即可实现一个简单的命令行;当需要向用户提出询问时,需要调用 question 方法,该方法的回调即用户在命令行的输入内容;
调用 question 方法提出问题:Do you want to install 'webpack-cli' (yes/no): ,紧接着在会调用接收 answer,如果 answer 是以 Y 开头,则认定用户同意了。征得用户同意后,接着就可以执行安装命令了;
代码如下:
const readLine = require("readline");
const question = `Do you want to install 'webpack-cli' (yes/no): `;
const questionInterface = readLine.createInterface({
input: process.stdin,
output: process.stderr
});
questionInterface.question(question, answer => {
questionInterface.close();
const normalizedAnswer = answer.toLowerCase().startsWith("y");
});
2.2.1.3. 创建子进程,执行安装 webpack-cli 的命令
这一步严格意义来说是上面第 2 步骤的一个子步骤,但是确实是个值得一提的点,所以单独拿出来;在拿到用户输入同意自动安装 webpack-cli 后,用已知的包管理器执行安装命令;webpack 中通过 runCommand 方法来调用安装命令的;
questionInterface.question(question, answer => {
const normalizedAnswer = answer.toLowerCase().startsWith("y");
runCommand(packageManager, installOptions.concat(packageName))
.then(() => {
require(packageName); //eslint-disable-line
})
.catch(error => {
console.error(error);
process.exitCode = 1;
});
});
- runCommand 方法:
通过 node.js 的 child_proces 创建子进程,调用 spawn 方法衍生子进程。
spawn 的第一个参数是要执行的命令,即上文中的 npm install D 或者 yarn add ,第二个参数 arg 是传递给要执行命令的参数,即 webpack-cli,第三个参数是控制 spawn 的行为的: stdio 继承当前父进程的 stdio,并且使用 shell将安装过程的信息输出到命令行,这块建议看下 node.js 相关的官方文档;
这里还值得一提的是封装这个 spawn 调用后返回一个 promise 对象,更丝滑的异步编程体验,当然开心的话也可以把他搞成一个 async 函数;
const runCommand = (command, args) => {
const cp = require("child_process");
return new Promise((resolve, reject) => {
const executedCommand = cp.spawn(command, args, {
stdio: "inherit",
shell: true
});
executedCommand.on("error", error => {
reject(error);
});
executedCommand.on("exit", code => {
if (code === 0) {
resolve();
} else {
reject();
}
});
});
};
当然,最重要的一步也是在安装 webpack-cli 成功后加载它,就是在 runCommand(...).then() 后的 require(packageName)
2.2.2 installed.length 为 1,即已经安装过 cli
解析该 cli 对应包的描述文件(package.json)路径,然后根据描述文件中的 bin 字段,加载对应的可执行文件;值得一提的是,require 会直接加载并执行这个可执行文件,让大宝流程继续下去;
if (installedClis.length === 0) {
// none
} else if (installedClis.length === 1) {
const path = require("path");
const pkgPath = require.resolve(`${installedClis[0].package}/package.json`); // 解析描述文件路径
const pkg = require(pkgPath);
// require 它也会执行它
require(path.resolve(
path.dirname(pkgPath),
pkg.bin[installedClis[0].binName]
));
} else {
// both
}
2.2.3 installed.length 为其他值,即两个都安装
如果检测到两个都安装了,一山难容二虎,这个时候就需要二选一了:告知用户得做出个抉择,然后令 process.exitCode = 1 退出进程:
console.warn(
`You have installed ${installedClis
.map(item => item.name)
.join(
" and "
)} together. To work with the "webpack" command you need only one CLI package, please remove one of them or use them directly via their binary.`
);
// @ts-ignore
process.exitCode = 1;