likes
comments
collection
share

webpack 懒加载(异步加载)原理

作者站长头像
站长
· 阅读数 17

在 JS 模块中,我们可以通过 import() 语法动态导入某个模块,也就是懒加载模块,那么 webpack 是如何处理懒加载的呢?

demo 演示

我们先来写一个 demo

// .src/a.js
export default () => {
  console.log("Jolyne");
};

// .src/main.js
const buttonEle = document.getElementById("button");
buttonEle.onclick = function () {
  //懒加载 a 模块
  import("./a").then((module) => {
    const callback = module.default;
    callback();
  });
};

// .src/index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <button id="button">点击按钮懒加载</button>
  </body>
  <script src="../dist/main.js"></script>
</html>

webpack 配置:

// webpack.config.js
module.exports = {
  mode: "development",
  entry: "./src/main.js",
  devtool: "source-map",
}

package.json 脚本

// package.json
{
    "scripts": {
        "build": "webpack --config ./webpack.config.js --env production",
    },
}

我们执行 npm run build 后,得到如下的文件:

webpack 懒加载(异步加载)原理

其中,src_a_js.js 就是通过 import("./a") 懒加载的模块

我们在浏览器中打开 index.html,打开控制台:

webpack 懒加载(异步加载)原理

此时只加载了 main.js,且控制台没有任何输出。当我点击按钮后,才会执行 src_a_js.js 模块,控制台打印 Jolyne

webpack 懒加载(异步加载)原理

webpack 懒加载(异步加载)原理

原理解析

我们打包代码后,在 ./dist/main.js 里面能找到如下的代码:

webpack 懒加载(异步加载)原理

我们发现他会分为三个步骤:

  • 调用 __webpack_require_.e("src_a_js"),返回 Promise
  • 调用 webpack\_require.bind(webpack\_require, "./src/a.js")
  • 通过 module.default 拿到 () => { console.log("Jolyne") } 并执行该 default 函数

我们来拆解他们分别做了什么

_webpack_require.e

这个函数做的事情可以概括为:

  • 第一部分:通过 Jsonp 的形式去加载 懒加载的模块
  • 第二部分:执行该模块,将该模块的代码合并到同步模块中

我们来看他是如何通过 JSONP 加载异步模块的:

//这个变量代表已经加载完毕的模块
var installChunks = {
    main: 0  // ./dist/main.js 这个模块已经加载完毕了
}

//这个变量存储:所有同步模块,demo里面没有同步模块,所以当前是个空对象
var modules = {}

const __webpack_require_.e = (chunkId) => {
    /**
        第一部分:JSONP 加载模块
    **/
    const promises = []
    const currentPromise = new Promise((resolve, reject) => {
        installChunks[chunkId] = [resove, reject]
    })
    promises.push(currentPromise)
    
    const url = require.publicPath + chunkId //这个 url 就是拼接了 ./dist/src_a_js.js 的路径
    const srcipt = document.createElement("script")
    srcipt.src = url
    document.head.appendChild(script)
    
    
    /**
        第二部分:执行模块,合并到同步模块中
    **/
    //这个函数我们分析第二步的时候再细说
    webpackJsonpCallback()
    
    
    return Promise.all(promises)
}

首先,第一部分的代码的逻辑是:

  • 创建一个 promises 数组
  • 创建一个 promise 对象,以 chunkId(当前要去加载的这个异步模块的Id)为 key,将 promise 对象 的 resolvereject 回调记录在 installChunks[chunkId] 上,此时: installChunks = { main: 0, src_a_js: [resolve, reject] }
  • 拼接 url,创建 script 标签,设置 srcipt.src = url,然后加载 url 指向的模块

至于为什么要创建一个 Promise 对象,又要记录它的 resolve、reject 回调,我们在 第二部分 里面分析

第一部分加载回来的模块长这样:

(self["webpackChunkwebpack_module"] =
  self["webpackChunkwebpack_module"] || []).push([
  ["src_a_js"],
  {
    "./src/a.js": (
      __unused_webpack_module,
      __webpack_exports__,
      __webpack_require__
    ) => {
      __webpack_require__.r(__webpack_exports__);
      __webpack_require__.d(__webpack_exports__, {
        default: () => __WEBPACK_DEFAULT_EXPORT__,
      });
      const __WEBPACK_DEFAULT_EXPORT__ = () => {
        console.log("Jolyne");
      };
    },
  },
]);

然后需要执行该模块,那么如何执行呢?此时就会调用一个叫做 webpackJsonpCallback 的函数


//...第一部分的代码

