【Webpack】异步加载(懒加载)原理

10,239 阅读9分钟

一、前言

本文是 从零到亿系统性的建立前端构建知识体系✨ 中的第二篇,整体难度 ⭐️⭐️。

承接上文(从构建产物洞悉模块化原理),本文将继续从分析构建产物出发,探索 Webpack 中异步加载(懒加载)的原理,最后将彻底弄清楚懒加载是如何做到能够加快应用初始加载速度的,整体深度阅读时间约15分钟。

在正式开始之前我们先看看几个常见的相关面试题:

  • 在Webpack搭建的项目中,如何达到懒加载的效果?
  • 在Webpack中常用的代码分割方式有哪些?
  • Webpack中懒加载的原理是什么?
  • ......

相信读完本文,你对上面的一系列问题都能够轻松解答。

二、前置知识

在正式内容开始之前,先来学一些预备小知识点,以免影响后面的学习。

懒加载或者按需加载,是一种很好的优化网页或应用的方式。这种方式实际上是先把你的代码在一些逻辑断点处分离开,然后在一些代码块中完成某些操作后,立即引用或即将引用另外一些新的代码块。这样加快了应用的初始加载速度,减轻了它的总体体积,因为某些代码块可能永远不会被加载。

懒加载的本质实际上就是代码分离。把代码分离到不同的 bundle 中,然后按需加载或并行加载这些文件

在Webpack中常用的代码分离方法有三种:

  • 入口起点:使用 entry 配置手动地分离代码。
  • 防止重复:使用 Entry dependencies 或者 SplitChunksPlugin 去重和分离 chunk。
  • 动态导入:通过模块的内联函数调用来分离代码。

今天我们的核心主要是第三种方式:动态导入

当涉及到动态代码拆分时,Webpack 提供了两个类似的技术:

  • 第一种,也是推荐选择的方式是,使用符合 ECMAScript 提案 的 import()语法 来实现动态导入
  • 第二种,则是 Webpack 的遗留功能,使用 Webpack 特定的 require.ensure (不推荐使用) ,本文不做探讨

我们主要看看 import()语法 的方式。

import() 的语法十分简单。该函数只接受一个参数,就是引用模块的地址,并且使用 promise 式的回调获取加载的模块。在代码中所有被 import() 的模块,都将打成一个单独的模块,放在 chunk 存储的目录下。在浏览器运行到这一行代码时,就会自动请求这个资源,实现异步加载。

常见使用场景:路由懒加载。

三、统一配置

为了防止出现我可以你不可以的情况,我们先统一配置:

  "webpack": "^5.73.0",
  "webpack-cli": "^4.10.0",

webpack.config.js 配置:

module.exports = {
  mode: "development",
  devtool: false,
  entry: {
    main: "./src/main.js",
  },
  output: {
    filename: "main.js", //定义打包后的文件名称
    path: path.resolve(__dirname, "./dist"), //必须是绝对路径
  },
};

四、import()基本使用

我们先来看看使用 import() 异步加载的效果。

在 main.js 中同步导入并使用:

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

buttonEle.onclick = function () {
  import("./test").then((module) => {
    const print = module.default;
    print();
  });
};

test.js:

export default () => {
  console.log("按钮点击了");
};

先看打包结果:将 main.js 和 test.js 打包成了两个文件(说明有做代码分割)。

image.png

将打包后的文件在 index.html 中引入(注意这里只引用了 main.js ,并没有引用 src_test_js.main.js ):

<!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="./main.js"></script>
</html>

将 index.html 在浏览器中打开,查看网络请求:

jvd1c-z85av.gif

发现首次并没有加载 src_test_js.main.js 文件(也就是 test.js 模块),在点击按钮后才会加载。符合懒加载的预期,确实有帮助我们做异步加载。

五、原理分析

结合现象看本质。在上面我们主要了解了异步加载的现象,接下来我们主要来分析和实现一下其中的原理。

老规矩,我们先说整体思路:

  • 第一步:当点击按钮时,先通过 jsonp 的方式去加载 test.js 模块所对应的文件
  • 第二步:加载回来后在浏览器中执行此JS脚本,将请求过来的模块定义合并到 main.js 中的 modules 中去
  • 第三步:合并完后,去加载这个模块
  • 第四步:拿到该模块导出的内容

整体代码思路(这里函数命名跟源代码有出入,有优化过):

image.png

