webpack 是如何实现 code spliting 的?

167 阅读5分钟

在 webpack 中使用import()方法可以动态加载ES6模块,示例如下:

// index.js
import('./sum').then(m => {
  setTimeout(() => {
    console.log(m.default(1, 2))
  }, 3000)
})
// sum.js
const sum = (...args) => args.reduce((x, y) => x + y, 0)
export default sum

仍是写一个打包脚本build.js

const webpack = require('webpack')
const path = require('path')

function f1() {
    return webpack({
        entry: './index.js',
        mode: 'none',
        output: {
            filename: 'main.[contenthash].js', // 文件hash
            chunkFilename: '[name].chunk.[contenthash].js', // 模块hash
            path: path.resolve(__dirname, 'dist/contenthash'), // 输出位置
            clean: true // 输出之前清空
        }
    })
}

f1().run((err, stat) => {
	console.log(stat.toJson())
})

使用webpack打包

node build.js

创建一个index.html文件引入dist/contenthash中都main.js文件。

直接看main.js末尾,如下

__webpack_require__.entry(/* import() */ 1)
    .then(__webpack_require__.bind(__webpack_require__, 1))
    .then(m => {
        console.log(m.default(1, 2))
    })

真实编译出来的是__webpack_require__.e,我这里为了方便好看,将e替换成了entry

很明显,这段代码能看得出来,code spliting是通过promise实现的。我们在浏览器中跑一下看一下webpack 是如何做到的。

首先进入__webpack_require__.entry函数

__webpack_require__.entry = (chunkId) => {
  return Promise.all(Object.keys(__webpack_require__.f).reduce((promises, key) => {
    __webpack_require__.f[key](chunkId, promises);
    return promises;
  }, []));
};

这里通过执行__webpack_require__.f中的属性,得到一个 promise 数组promises,然后调用Promise.all执行这个promises最后返回。

其中__webpack_require__.f内有一个属性jj是一个函数,这里会执行这个j函数,函数如下

// 使用installedChunks对象来存储已加载和正在加载的模块
// 其中 undefined 表示 模块未加载,null 表示模块 preloaded/prefetched 
// [resolve, reject, Promise] 表示加载中, 0 = 已加载
var installedChunks = {
  0: 0
};

__webpack_require__.f.j = (chunkId, promises) => {
  // JSONP chunk loading for javascript(这里应该很明显,JavaScript文件是通过JSONP加载的)
  var installedChunkData = __webpack_require__.hasOwnProperty(installedChunks, chunkId)
    ? installedChunks[chunkId]
    : undefined;
  if (installedChunkData !== 0) { // 0 means "already installed". 如果是0表示已经加载过了

    // a Promise means "currently loading".
    if (installedChunkData) { // 如果存在,则是个 Promise,正在加载中
      promises.push(installedChunkData[2]);
    } else {
      if (true) { // all chunks have JS
        // setup Promise in chunk cache
        // 创建一个 Promise,同时将 installedChunkData(当前安装模块) 与 installedChunks(记录安装的模块)[chunkId] 设置为 [resolve, reject]
        var promise = new Promise((resolve, reject) => (installedChunkData = installedChunks[chunkId] = [resolve, reject]));
        // 接着将这个 promise 对象添加到 promises内,同时将其添加到 installedChunkData, 此时 installedChunkData = [resolve, reject, promise]
        promises.push(installedChunkData[2] = promise);

        // start chunk loading
        // 拼接 script 的 url(将要加载的js文件路径)
        var url = __webpack_require__.path + __webpack_require__.urlFilename(chunkId);
        // create error before stack unwound to get useful stacktrace later
        var error = new Error();
        var loadingEnded = (event) => {
          if (__webpack_require__.hasOwnProperty(installedChunks, chunkId)) {
            installedChunkData = installedChunks[chunkId];
            if (installedChunkData !== 0) installedChunks[chunkId] = undefined;
            if (installedChunkData) {
              var errorType = event && (event.type === 'load' ? 'missing' : event.type);
              var realSrc = event && event.target && event.target.src;
              error.message = 'Loading chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')';
              error.name = 'ChunkLoadError';
              error.type = errorType;
              error.request = realSrc;
              installedChunkData[1](error);
            }
          }
        };
        // 调用 load,传入 url, 加载完成函数, key 和 chunkId
        __webpack_require__.load(url, loadingEnded, "chunk-" + chunkId, chunkId);
      } else installedChunks[chunkId] = 0;
    }
  }
};

这里有一个installedChunks对象来存储已加载和正在加载的模块。我们关注以下几个值状态:

  1. ** undefined 表示模块未加载**。
  2. [resolve, reject, Promise] 表示加载中。
  3. 0 表示已加载。

