Webpack4源码解析之定位Webpack打包入口

641 阅读15分钟

Webpack源码解析

// 使用webpack版本

"html-webpack-plugin": "^4.5.0",
"webpack": "^4.44.2",
"webpack-cli": "^3.3.12"

定位webpack打包入口

我们创建一个 run.js 文件,引入 webpack 和我们的配置文件 webpack.config.js ,将 webpack 作为函数调用,参数就是配置选项 webpack.config.js 中的内容:

// run.js

const config = require('./webpack.config')
const webpack = require('webpack')

const compiler = webpack(config)

compiler.run((err, stats) => {
  console.log(err)
  console.log(stats.toJson())
})

webpack 函数执行完毕会生成一个编译器 compiler,调用 compiler 的 run 方法就会进行代码编译,我们使用 node 运行这个 run.js 会发现项目能够正常编译打包,这就说明 webpack 的核心模块就是 compiler 。

我们看一下 webpack 的 package.json 对于入口文件的标识:

"main": "lib/webpack.js",
"web": "lib/webpack.web.js",
"bin": "./bin/webpack.js",

lib/webpack.js 做了什么

首先我们先看一下 webpack 的 lib 目录下的 webpack.js 文件做了什么:

// webpack lib/webpack.js

... // 上面是一堆文件导入

/**
 * @param {WebpackOptions} options options object
 * @param {function(Error=, Stats=): void=} callback callback
 * @returns {Compiler | MultiCompiler} the compiler object
 */
const webpack = (options, callback) => {
  // 校验options是否合乎规则,也就是校验用户的配置文件是否存在不合理的地方
  const webpackOptionsValidationErrors = validateSchema(
    webpackOptionsSchema,
    options
  );
  // 用户配置文件如果存在问题,就抛出异常信息
  if (webpackOptionsValidationErrors.length) {
    throw new WebpackOptionsValidationError(webpackOptionsValidationErrors);
  }
  // 声明一个编译器
  let compiler;
  // 如果是多配置打包,那么就生成多个compiler然后将这个compilers交由MultiCompiler处理
  if (Array.isArray(options)) {
    compiler = new MultiCompiler(
      Array.from(options).map((options) => webpack(options))
    );
  } else if (typeof options === "object") {
    // 否则的话就合并webpack的默认配置至用户配置,用来增强编译能力
    options = new WebpackOptionsDefaulter().process(options);

    // 生成compiler实例
    compiler = new Compiler(options.context);
    
    // 挂载配置选项至compiler实例,整个打包过程中都会携带options
    compiler.options = options;
    
    // 加载 node I/O 文件读写插件,分别是
    // inputFileSystem与outputFileSystem处理文件i/o,
    // watchFileSystem监听文件改动,intermediateFileSystem则处理所有不被看做是输入或输出文件系统操作,
    // 比如写记录,缓存或者分析输出
    new NodeEnvironmentPlugin({
      infrastructureLogging: options.infrastructureLogging,
    }).apply(compiler);
    
    // 挂载用户插件
    if (options.plugins && Array.isArray(options.plugins)) {
      for (const plugin of options.plugins) {
        if (typeof plugin === "function") {
          plugin.call(compiler, compiler);
        } else {
          plugin.apply(compiler);
        }
      }
    }
    // 执行environment上绑定的事件
    compiler.hooks.environment.call();
    
    // 执行afterEnvironment上绑定的事件
    compiler.hooks.afterEnvironment.call();
    
    // 加载webpack内置插件
    compiler.options = new WebpackOptionsApply().process(options, compiler);
  } else {
    throw new Error("Invalid argument: options");
  }
  // 编译完毕回调
  if (callback) {
    if (typeof callback !== "function") {
      throw new Error("Invalid argument: callback");
    }
    if (
      options.watch === true ||
      (Array.isArray(options) && options.some((o) => o.watch))
    ) {
      const watchOptions = Array.isArray(options)
        ? options.map((o) => o.watchOptions || {})
        : options.watchOptions || {};
      return compiler.watch(watchOptions, callback);
    }
    compiler.run(callback);
  }
  return compiler;
};

exports = module.exports = webpack;

... // 下面是往webpack上挂载一堆原生插件,方便 `webpack.` 调用