第一步:当点击按钮时,先通过 jsonp 的方式去加载 test.js 模块所对应的文件

const buttonEle = document.getElementById("button");
buttonEle.onclick = function () {
  require.e("src_test_js") //src_test_js是test.js打包后的chunkName
};

接下来就去实现require.e函数:

//接收chunkId,这里其实就是 "src_test_js"
require.e = function (chunkId) {
  let promises = []; //定义promises,这里面放的是一个个promise
  require.j(chunkId, promises); //给promises赋值
  return Promise.all(promises); //只有当promises中的所有promise都执行完成后,才能走到下一步
};

require.j函数:这一步其实就是给promises数组赋值,并通过jsonp去加载文件

//已经安装好的代码块,main.js就是对应的main代码块,0表示已经加载成功,已经就绪
var installedChunks = {
  main: 0,
};

//这里传入的是 "src_test_js" , []
require.j = function (chunkId, promises) {
  var promise = new Promise((resolve, reject) => {
    installedChunks[chunkId] = [resolve, reject]; //此时installedChunks={ main: 0, "src_test_js":[ resolve, reject ]}
  });
  promises.push(promise); //此时promises=[ promise ]
  
  var url = require.publicPath + chunkId + ".main.js"; //拿到的结果就是test.js打包后输出的文件名称:src_test_js.main.js,publicPath就是我们在output中配置的publicPath,默认是空字符串
  let script = document.createElement("script");
  script.src = url;
  document.head.appendChild(script); //将该脚本添加进来
};

第二步:加载回来后在浏览器中执行此JS脚本,将请求过来的模块定义合并到 main.js 中的 modules 中去

在第一步中我们通过jsonp的方式加载了src_test_js.main.js文件,加载后需要立即执行该文件的内容,我们先来看看该文件长什么样子:

self["webpackChunkstudy"].push([
  ["src_test_js"],
  {
    "./src/test.js": (modules, exports, require) => {
      require.defineProperty(exports, {
        default: () => WEBPACK_DEFAULT_EXPORT,
      });
      const WEBPACK_DEFAULT_EXPORT = () => {
        console.log("按钮点击了");
      };
    },
  },
]);

这里的self其实就是windowwebpackChunkstudy就是一个名字,它是webpackChunk + 我们package.json 中的 name 字段拼接来的,我这里是study。

翻译过来就是要执行 window.webpackChunkstudy.push([xxx])这个函数,那接下来我们就实现一下它:接受一个二维数组作为参数,二维数组中,第一项是moduleId,第二项是模块定义:

//初始化:默认情况下这里放的是同步代码块,这里的demo因为没有同步代码,所以是一个空的模块对象
var modules = {};

//这里chunkIds=["src_test_js"] moreModules={xxx} test.js文件的模块定义
function webpackJsonpCallback([chunkIds, moreModules]) {
  const resolves = [];
  for (let i = 0; i < chunkIds.length; i++) {
    const chunkId = chunkIds[i];//src_test_js
    resolves.push(installedChunks[chunkId][0]); //此时installedChunks={ main: 0, "src_test_js":[ resolve, reject ]} ,将 src_test_js 的resolve放到resolves中去
    installedChunks[chunkId] = 0; //标识一下代码已经加载完成了
  }

  for (const moduleId in moreModules) {
    modules[moduleId] = moreModules[moduleId]; //合并modules,此时modules中有了test.js的代码
  }

  while (resolves.length) {
    resolves.shift()(); //执行promise中的resolve,当所有promises都resolve后,接下来执行第三步
  }
}

window.webpackChunkstudy.push = webpackJsonpCallback;

此时 modules 已经变为:

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

第三步:合并完后,去加载这个模块

走到这里require.e函数中的 Promise.all 已经走完,接下来走到第一个.then处:require.bind(require, "./src/test.js")

require.e("src_test_js") //完成第一步和第二步的工作
    .then(require.bind(require, "./src/test.js")) //完成第三步

require函数与之前相同,不做过多的赘述,大家可以看前一篇文章:从构建产物洞悉模块化原理。这里直接拷贝过来:

//已经加载过的模块
var cache = {};

//相当于在浏览器中用于加载模块的polyfill
function require(moduleId) {
  var cachedModule = cache[moduleId];
  if (cachedModule !== undefined) {
    return cachedModule.exports;
  }
  var module = (cache[moduleId] = {
    exports: {},
  });
  modules[moduleId](module, module.exports, require);
  return module.exports;
}

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