/** 
    chunkIds 就是上面 ["src_a_js"]
    moreModules 就是上面的: 
        {
          "./src/a.js": (
            __unused_webpack_module,
            __webpack_exports__,
            __webpack_require__
          ) => {
            __webpack_require__.r(__webpack_exports__);
            __webpack_require__.d(__webpack_exports__, {
              default: () => __WEBPACK_DEFAULT_EXPORT__,
            });
            const __WEBPACK_DEFAULT_EXPORT__ = () => {
              console.log("Jolyne");
            };
          },
        },
**/
const webpackJsonpCallback = (chunkIds, moreModules) => {
    const resolves = []
    
    for (moduleId in moreModules) {
        // modules 是第一部分里面存储所有同步模块的那个变量
        // 相当于把异步模块的代码合并到 modules 上
        modules[moduleId] = moreModules[moduleId]
    }
    
    for (let i = 0; i < chunkIds.length; i++) {
        // 还记得之前我们将 Promise 对象的 resolve、reject 回调记录在 installChunks 上吗
        // 这里我们就是去取出 resolve 回调,放到 resolves 数组中去
        resolves.push(installChunks[chunkIds[i]][0])
        // 标识当前这个懒加载的模块已经执行完毕了
        installChunks[chunkIds[i]] = 0 
    }
    
    while (resolves.length) {
        // 第一部分我们不是 return Promise.all(promises) 吗?
        // 这里相当于去执行 promises 里面所有的 resolve 回调
        // 当 所有的 resolve 回调执行完毕之后,表明所有的懒加载模块都加载且合并完毕了
        resolves.shift()()
    }
    
    // 执行到这里,__webpack_require_.e 这个函数才执行完毕
}

上面的代码的思路为:

  • 创建 resolves 数组,用来存放第一部分我们创建的 Promise 对象的被记录的 resolve 回调
  • 将懒加载的模块代码 合并到 modules 这个变量上
  • 标识 installChunks[moduleId] = 0,表明懒加载的模块已经合并完成了
  • 执行 resolves 数组里面所有的 resolve 回调,这样第一部分中 return Promise.all(promises) 才会继续下一步

此时 modules 变量为:

const modules = {
    "./src/a.js": (modules, exports, require) => {
      require.defineProperty(exports, {
        default: () => WEBPACK_DEFAULT_EXPORT,
      });
      const WEBPACK_DEFAULT_EXPORT = () => {
        console.log("按钮点击了");
      };
    },
};

看完第二部分的操作后,我们来回答一下第一部分我们抛出的问题: 为什么要创建一个 Promise 对象,又要记录它的 resolve、reject 回调

创建 Promise 对象是为了能跟踪懒加载模块的状态(是否正在加载?是否加载完毕?),记录 resolve、reject 回调 是为了确保懒加载模块加载完毕且已经合并了代码,此时执行 resolve() 改变 Promise 的状态,第一部分的 return Promise.all(promises) 成功后,才继续下一步操作

至此,__webpack_require_e 函数执行完毕

webpack_require.bind(webpack_require, "./src/a.js")

这一步其实就是对 exports 对象做代理,然后 return exports 对象

const webpack_cache = {};

function require(moduleId) {
  var cache = webpack_cache[moduleId];
  if (cache !== undefined) {
    return cache.exports;
  }
  var module = (webpack_cache[moduleId] = {
    exports: {},
  });
  
  // 对 exports 对象做代理
  modules[moduleId](module, module.exports, require);
  // 返回 exports 对象
  return module.exports;
}

require.defineProperty = (exports, definition) => {
  for (var key in definition) {
    Object.defineProperty(exports, key, {
      enumerable: true,
      get: definition[key],
    });
  }
};

这一部分可以去看 # webpack模块化原理解析,这里不细说了

然后抛出的 exports 对象就可以在第三步拿到了,也就是:

const buttonEle = document.getElementById("button");

  buttonEle.onclick = function () {
    //第一步
    __webpack_require__.e(/*! import() */ "src_a_js")
      .then(
        __webpack_require__.bind(__webpack_require__, /*! ./a */ "./src/a.js")
      )
      .then((module) => {
        //在这里,就能拿到 exports 对象了
        const callback = module.default;
        callback(); // Jolyne
      });
  };

总结

webpack 模块懒加载的原理:

  • 创建 Promise 对象(用来跟踪懒加载模块的状态),以模块Id 为 key,记录 [resolve、reject] 回调(确保模块加载合并完毕
  • 拼接 url,创建 script 标签,以 JSONP 的形式加载模块,然后执行 webpackJsonpCallback函数,合并模块代码到 modules`(modules是记录所有同步模块的变量),完毕后执行 resolve 回调
  • 对 exports 对象做代理,返回 exports 对象
  • 最后从 exports 对象身上拿到 default 函数,执行并打印结果