lib/webpack.js 就是做了一件主要的事情:生成编译器实例 compiler,它是类 Compiler 的实例对象,而 Compiler 又是 Tapable 的子类,所以 compiler 的核心就是 执行挂载在钩子上的一个个编译打包任务,它们可以是 同步的,也可以是 异步的,最终都会输出一个打包好的文件集合,而输入输出的关键就是 NodeEnvironmentPlugin。在此过程中挂载的一个个 plugin 也是挂载在钩子上的一个个待执行任务,当这个钩子被触发时,这些插件任务就会参与到编译打包过程中去,增强编译打包效果。

bin/webpack.js 做了什么

下面我们再来看看 bin/webpack.js 做了什么:

// webpack bin/webpack.js

#!/usr/bin/env node

// @ts-ignore
// 进程退出标识码,0表示正常退出,1表示异常退出
process.exitCode = 0;

/**
 * @param {string} command process to run
 * @param {string[]} args commandline arguments
 * @returns {Promise<void>} promise
 */
// 执行命令行任务,主要用来引导用户安装webpack命令行工具
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();
      }
    });
  });
};

/**
 * @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;
  }
};

/**
 * @typedef {Object} CliOption
 * @property {string} name display name
 * @property {string} package npm package name
 * @property {string} binName name of the executable file
 * @property {string} alias shortcut for choice
 * @property {boolean} installed currently installed?
 * @property {boolean} recommended is recommended
 * @property {string} url homepage
 * @property {string} description description
 */

/** @type {CliOption[]} */
// webpack命令行工具集合
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.",
  },
];

// 找出已经安装的命令行工具,一般就是webpack-cli
// {
//  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."
// },
const installedClis = CLIs.filter((cli) => cli.installed);

if (installedClis.length === 0) {
  // 如果没有安装任何一个webpack命令行工具,就会通过命令行询问一些问题
  // 来引导用户安装webpack的命令行工具
  const path = require("path");
  const fs = require("fs");
  const readLine = require("readline");

  let notify =
    "One CLI for webpack must be installed. These are recommended choices, delivered as separate packages:";

  ... // 省略部分代码(就是询问一些信息引导用户安装webpack命令行工具依赖)

} else if (installedClis.length === 1) {
  const path = require("path");
  // 一般 installedClis[0].package = 'webpack-cli'
  // 这里等价于 const pkgPath = require.resolve(`webpack-cli/package.json`);
  // 也就是获取webpack命令行工具 package.json 的绝对路径
  const pkgPath = require.resolve(`${installedClis[0].package}/package.json`);
  // eslint-disable-next-line node/no-missing-require
  // 加载package.json,获取包信息
  const pkg = require(pkgPath);
  // eslint-disable-next-line node/no-missing-require
  // 下面的 installedClis[0].binName = 'webpack-cli'
  // path.dirname(pkgPath) 就是webpack-cli的绝对路径
  // "bin": {
  //   "webpack-cli": "./bin/cli.js"
  // },
  // 这里就是加载webpack-cli/bin/cli.js
  require(path.resolve(
    path.dirname(pkgPath),
    pkg.bin[installedClis[0].binName]
  ));
} else {
  // 安装了多个命令行工具会导致冲突,所以要提示用户卸载掉一个,只保留一个就可以了
  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;
}

由上可知,命令行执行 webpack 其实就是找到我们已经安装的 webpack 命令行工具 webpack-cli 或者 webpack-command,我们一般都是使用 webpack-cli,找到后直接通过 require 函数执行。

而我们通过阅读 webpack-cli/bin/cli.js 可以看出最终还是通过调用 processOptions(options); 来执行以下代码:

const config = require('./webpack.config')
const webpack = require('webpack')

const compiler = webpack(config)

compiler.run((err, stats) => {
  console.log(err)
  console.log(stats.toJson())
})

webpack-cli/bin/cli.js 的源码就不做展示了,有兴趣的可以自己去看看,主要就是进行参数处理整合,最终整合成 options 供 processOptions 使用。

这就是我们一开始编写的 run.js,也就是说命令行运行 webpack 时,bin/webpack.js 会判断用户是否安装 webpack-cli 等命令行工具,没有安装的话提示用户安装,安装的话就通过命令行参数拼装 options,最终还是将 options 交由我们 run.js 中的代码执行,然后进行打包。