这里执行完require.bind(require, "./src/test.js")后,返回的是一个export对象:

 {
        default: () => {
            console.log("按钮点击了");
        } //因为这里是默认导出,所以是default
 }

第四步:拿到该模块导出的内容

  require.e("src_test_js") //完成第一步和第二步的工作
    .then(require.bind(require, "./src/test.js")) //完成第三步:前面代码加载并合并完后,去执行该模块代码
    .then((module) => { //完成第四步
      const print = module.default;
      print();
    });

在第三步中导出的是一个export对象,又因为是默认导出,所以这里取值是module.default,走到这里就完全走完啦。

六、整体代码

打包后的main.js(经优化):

//初始化:默认情况下这里放的是同步代码块,这里的demo因为没有同步代码,所以是一个空的模块对象
var modules = {};

//已经加载过的模块
var cache = {};

//相当于在浏览器中用于加载模块的polyfill
function require(moduleId) {
  var cachedModule = cache[moduleId];
  if (cachedModule !== undefined) {
    return cachedModule.exports;
  }
  var module = (cache[moduleId] = {
    exports: {},
  });
  modules[moduleId](module, module.exports, require);
  return module.exports;
}


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

//已经安装好的代码块,main.js就是对应的main代码块,0表示已经加载成功,已经就绪
var installedChunks = {
  main: 0,
};

require.publicPath = ""; //output中的publicPath属性

require.j = function (chunkId, promises) {
  var promise = new Promise((resolve, reject) => {
    installedChunks[chunkId] = [resolve, reject];
  });
  promises.push(promise);
  var url = require.publicPath + chunkId + ".main.js";
  let script = document.createElement("script");
  script.src = url;
  document.head.appendChild(script);
};

function webpackJsonpCallback([chunkIds, moreModules]) {
  const resolves = [];
  for (let i = 0; i < chunkIds.length; i++) {
    const chunkId = chunkIds[i];
    resolves.push(installedChunks[chunkId][0]);
    installedChunks[chunkId] = 0; //标识一下代码已经加载完成了
  }

  for (const moduleId in moreModules) {
    modules[moduleId] = moreModules[moduleId]; //合并modules
  }

  while (resolves.length) {
    resolves.shift()();
  }
}
self.webpackChunkstudy = {};
self.webpackChunkstudy.push = webpackJsonpCallback;

require.e = function (chunkId) {
  let promises = [];
  require.j(chunkId, promises);
  return Promise.all(promises);
};

const buttonEle = document.getElementById("button");
buttonEle.onclick = function () {
  require
    .e("src_test_js")
    .then(require.bind(require, "./src/test.js"))
    .then((module) => {
      const print = module.default;
      print();
    });
};

打包后的test.js:

self["webpackChunkstudy"].push([
  ["src_test_js"],
  {
    "./src/test.js": (modules, exports, require) => {
      require.defineProperty(exports, {
        default: () => WEBPACK_DEFAULT_EXPORT,
      });
      const WEBPACK_DEFAULT_EXPORT = () => {
        console.log("按钮点击了");
      };
    },
  },
]);

七、总结

上面我们差不多用50行代码写了一个简易demo实现了懒加载原理,在该demo中当然还有一些场景没有考虑进去:比如当点击按钮时,只需第一次加载时去请求文件,后面加载时应该要去使用缓存。但这并不是重点,希望通过本章大家能够更加深入理解Webpack中的懒加载,早日摆脱API工程师。

八、推荐阅读

  1. 从零到亿系统性的建立前端构建知识体系✨
  2. 我是如何带领团队从零到一建立前端规范的?🎉🎉🎉
  3. 二十张图片彻底讲明白Webpack设计理念,以看懂为目的
  4. 【中级/高级前端】为什么我建议你一定要读一读 Tapable 源码?
  5. 前端工程化基石 -- AST(抽象语法树)以及AST的广泛应用
  6. 线上崩了?一招教你快速定位问题!
  7. 【Webpack Plugin】写了个插件跟喜欢的女生表白,结果.....
  8. 从构建产物洞悉模块化原理
  9. Webpack深度进阶:两张图彻底讲明白热更新原理!
  10. 【万字长文|趣味图解】彻底弄懂Webpack中的Loader机制
  11. Esbuild深度调研:吹了三年,能上生产了吗?