Webpack 是怎样运行的?(一)

5,680 阅读6分钟

Webpack 是时下最流行的前端打包工具,它打包开发代码,输出能在各种浏览器运行的代码,提升了开发至发布过程的效率。

你可能已经知道,这种便捷是由 Webpack 的插件系统带来的,但我们今天先把这些概念放在一边,从简单的实践开始,探索 Webpack 打包出的代码是如何在浏览器环境运行的。

简单配置

配置文件是使用 Webpack 的关键,一份配置文件主要包含入口(entry)、输出文件(output)、模式、Loader、插件(Plugin)等几个部分,但如果只需要组织 JS 文件的话,指定入口和输出文件路径即可完成一个迷你项目的打包:

项目目录:

  • build
    • webpack.config.js -- 存放 webpack 配置对象
  • src
    • index.js -- 源文件
  • package.json -- 本文使用 webpack ^4.23.0 作示例

为了更好地观察产出的文件,我们将模式设置为 development 关闭代码压缩,再开启 source-map 支持原始源代码调试。

配置文件 build/webpack.config.js

const path = require('path');
const resolve = relativePath => path.resolve(__dirname, relativePath);

module.exports = {
  mode: 'development',
  devtool: 'source-map',
  entry: resolve('../src/index.js'),
  output: {
    path: resolve('../dist'),
  }
};

源文件 src/index.js

document.writeln('Hello webpack!');

现在我们运行命令 webpack --config build/webpack.config.js ,打包完成后会多出一个输出目录 dist:

  • build
    • webpack.config.js
  • dist
    • main.js
  • src
    • index.js
  • package.json

main 是 webpack 默认设置的输出文件名,我们快速瞄一眼这个文件:

dist/main.js

(function(modules){
  // ...
})({
  "./src/index.js": (function(){
    // ...
  })
});

整个文件只含一个 立即执行函数(IIFE),我们称它为 webpackBootstrap,它仅接收一个对象 —— 未加载的 模块集合(modules),这个 modules 对象的 key 是一个路径,value 是一个函数。你也许会问,这里的模块是什么?它们又是如何加载的呢?

模块

别着急,在细看产出代码前,我们先丰富一下源代码:

项目目录:

  • build
    • webpack.config.js
  • src
    • utils
      • math.js
    • index.js
  • package.json

新文件 src/utils/math.js

export const plus = (a, b) => {
  return a + b;
};

export const minus = (a, b) => {
  return a - b;
};

src/index.js

import {plus, minus} from './utils/math.js';

document.writeln('Hello webpack!');
document.writeln('1 + 2: ', plus(1, 2));
document.writeln('1 - 2: ', minus(1, 2));

我们按照 ES 规范的模块化语法写了一个简单的模块 src/utils/math.js,给 src/index.js 引用。目前,虽然各大浏览器开始支持通过 <script type="module"> 的方式支持 ES6 Module,但还需时间覆盖。Webpack 用自己的方式支持了 ES6 Module 规范,前面提到的 module 就是和 ES6 module 对应的概念。

接下来我们看一下这些模块是如何通 ES5 代码实现的。再次运行命令 webpack --config build/webpack.config.js 后查看输出文件:

dist/main.js

(function(modules){
  // ...
})({
  "./src/index.js": (function(){
    // ...
  }),
  "./src/utils/math.js": (function() {
    // ...
  })
});

IIFE 传入的 modules 对象里多了一个键值对,对应着新模块 src/utils/math.js,这和我们在源代码中拆分的模块互相呼应。然而,有了 modules 只是第一步,这份文件最终达到的效果应该是让各个模块按开发者编排的顺序运行。

探究 webpackBootstrap

接下来看看 webpackBootstrap 函数中有些什么:

// webpackBootstrap
(function(modules){

  // 缓存 __webpack_require__ 函数加载过的模块
  var installedModules = {};
  
  /**
   * Webpack 加载函数,用来加载 webpack 定义的模块
   * @param {String} moduleId 模块 ID,一般为模
            块的源码路径,如 "./src/index.js"
   * @returns {Object} exports 导出对象
   */
  function __webpack_require__(moduleId) {
    // ...
  }

  // 在 __webpack_require__ 函数对象上挂载一些变量
  // 及函数 ...

  // 传入表达式的值为 "./src/index.js"
  return __webpack_require__(__webpack_require__.s = "./src/index.js");
})(/* modules */);

可以看到其实主要做了两件事:

  1. 定义一个模块加载函数 __webpack_require__
  2. 使用加载函数加载入口模块 "./src/index.js"

整个 webpackBootstrap 中只出现了入口模块的影子,那其他模块又是如何加载的呢?我们顺着 __webpack_require__("./src/index.js") 细看加载函数的内部逻辑:

// ...