打包主流程分析

1. 配置扩展

使用webpack默认配置扩展用户自定义配置,增强编译能力。

// node_modules/webpack/lib/webpack.js

function webpack(){
  ...

  options = new WebpackOptionsDefaulter().process(options);

  ...
}

WebpackOptionsDefaulter 类没有自己的成员方法,它继承至父类 OptionsDefaulter,只有一个构造函数,内部先执行父类的构造函数,然后调用继承父类的set方法设置一些默认选项、配置:

// node_modules/webpack/lib/WebpackOptionsDefaulter.js

class WebpackOptionsDefaulter extends OptionsDefaulter {
  constructor() {
    super();

    this.set("entry", "./src");

    this.set("devtool", "make", (options) =>
      options.mode === "development" ? "eval" : false
    );
    this.set("cache", "make", (options) => options.mode === "development");

    this.set("context", process.cwd());
    this.set("target", "web");

    this.set("module", "call", value => Object.assign({}, value));

    ...
  }
}
// node_modules/webpack/lib/OptionsDefaulter.js

/*
 MIT License http://www.opensource.org/licenses/mit-license.php
 Author Tobias Koppers @sokra
*/
"use strict";

/**
 * 取出用户自定义配置
 * @param {object} obj 用户自定义的配置
 * @param {string} path 配置选项(路径参数: output.filename)
 * @returns {any} - if {@param path} requests element from array, then `undefined` will be returned
 */
const getProperty = (obj, path) => {
  let name = path.split(".");
  for (let i = 0; i < name.length - 1; i++) {
    obj = obj[name[i]];
    // 如果用户配置的值不是一个对象,取反为真,或者是一个数组,停止执行
    if (typeof obj !== "object" || !obj || Array.isArray(obj)) return;
  }
  return obj[name.pop()];
};

/**
 * Sets the value at path of object. Stops execution, if {@param path} requests element from array to be set
 * @param {object} obj object to query
 * @param {string} path query path
 * @param {any} value value to be set
 * @returns {void}
 */
const setProperty = (obj, path, value) => {
  let name = path.split(".");
  for (let i = 0; i < name.length - 1; i++) {
    // 如果配置项的值不是一个object,或者不是undefined,停止执行
    if (typeof obj[name[i]] !== "object" && obj[name[i]] !== undefined) return;
    // 如果配置项的值是一个数组,停止执行
    if (Array.isArray(obj[name[i]])) return;
    // 如果这个值为undefined,那么声明为一个对象
    if (!obj[name[i]]) obj[name[i]] = {};
    // 将配置项设置给obj
    obj = obj[name[i]];
  }
  // 给配置选项设置值
  obj[name.pop()] = value;
};

/**
 * @typedef {'call' | 'make' | 'append'} 配置方式
 */
/**
 * @typedef {(options: object) => any} 创建配置函数
 */
/**
 * @typedef {(value: any, options: object) => any} 计算配置函数
 */
/**
 * @typedef {any[]} AppendConfigValues
 */

class OptionsDefaulter {
  constructor() {
    /**
     * 存储用于计算它们的默认选项设置或函数(默认配置可能是一个设置,也可能是一个函数,需要通过函数调用计算出设置)
     */
    this.defaults = {};
    /**
     * 存储选项的配置 {'call' | 'make' | 'append'}
     * @type {{[key: string]: ConfigType}}
     */
    this.config = {};
  }

