leeword / blog

总结 沉淀 分享
3 stars 0 forks source link

【webpack】解析 `webpackBootstrap` 启动代码 #3

Open leeword opened 3 years ago

leeword commented 3 years ago

webpack 是一个常用的模块打包器,使用 webpack 将项目代码打包编译后,除业务代码外,还有一部分,则是启动引导程序(webpackBootstrap),它维护着 webpack 内部使用的变量和模块加载逻辑,本文将对这部分内容分析,讲一讲我的理解。

新建工程

首先新建 webpack 工程,将 webpackBootstrap 的内容构建到本地:

  1. 初始化一个文件夹,在根目录执行 npm init -y命令, 生成 package.json 文件,用于维护项目依赖;

  2. 安装 webpackwebpack-cli (以下使用 webpack 4 为例):

    npm i webpack webpack-cli -D

    webpack 默认读取项目根目录下的 webpack.config.js作为配置文件。

    mode 设置为 nonemode 即为模式,配置 mode 的不同值,webpack 会在开发环境或生成环境下,默认启用一些插件,来减少使用者的配置负担。配置 none 则表示,不使用任何 webpack 的任何默认优化,我们研究下最原始版本即可:

  3. 简单添加一些项目业务代码,方便观察打包内容的变化,示例如下:

    src/index.js src/utils

    这里逻辑比较简单,/src/utils.js 导出了两个函数,然后 /src/index.jsutils文件导入了 add 函数并执行了它。

  4. 接下来,我们就可以使用 webpack 对项目工程打包了。在命令行运行下方命令:

    npx webpack
  5. 完成上述步骤后,项目根目录会多出一个文件夹, dist 目录是webpack打包资源的默认输出目录,/dist/main.js 则是默认输出的文件路径:

    image

webpack bootStrap

进行到这里,本篇文章的主角正式登场,我们重点分析下 main.js 的内容,我把它贴出来,下文简称这部分为webpackBootstrap

image

生成的代码并不算特别长,算上注释和空行才100多行,从大致逻辑可以看出,代码主体是一个自执行的 IIFE 函数,我们逐步拆解内容:

(function(modules) {
  // webpack 启动引导代码

})([
  (function(module, __webpack_exports__, __webpack_require__) {
    // 模块 id: 0 对应的业务代码
    // src/index.js 对应代码
  }),
  (function(module, __webpack_exports__, __webpack_require__) {
    // 模块 id: 1 对应的业务代码
    // src/utils.js 对应代码
  }),
]);

打包阶段,webpack 会将我们的业务代码进行包装,以入口文件为例:

(function(module, __webpack_exports__, __webpack_require__) {
    // src/index.js 中的代码
}),

业务代码被放入函数体内延迟执行,很好的将业务代码模块的作用域隔离开来。这种做法并不新颖,它借鉴了 NodeJS 代码的执行方式。

webpack 内部,每个业务模块使用唯一 id 表示,即 moduleId 。按现有配置 build 出的资源,模块被一一放置在数组里,数组索引具有唯一性,用于表示 moduleId

模块被包装后,放在数组里,然后被当成参数传入了 IIFE 函数体。

我们先将不必要的代码剔除掉,只保留主干代码:

(function(modules) {
  // 保存已加载的模块
  var installedModules = {};

  // webpack 模块加载函数
  function __webpack_require__(moduleId) {
    // 判断待加载模块是否已有缓存
    if (installedModules[moduleId]) {
        // 如果存在缓存, 返回该模块输出的变量
        return installedModules[moduleId].exports;
    }
    var module = installedModules[moduleId] = {
      // 模块id
      i: moduleId,
      // 保存模块导出的变量
      exports: {}
    };
    // 执行模块包装函数,将三个变量当作参数传入,将模块导出的变量保存在 module.exports 中
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    // 标记模块已加载
    module.l = true;

    // 若模块初次加载, 返回模块导出的内容
    return module.exports;
  }

  // 从入口模块开始执行代码
  return __webpack_require__(__webpack_require__.s = 0);
})([
  (function(module, __webpack_exports__, __webpack_require__) {
    /* 模块 Id: 0 对应的代码 */
  }),
  (function(module, __webpack_exports__, __webpack_require__) {
    /* 模块 Id: 1 对应的代码 */
  }),
  ...其他模块代码
]);