__webpack_require__.f.j中的执行流程如下:

  1. 首先定义变量installedChunkData,它的值会尝试在installedChunks中根据chunkId获取。
  2. 如果installedChunkData不为0,则表示该模块要么还未加载(undefined),要么还在加载中([resolve, reject, Promsie])
  3. 如果模块已经在加载中,则将installedChunkData[2](也就是那个 Promise)添加到promises中即可
  4. 如果模块未加载,则创建一个promise,同时将 installedChunkData(当前安装模块) 与 installedChunks[chunkId](记录安装的模块)设置为 [resolve, reject]
  5. 接着将这个 promise 对象添加到promises内,同时将其添加到installedChunkData, 此时installedChunkData的值为[resolve, reject, promise]
  6. 直接看Line47调用load方法,这个方法最终就是创建一个script然后加载模块js文件,加载完成后删除这个script

到这一段,sum.jsjs是加载出来了,但是并不能直接使用,也就是说加载script以后并没完,script加载以后里面还会做一些事情,我们来看一下加载的script会做什么操作。

"use strict";
(self["webpackChunk"] = self["webpackChunk"] || []).push(
    [
        [1],
        [
            /* 0 */,
            ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

                __webpack_require__.r(__webpack_exports__);
                __webpack_require__.d(__webpack_exports__, {
                        "default": () => (__WEBPACK_DEFAULT_EXPORT__)
                });
                const sum = (...args) => args.reduce((x, y) => x + y, 0)

                const __WEBPACK_DEFAULT_EXPORT__ = (sum);

            })
        ]
    ]
);

在其内部有一个self["webpackChunk"].push操作,我一开始压根就没注意到这个push函数,调试了半天,走了很多歪路~

image.png

后来我就在main.js中翻了一下,果然让我找到了webpackChunk这个属性,好家伙,原来此push非彼push

image.png

// install a JSONP callback for chunk loading
var webpackJsonpCallback = (parentChunkLoadingFunction, data) => {
  var [chunkIds, moreModules, runtime] = data;
  // add "moreModules" to the modules object,
  // then flag all "chunkIds" as loaded and fire callback
  var moduleId, chunkId, i = 0;
  // 解构出来 chunkIds 判断是否未被加载过
  if (chunkIds.some((id) => (installedChunks[id] !== 0))) {
    for (moduleId in moreModules) {
      if (__webpack_require__.hasOwnProperty(moreModules, moduleId)) {
        // 将 sum.js 中的那个函数拿过来,放到 moduels 上
        __webpack_require__.modules[moduleId] = moreModules[moduleId];
      }
    }
    if (runtime) var result = runtime(__webpack_require__);
  }
  if (parentChunkLoadingFunction) parentChunkLoadingFunction(data);
  for (; i < chunkIds.length; i++) {
    chunkId = chunkIds[i];
    if (__webpack_require__.hasOwnProperty(installedChunks, chunkId) && installedChunks[chunkId]) {
      // installedChunks[chunkId] 就是之前的 [resolve, reject, promise]
      // 这里就是 resolve 掉(代表着一开始 __webpack_require__.entry 中的 promise.all)
			installedChunks[chunkId][0]();
    }
    installedChunks[chunkId] = 0; // 同时标记为已加载过
  }

}

var chunkLoadingGlobal = self["webpackChunk"] = self["webpackChunk"] || [];
chunkLoadingGlobal.forEach(webpackJsonpCallback.bind(null, 0));
chunkLoadingGlobal.push = webpackJsonpCallback.bind(null, chunkLoadingGlobal.push.bind(chunkLoadingGlobal));

重点是Line28``push不是数组的push,原来sum.js中调用的就是就是webpackJsonpCallback.bind(null, chunkLoadingGlobal.push.bind(chunkLoadingGlobal))

进入到webpackJsonpCallback内部执行流程如下:

  1. 第二个参数data就是sum.jspush过来的数组,解构出来的chunkIds需要判断是还未加载过的
  2. sum.js中的函数赋值给__webpack_require__.modules
  3. 最后是将installedChunks中对应的模块状态中的resolve掉,同时标记模块已加载。
  4. 到这里,标志着__webpack_require__.entry中的Promise.all执行完毕
  5. 到第一个then中执行__webpack_require__(1),这其中的逻辑与我上一篇 Webpack 是如何打包CJS的?中一致,我这里就不赘述了,在这个地方其实就是返回一个对象,里面有一个default属性,default的值就是export defaultsum函数。
  6. 最后我们就可以在最后一个then函数中执行sum函数了,也就是m.default(1, 2)

总结

这里其实最重要的就是如下几个步骤:

  1. 创建状态,添加到installedChunks
  2. 通过创建script标签,加载sum.jshead标签中,加载完成后删除这个script
  3. 接着在sum.js中调用webpackChunk.push方法
  4. webpackChunk.push中将installedChunks中对应的模块状态resolve掉,同时状态设置为已加载
  5. 最后执行__webpack_require__得到一个对象,内有属性defaultdefault的值就是sum函数体。

呼~我这两篇文章都是看了B站UP 山月的视频 然后自己一步一步调试写出来的,这种产物代码实在是有点儿读懂,算是下了一番功夫,也很有收获,希望你也能按照步骤来调试看看。