  /**
   * Enhancing {@param options} with default values
   * @param {object} options provided options
   * @returns {object} - enhanced options
   * @throws {Error} - will throw error, if configuration value is other then `undefined` or {@link ConfigType}
   */
  process(options) {
    options = Object.assign({}, options);
    for (let name in this.defaults) {
      switch (this.config[name]) {
        /**
         * 如果该配置项不需要通过函数求值,并且用户没有给该配置项设置值,那么就使用default中的值设置它
         */
        case undefined:
          if (getProperty(options, name) === undefined) {
            setProperty(options, name, this.defaults[name]);
          }
          break;
        /**
         * call 将用户配置取出经过函数中的一些判断,返回值或者进行对象合并后返回,再设置回去
         */
        case "call":
          setProperty(
            options,
            name,
            this.defaults[name].call(this, getProperty(options, name), options)
          );
          break;
        /**
         * make 依赖于用户的配置选项options,会根据options求值,
         * 会去执行 WebpackOptionsDefaulter 中通过set方法给配置项设置的函数
         * 这个函数的执行依赖于用户配置选项options,会根据options中的一些配置计算出该项的配置值
         */
        case "make":
          // 如果用户配置了该选项,那么就采用户的配置,否则采用默认配置
          if (getProperty(options, name) === undefined) {
            setProperty(options, name, this.defaults[name].call(this, options));
          }
          break;
        /**
         * 向原有配置插入默认配置(基本上没用到)
         */
        case "append": {
          let oldValue = getProperty(options, name);
          if (!Array.isArray(oldValue)) {
            oldValue = [];
          }
          oldValue.push(...this.defaults[name]);
          setProperty(options, name, oldValue);
          break;
        }
        default:
          throw new Error(
            "OptionsDefaulter cannot process " + this.config[name]
          );
      }
    }
    return options;
  }

  /**
   * 建立默认值
   * @param {string} name 选项路径(不是一个单独名称,是名称路径,类似于 output.filename)
   * @param {ConfigType | any} config 如果提供了第三个参数,那么必须使用config来求值(第三个参数一般是个函数,第二个参数决定了以什么方式来求值)
   * @param {MakeConfigHandler | CallConfigHandler | AppendConfigValues} [def] 默认配置
   * @returns {void}
   */
  set(name, config, def) {
    // defaults 存入配置选项的值,或者是获取值得一个函数
    // config 存入的是获取配置选项值得方式
    if (def !== undefined) {
      this.defaults[name] = def;
      this.config[name] = config;
    } else {
      this.defaults[name] = config;
      delete this.config[name];
    }
  }
}

module.exports = OptionsDefaulter;

WebpackOptionsDefaulter 的构造函数通过调用 set 方法往 defaults 里面存入了配置选项的值或者计算值的函数,往 config 里面存入了值得计算方式,call 是将用户配置取出经过一些判断,返回本值,或进行对象合并后返回;而 make 依赖于用户的自定义配置,通过配置计算出值,然后设置给配置项。

2. 实例化Compiler

Compiler 模块是 webpack 的支柱引擎,它通过 CLI 或 Node API 传递的所有选项,创建出一个 compilation 实例。它扩展(extend)自 Tapable 类,以便注册和调用插件。大多数面向用户的插件首先会在 Compiler 上注册。

// node_modules/webpack/lib/webpack.js

function webpack(){
  ...

  compiler = new Compiler(options.context);

  ...
}

创建一个 Compiler 实例,参数是当前执行环境,即当前 webpack 打包时的运行环境(process.cwd()),一般为项目的根路径(绝对路径),类 Compiler 是类 Tapable 的子类,Compiler 的构造函数中首先运行 super() 执行类 Tapable 的构造函数:

// Tapable.js

function Tapable() {
  this._pluginCompat = new SyncBailHook(["options"]);

  // 插件名大写
  this._pluginCompat.tap(
    {
      name: "Tapable camelCase",
      stage: 100,
    },
    (options) => {
      options.names.add(
        options.name.replace(/[- ]([a-z])/g, (str, ch) => ch.toUpperCase())
      );
    }
  );
  // 挂载插件至hooks
  this._pluginCompat.tap(
    {
      name: "Tapable this.hooks",
      stage: 200,
    },
    (options) => {
      let hook;
      for (const name of options.names) {
        hook = this.hooks[name];
        if (hook !== undefined) {
          break;
        }
      }
      if (hook !== undefined) {
        const tapOpt = {
          name: options.fn.name || "unnamed compat plugin",
          stage: options.stage || 0,
        };
        if (options.async) hook.tapAsync(tapOpt, options.fn);
        else hook.tap(tapOpt, options.fn);
        return true;
      }
    }
  );
}

Tapable 中定义了一个成员 _pluginCompat,并实例化了一个同步熔断钩子赋值给 _pluginCompat,这个钩子主要是用来处理插件兼容的,注册了两个钩子函数来大写命名插件和挂载插件。下面在Tapable原型上挂载的 pluginapplyplugin 提示使用 _pluginCompat 挂载插件,apply 提示 Tapable.apply 已弃用,直接在插件上调用 apply