代码做了注释帮助读者理解这部分代码,其中__webpack_require__webpack 中的模块加载函数,本篇着重讲解这部分内容。

上文有提到,webpack 模块借鉴了 NodeJS 的包装方式,我们可以把__webpack_require__() 想象成 NodeJS 中的模块引入函数require()require()的参数是模块路径,__webpack_require__则需要 moduleId

IIFE 函数体的最后一行:

return __webpack_require__(__webpack_require__.s = 0);

执行了__webpack_require__ 函数并传入参数0,从这里,打包代码正式执行,加载执行模块0

然后执行权转移到 __webpack_require__ 函数内部,加载执行后的代码模块会保存在 installedModules

  1. 首先会进行判断,假如installedModules 存在传入的 moduleId ,则直接返回该模块导出的变量,流程结束:

    image

  2. 如果不存在,则以 moduleId 为键名,初始化一个值,保存一些该模块的信息,imoduleIdl 代表模块是否加载,exports存储模块对外暴露的变量:

    image

  3. 紧接着,以 module.exportsthis 值,执行模块包装函数,并传入三个变量,假如该模块有对外导出的值,将会在函数执行过程中,把对外暴露的接口存储在 module.exports 变量内:

    image

  4. 模块内部代码执行完毕,将模块标记为已加载:

    image

  5. 将模块对外导出的变量返回:

    image

模块定义对外接口

模块对外暴露的接口,是如何与 webpack 内部的变量联系到一起的呢? 还记得utils.js对外暴露了两个方法吧,我们来看下编译后模块Id为1的代码(对应源文件/src/utils.js):

image

源代码中以ES module 定义导出的接口,则被编译成了 __webpack_require__.d 函数。

包装函数的第二个参数__webpack_exports__ 即为installedModules[moduleId].exports,初始化时是一个空对象,代码如下:

image

ES module 模块是静态的,在编译阶段就可以确定模块的依赖顺序。

不过为了兼容浏览器环境和 NodeJS 程序,webpack 会将原生模块导出功能,打包成运行时的导出,每个模块在首次执行时(modules[moduleId].call(...)),通过 __webpack_require__.d 函数,将模块对外暴露的方法,定义为了module.exports的同名对象,这样外部程序就能通过 getter 访问到模块内部的接口了:

// Object.prototype.hasOwnProperty.call
__webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };

// define getter function for harmony exports
// 翻译:为原生 exports 定义 getter 函数
__webpack_require__.d = function(exports, name, getter) {
  // 若 exports 上找不到 [name] 的 key
  if(!__webpack_require__.o(exports, name)) {
    // 在 exports 上定义 key [name] 的 getter 访问函数
    Object.defineProperty(exports, name, { enumerable: true, get: getter });
  }
};

加载引用外部模块

__webpack_require__ 不仅是模块执行器,而且是模块加载器

比如 src/index.js导入了add方法,我们看一下编译后的代码:

image

在该模块被执行时,__webpack_require__被当作参数传入模块内部,如上所示,使用 __webpack_require__(1) 得到 moduleId1的模块(src/utils)对外暴露的代码,它返回了模块1exports 引用,然后就可以使用它对外暴露的接口了:Object(_utils__WEBPACK_IMPORTED_MODULE_0__["add"])(1, 2)

__webpack_require__的其他属性

JavaScript 中,函数是一个特殊的对象,除上文提到的内容,__webpack_require__也提供了几个静态属性:

具体实现参考 webpackBootstrap

小结

理解 webpackBootstrap,是理解 webpack 工程的基础。讲到这里,很多问题都有了答案,比如为了提升hash 稳定性,为什么要把 moduleId数组索引替换为模块路径

上文讲解了同步模块的保存、引用、加载逻辑,如果项目内部使用import()对项目代码做了动态分割,webpackBootstrap 内容会有变化么?这部分留给读者自行探索了。