三分钟学会 webpack 的启动流程

1,783 阅读6分钟

这是我参与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/webpacknode_modules/webpack/bin/webpack.js, 你会发现这俩文件是一样的。所以当你在命令行里执行 npm run build 这条命令时,最终 npm 会找到这个 node_modules/.bin/webpack 可执行文件,然后执行他,你的过山车之旅就开始了。

二、可执行文件 webpack

整个 node_modules/.bin/webpack 就做了处理了两件事:

  1. 检查 webpack 安装的 webpack cli
  2. 根据第一步的检测结果,若已经安装了 webpack cli 就调用它,而未安装时引导用户安装 webpack-cli

个人觉得引导用户安装这一部分还是很精彩的,它用短短的几行代码实现了一个交互式的 CLI ,又用简单的几行代码开启子进程去安装 webpack-cli。这对于那些写独立脚本的同学简直是个福音,可以大大方方的说 0 依赖,接下来我们展开看看这个两件事都是咋实现的;

2.1 检查 webpack-cli 的安装情况

webpack 检查以下两个 cli 的安装情况:webpack-cliwebpack-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 值的情况即可。

  1. installedClis.length0,表示一个都没有安装;
  2. installed.length1,表示安装了某一个;
  3. installed.length2,说明两个都安装了;

简化代码如下:

if (installedClis.length === 0) {
   // none
} else if (installedClis.length === 1) {
   // one of the CLIs
} else {
   // both
}

2.2.1 installedClis.length0 时引导用户安装 webpack-cli

引导用户去安装主要通过以下几个步骤实现的:

  1. 选择包管理器,即 yarn 还是 npm
  2. 通过 readline 创建交互式的命令行,询问用户是否接受自动安装 webpack-cli
  3. 通过 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.jschild_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.length1,即已经安装过 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;