Tapable 的构造函数执行完毕后 Compiler 就在自己的 hooks 上挂载生命周期函数,这些函数都是 Tapabe 钩子类的实例:

// node_modules/webpack/lib/Compiler.js

this.hooks = {
  // 所有需要输出的文件已经生成好,询问插件哪些文件需要输出,哪些不需要
  shouldEmit: new SyncBailHook(["compilation"]),

  // 成功完成一次完整的编译和输出流程
  done: new AsyncSeriesHook(["stats"]),

  // 这个钩子允许你多做一次构建
  additionalPass: new AsyncSeriesHook([]),

  // run之前启动的钩子,在开始执行一次构建之前调用,compiler.run 方法开始执行后立刻进行调用
  beforeRun: new AsyncSeriesHook(["compiler"]),

  // 启动一次新的编译
  run: new AsyncSeriesHook(["compiler"]),

  // 确定好要输出哪些文件后,执行文件输出,可以在这里获取和修改输出内容
  emit: new AsyncSeriesHook(["compilation"]),

  // 在 asset 被输出时执行。此钩子可以访问被输出的 asset 的相关信息,例如它的输出路径和字节内容
  assetEmitted: new AsyncSeriesHook(["file", "content"]),

  // 输出完毕之后执行的钩子
  afterEmit: new AsyncSeriesHook(["compilation"]),

  // 初始化 compilation 时调用,在触发 compilation 事件之前调用
  thisCompilation: new SyncHook(["compilation", "params"]),

  // 当 Webpack 以开发模式运行时,每当检测到文件变化,一次新的 Compilation 将被创建。一个 Compilation 对象包含了当前的模块资源、编译生成资源、变化的文件等。Compilation 对象也提供了很多事件回调供插件做扩展
  // compilation 创建之后执行
  compilation: new SyncHook(["compilation", "params"]),

  // Compiler 使用 NormalModuleFactory 模块生成各类模块。从入口点开始,此模块会分解每个请求,解析文件内容以查找进一步的请求,然后通过分解所有请求以及解析新的文件来爬取全部文件。在最后阶段,每个依赖项都会成为一个模块实例
  normalModuleFactory: new SyncHook(["normalModuleFactory"]),

  // Compiler 使用 ContextModuleFactory 模块从 webpack 独特的 require.context API 生成依赖关系。它会解析请求的目录,为每个文件生成请求,并依据传递来的 regExp 进行过滤。最后匹配成功的依赖关系将被传入 NormalModuleFactory
  contextModuleFactory: new SyncHook(["contextModulefactory"]),

  // 在创建 compilation parameter 之后执行,此钩子可用于添加/修改 compilation parameter
  beforeCompile: new AsyncSeriesHook(["params"]),

  // beforeCompile 之后立即调用,但在一个新的 compilation 创建之前
  // 该事件是为了告诉插件一次新的编译将要启动,同时会给插件带上 compiler 对象
  compile: new SyncHook(["params"]),

  // compilation 结束之前执行,一个新的 Compilation 创建完毕,即将从 Entry 开始读取文件,根据文件类型和配置的 Loader 对文件进行编译,编译完后再找出该文件依赖的文件,递归的编译和解析
  make: new AsyncParallelHook(["compilation"]),

  // 一次 Compilation 执行完成,compilation 结束和封印之后执行
  afterCompile: new AsyncSeriesHook(["compilation"]),

  // 在监听模式下,一个新的 compilation 触发之后,但在 compilation 实际开始之前执行
  // 和 run 类似,区别在于它是在监听模式下启动的编译,在这个事件中可以获取到是哪些文件发生了变化导致重新启动一次新的编译
  watchRun: new AsyncSeriesHook(["compiler"]),

  // 在 compilation 失败时调用
  // 如果在编译和输出流程中遇到异常导致 Webpack 退出时,就会直接跳转到本步骤,插件可以在本事件中获取到具体的错误原因
  failed: new SyncHook(["error"]),

  // 在一个观察中的 compilation 无效时执行
  invalid: new SyncHook(["filename", "changeTime"]),

  // 在一个观察中的 compilation 停止时执行
  watchClose: new SyncHook([]),

  // 在配置中启用 infrastructureLogging 选项 后,允许使用 infrastructure log(基础日志)
  infrastructureLog: new SyncBailHook(["origin", "type", "args"]),

  // 在编译器准备环境时调用,时机就在配置文件中初始化插件之后
  // 开始应用 Node.js 风格的文件系统到 compiler 对象,以方便后续的文件寻找和读取
  environment: new SyncHook([]),

  // 当编译器环境设置完成后,在 environment hook 后直接调用
  afterEnvironment: new SyncHook([]),

  // 在初始化内部插件集合完成设置之后调用
  // 调用完所有内置的和配置的插件的 apply 方法,启用插件
  afterPlugins: new SyncHook(["compiler"]),

  // resolver 设置完成之后触发
  // 根据配置初始化完 resolver,resolver 负责在文件系统中寻找指定路径的文件
  afterResolvers: new SyncHook(["compiler"]),

  // 在 webpack 选项中的 entry 被处理过之后调用
  // 读取配置的 Entrys,为每个 Entry 实例化一个对应的 EntryPlugin,为后面该 Entry 的递归解析工作做准备
  entryOption: new SyncBailHook(["context", "entry"]),
};