function __webpack_require__(moduleId) {
  // 重复加载则利用缓存
  if (installedModules[moduleId]) {
    return installedModules[moduleId].exports;
  }

  // 如果是第一次加载,则初始化模块对象,并缓存
  var module = installedModules[moduleId] = {
    i: moduleId,  // 模块 ID
    l: false,     // 模块加载标识
    exports: {}   // 模块导出对象
  };

  /**
    * 执行模块
    * @param module.exports -- 模块导出对象引用,改变模块包裹函数内部的 this 指向
    * @param module -- 当前模块对象引用
    * @param module.exports -- 模块导出对象引用
    * @param __webpack_require__ -- 用于在模块中加载其他模块
    */
  modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

  // 模块加载标识置为已加载
  module.l = true;

  // 返回当前模块的导出对象引用
  return module.exports;
}

// ...

首先,加载函数使用了闭包变量 installedModules,用来将已加载过的模块保存在内存中。 接着是初始化模块对象,并把它挂载到缓存里。然后是模块的执行过程,加载入口文件时 modules[moduleId] 其实就是 ./src/index.js 对应的模块函数。执行模块函数前传入了跟模块相关的几个实参,让模块可以导出内容,以及加载其他模块的导出。最后标识该模块加载完成,返回模块的导出内容。

根据 __webpack_require__ 的缓存和导出逻辑,我们得知在整个 IIFE 运行过程中,加载已缓存的模块时,都会直接返回 installedModules[moduleId].exports,换句话说,相同的模块只有在第一次引用的时候才会执行模块本身。

模块执行函数

__webpack_require__ 中通过 modules[moduleId].call() 运行了模块执行函数,下面我们就进入到 webpackBootstrap 的参数部分,看看模块的执行函数。


// webpackBootstrap
(function(modules){

  // ...

})({

  /*** 入口模块 ./src/index.js ***/
  "./src/index.js": (function (module, __webpack_exports__, __webpack_require__) {
    "use strict";

    // 用于区分 ES 模块和其他模块规范,不影响理解 demo,战略跳过。
    __webpack_require__.r(__webpack_exports__);

    // 源模块代码中,`import {plus, minus} from './utils/math.js';` 语句被 loader 解析转化。
    // 加载 "./src/utils/math.js" 模块,
    /* harmony import */ var _utils_math_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./utils/math.js */ "./src/utils/math.js");

    document.writeln('Hello webpack!');
    document.writeln('1 + 2: ', Object(_utils_math_js__WEBPACK_IMPORTED_MODULE_0__["plus"])(1, 2));
    document.writeln('1 - 2: ', Object(_utils_math_js__WEBPACK_IMPORTED_MODULE_0__["minus"])(1, 2));
  }),

  /*** 工具模块 ./src/utils/math.js ***/
  "./src/utils/math.js": (function(module, __webpack_exports__, __webpack_require__) {
    "use strict";

    // 同 "./src/index.js"
    __webpack_require__.r(__webpack_exports__);

    // 源模块代码中,`export` 语句被 loader 解析转化。
    // 导出 __webpack_exports__
    /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "plus", function() { return plus; });
    /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "minus", function() { return minus; });
    const plus = (a, b) => {
      return a + b;
    };

    const minus = (a, b) => {
      return a - b;
    };
  })
});

执行顺序是:入口模块 -> 工具模块 -> 入口模块。入口模块中首先就通过 __webpack_require__("./src/utils/math.js") 拿到了工具模块的 exports 对象。再看工具模块,ES 导出语法转化成了__webpack_require__.d(__webpack_exports__, [key], [getter]),而 __webpack_require__.d 函数的定义在 webpackBootstrap 内:

// ...

  // 定义 exports 对象导出的属性。
  __webpack_require__.d = function (exports, name, getter) {

    // 如果 exports (不含原型链上)没有 [name] 属性,定义该属性的 getter。
    if (!__webpack_require__.o(exports, name)) {
      Object.defineProperty(exports, name, {
        enumerable: true,
        get: getter
      });
    }
  };

  // 包装 Object.prototype.hasOwnProperty 函数。
  __webpack_require__.o = function (object, property) {
    return Object.prototype.hasOwnProperty.call(object, property);
  };

// ...

可见 __webpack_require__.d 其实就是 Object.defineProperty 的简单包装(怪不得叫 d 呢)。

回顾一下,__webpack_exports__ 原本在 __webpack_require__ 中创建,初始值为 {}。这个导出对象一路传到工具模块 math.js 中,被添加上 plusminus,然后又在 __webpack_require__ 函数最后导出,为入口模块 index.js 的执行函数所用。

exports 的一生:

exports 的一生

引用工具模块导出的变量后,入口模块再执行它剩余的部分。至此,Webpack 基本的模块执行过程就结束了。

以上内容可克隆示例代码库调试,分支为 demo1

除了 ES6 Module 规范,Webpack 同样支持 CommonJS 与 AMD 规范,你可以替换模块化规范,重新打包来观察它们的区别。

小结

好了,我们用流程图总结一下 Webpack 模块的加载思路:

webpack-module-implementation-sync

参考

Webpack 术语表 - Module