所有周期函数实例化完毕后就使用_pluginCompat注册了一个Compailer钩子,这个用来兼容之前的老版 webpack 的 plugin 的钩子,触发时机在 tapable/lib/Tapable.js 里调用 plugin 的时候,主要就是通过Tapable中以下方法实现的,最终插件都会挂载到 this.hooks

// Tapable.js

Tapable.addCompatLayer = function addCompatLayer(instance) {
  Tapable.call(instance);
  instance.plugin = Tapable.prototype.plugin;
  instance.apply = Tapable.prototype.apply;
};

Tapable.prototype.plugin = util.deprecate(function plugin(name, fn) {
  if (Array.isArray(name)) {
    name.forEach(function (name) {
      this.plugin(name, fn);
    }, this);
    return;
  }
  const result = this._pluginCompat.call({
    name: name,
    fn: fn,
    names: new Set([name]),
  });
  if (!result) {
    throw new Error(
      `Plugin could not be registered at '${name}'. Hook was not found.\n` +
        "BREAKING CHANGE: There need to exist a hook at 'this.hooks'. " +
        "To create a compatibility layer for this hook, hook into 'this._pluginCompat'."
    );
  }
}, "Tapable.plugin is deprecated. Use new API on `.hooks` instead");

Tapable.prototype.apply = util.deprecate(function apply() {
  for (var i = 0; i < arguments.length; i++) {
    arguments[i].apply(this);
  }
}, "Tapable.apply is deprecated. Call apply on the plugin directly instead");

以上主要就是做了两个事情:注册插件兼容的钩子 _pluginCompat,将 webpack.plugin 注册的钩子挂载到 Compiler.hooks 上面去;Compiler.hooks 上面挂载一堆 Compiler 生命周期钩子。

compiler 上面挂载了几个主要的方法:run、compile、readRecords 等后面在执行run的时候介绍。

3. 挂载配置项 options 和插件 plugins 至 compiler

// node_modules/webpack/lib/webpack.js

function webpack() {
  ...

  compiler.options = options;

  if (options.plugins && Array.isArray(options.plugins)) {
    for (const plugin of options.plugins) {
      if (typeof plugin === "function") {
        plugin.call(compiler, compiler);
      } else {
        plugin.apply(compiler);
      }
    }
  }

  ...
}

将扩展后的 options 挂载至实例 compiler 上,然后挂载插件,如果插件是一个函数的话直接执行,this 和参数都是实例 compiler ,如果插件是一个对象的话直接调用它的 apply 方法挂载至 compiler。

4. 添加文件读写和监听的能力

// node_modules/webpack/lib/webpack.js

function webpack() {
  ...

  new NodeEnvironmentPlugin({
    infrastructureLogging: options.infrastructureLogging,
  }).apply(compiler);

  ...
}

NodeEnvironmentPlugin 是一个扩展为node运行环境的插件,为实例compiler添加文件读写和文件监听的能力:

// node_modules/webpack/lib/node/NodeEnvironmentPlugin.js

apply(compiler) {
  // 日志记录
  compiler.infrastructureLogger = createConsoleLogger(
    Object.assign(
      {
        level: "info",
        debug: false,
        console: nodeConsole,
      },
      this.options.infrastructureLogging
    )
  );
  // 添加文件读能力
  compiler.inputFileSystem = new CachedInputFileSystem(
    new NodeJsInputFileSystem(),
    60000
  );

  const inputFileSystem = compiler.inputFileSystem;

  // 添加文件写能力
  compiler.outputFileSystem = new NodeOutputFileSystem();

  // 添加文件监听的能力(在读取文件的时候如果发现文件发生改变进行重新编译)
  compiler.watchFileSystem = new NodeWatchFileSystem(compiler.inputFileSystem);
  // run之前重置inputFileSystem
  compiler.hooks.beforeRun.tap("NodeEnvironmentPlugin", (compiler) => {
    if (compiler.inputFileSystem === inputFileSystem) inputFileSystem.purge();
  });
}

5. 挂载一堆内置插件和外部插件

// node_modules/webpack/lib/webpack.js

function webpack() {
  ...

  compiler.options = new WebpackOptionsApply().process(options, compiler);

  ...
}

其中关键的是对 entry 的处理:

// node_modules/webpack/lib/WebpackOptionsApply.js

/**
 * @param {WebpackOptions} options options object
 * @param {Compiler} compiler compiler object
 * @returns {WebpackOptions} options object
 */
process(options, compiler) {
  
  ...

  new EntryOptionPlugin().apply(compiler);
  compiler.hooks.entryOption.call(options.context, options.entry);

  ...

  return options;
}

我们看看 EntryOptionPlugin 做了什么:

// node_modules/webpack/lib/EntryOptionPlugin.js

"use strict";
// 依赖导入,主要是三种入口文件加载方式:单入口、多入口、动态入口

const SingleEntryPlugin = require("./SingleEntryPlugin");
const MultiEntryPlugin = require("./MultiEntryPlugin");
const DynamicEntryPlugin = require("./DynamicEntryPlugin");

/**
 * 判断是单入口或者多入口
 * @param {string} context context path
 * @param {EntryItem} item entry array or single path
 * @param {string} name entry key name
 * @returns {SingleEntryPlugin | MultiEntryPlugin} returns either a single or multi entry plugin
 */
const itemToPlugin = (context, item, name) => {
  // 数组的话就是多入口,字符串就是单入口
  if (Array.isArray(item)) {
    return new MultiEntryPlugin(context, item, name);
  }
  return new SingleEntryPlugin(context, item, name);
};

module.exports = class EntryOptionPlugin {
  /**
   * @param {Compiler} compiler the compiler instance one is tapping into
   * @returns {void}
   */
  apply(compiler) {
    compiler.hooks.entryOption.tap("EntryOptionPlugin", (context, entry) => {
      // 如果是字符串或者数组的话就进行单入口和多入口判断,生成对应的插件实例
      if (typeof entry === "string" || Array.isArray(entry)) {
        itemToPlugin(context, entry, "main").apply(compiler);
      } else if (typeof entry === "object") {
        // 如果是对象的话就遍后进行判断,然后生成对应的插件实例
        for (const name of Object.keys(entry)) {
          itemToPlugin(context, entry[name], name).apply(compiler);
        }
      } else if (typeof entry === "function") {
        // 如果是一个函数的话可能就是动态导入,生成动态导入的插件实例
        new DynamicEntryPlugin(context, entry).apply(compiler);
      }
      return true;
    });
  }
};

调用 EntryOptionPlugin 实例的 apply 方法时会在 entryOption 钩子上注册一个 EntryOptionPlugin 函数,紧接着就去执行这个钩子函数,对不同的入口文件生成不同的插件实例。

终上所述,我们可以看出webpack的工作主流程就是:扩展options -> 实例化compiler -> 将用户配置的options和plugins挂载至compiler -> 添加compiler的文件读写和监听能力 -> 挂载内置插件和外部插件,最终返回一个compiler实例,调用 compiler 的 run 方法就会开始编译打包。