书接上篇, 本篇我们来看看异步加载(code split).

示例项目:https://github.com/njleonzhang/deep-into-webpack-output-sample/tree/master/codesplit

我们还是使用一个简单的例子:

// const.js
export let name = 'leon'
export default function print() { console.log('print: ' + name) }
// test.js
export let testStr = 'I am test'
// index.js
import { testStr } from './test' // 同步加载

console.log(testStr)

import(/* webpackChunkName: "const" */ './const').then(Component => { // 异步加载
  Component.default()
  console.log(Component, Component.name)
})
// webpack
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  mode: 'development',
  entry: {
    index: './index.js'
  },
  output: {
    filename: '[name].bundle.js',
    chunkFilename: '[name].bundle.js',
    path: path.resolve(__dirname, 'dist')
  },
  devtool: 'cheap-module',
  plugins: [
    new HtmlWebpackPlugin({
      filename: 'index.html',
      inject: true
    })
  ],
}

接着我们来观察编译后的输出文件:

|__ index.bundle.js   // 入口 `index.js` 和 同步模块 `test.js`
|__ 0.bundle.js       // 异步模块 const 的代码

index.js 里的大多数代码我们都是熟悉的,主要是前几篇里我们介绍过的对同步模块的处理,多出的内容就是关于异步模块的处理,我们截取这段代码来看:

"./index.js":
(function(module, __webpack_exports__, __webpack_require__) {
  "use strict";
  __webpack_require__.r(__webpack_exports__);
  // 同步模块的处理
  var _test__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./test.js");
  console.log(_test__WEBPACK_IMPORTED_MODULE_0__["testStr"])

  // 异步模块的处理
  __webpack_require__.e("const").then(__webpack_require__.bind(null, "./const.js")).then(Component => {
    Component.default()
    console.log(Component, Component.name)
  })
})

这里出现了一个我们没有见过的新的方法: __webpack_require__.e, 我们看一下它的一些相关实现:

// object to store loaded and loading chunks
// undefined = chunk not loaded, null = chunk preloaded/prefetched
// Promise = chunk loading, 0 = chunk loaded
// 用于存储已加载和加载中的 chunk, 异步模块在 webpack 中被称为 chunk
var installedChunks = {
  "index": 0
};

// webpack_require 的定义
__webpack_require__.e = function requireEnsure(chunkId) {
  // 声明一个队列,此队列与此 chunk 绑定。
  var promises = [];

  // JSONP chunk loading for javascript
  // 拿到该 chunk 对应的值, 我们这个调用中,显然 installedChunks 里没有
  // const 这个 chunk,所以 installedChunkData 就是 undefined
  var installedChunkData = installedChunks[chunkId];
  if(installedChunkData !== 0) { // 0 means "already installed".

    // a Promise means "currently loading".
    // 如果正在加载
    if(installedChunkData) {
      promises.push(installedChunkData[2]);
    } else {
      // 如果没有加载,(本例的场景), 构造一个 Promise 代表此异步模块的加载结果,并以
      // [resolve, reject, Promise] 这样的结构来存储
      // setup Promise in chunk cache
      var promise = new Promise(function(resolve, reject) {
        installedChunkData = installedChunks[chunkId] = [resolve, reject];
      });
      // 将构造的这个 promise 加入队列
      promises.push(installedChunkData[2] = promise);

      // start chunk loading
      // 开始加载 chunk,通过在页面里插入一个 script 标签来做
      var script = document.createElement('script');
      var onScriptComplete;

      // 设置编码方式
      script.charset = 'utf-8';
      // 设置超时时间
      script.timeout = 120;

      // 如果启用了 CSP 教研,则设置 nonce 属性
      if (__webpack_require__.nc) {
        script.setAttribute("nonce", __webpack_require__.nc);
      }

      // 拼接 chunk 文件的服务器地址
      script.src = jsonpScriptSrc(chunkId);

      // 定义加载完成的处理函数
      onScriptComplete = function (event) {
        // 加载结束了
        // avoid mem leaks in IE.
        script.onerror = script.onload = null;
        // 有结果了,所以清除加载超时的定时器
        clearTimeout(timeout);
        // 读出 chunk 的结构
        var chunk = installedChunks[chunkId];
        if(chunk !== 0) {
          if(chunk) {
            var errorType = event && (event.type === 'load' ? 'missing' : event.type);
            var realSrc = event && event.target && event.target.src;
            var error = new Error('Loading chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')');
            error.type = errorType;
            error.request = realSrc;
            chunk[1](error); // 这里调用的错误处理函数,也就是说这里没有加载成功的处理。
          }
          installedChunks[chunkId] = undefined;
        }
      };
      // 设置超时的处理
      var timeout = setTimeout(function(){
        onScriptComplete({ type: 'timeout', target: script });
      }, 120000);
      script.onerror = script.onload = onScriptComplete;
      document.head.appendChild(script);
    }
  }
  return Promise.all(promises);
};

这段逻辑总体不难理解,大家可以对照注释来看很容易理解。我总结一下, webpack 为每一个异步模块都分配了一个 id,并维护了一个全局对象 installedChunks 用于存放异步加载模块的信息,其结构为:

{
  index: 0,
  [id: string]: any
}

id 对应的值有多种形式:

我们的这个简单场景中,const 模块的 idconst, __webpack_require__.e 首先为它在 installedChunks 里添加了一个数组:

  {
    index: 0,
    const: [resolve Function, reject Function, Promise]
  }

接着为 const.js 模块创建了一个 srcipt 标签, src 设置为 const.js 的网络地址,接着把这个 script 被插入到了页面的 head 里, 用于加载此模块。这都好理解,奇怪的地方是 __webpack_require__.e 里并没有加载成功的处理代码,只有加载错误的处理代码。那么加载成功的代码在哪呢?

可以发现 webpack 启动代码里多了这么几行:

  // 初始化 window.webpackJsonp 这个对象
  var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
  // 暂存 JsonArray 的 push 方法
  var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
  // 修改 JsonArray 的 push 方法为一个叫 webpackJsonpCallback 的函数
  jsonpArray.push = webpackJsonpCallback;
  // 把 jsonpArray 做一份拷贝
  jsonpArray = jsonpArray.slice();
  // 遍历 jsonpArray 对其各项调用 webpackJsonpCallback 函数
  for(var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
  var parentJsonpFunction = oldJsonpFunction;

jsonpArray.push = webpackJsonpCallback; 这一行实际上给 window["webpackJsonp"] 这个变量添加了一个 push 方法, window["webpackJsonp"] 是个数组,也就是说 window["webpackJsonp"] 这个数组的 push 函数被复写成了 webpackJsonpCallback.

我们前面说到 const.js 模块被加载到了页面里了。那么加载成功后,其代码也就自然执行了。

// const.js
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([["const"],{
  "./const.js":
  (function(module, __webpack_exports__, __webpack_require__) {
    "use strict";
    __webpack_require__.r(__webpack_exports__);
    __webpack_require__.d(__webpack_exports__, "name", function() { return name; });
    __webpack_require__.d(__webpack_exports__, "default", function() { return print; });
    let name = 'leon'
    function print() { console.log('print: ' + name) }
  })
}]);

我们会惊奇的发现,原来模块加载正常的处理逻辑在这里, window["webpackJsonp"]push 方法 (webpackJsonpCallback) 被调用了。

那么 webpackJsonpCallback 又干了什么呢?其定义在 index.bundle.js 里。

function webpackJsonpCallback(data) {
  var chunkIds = data[0];    // 取出模块的 id
  var moreModules = data[1]; // 取出模块

  // 标记所有 chunk 为已加载
  var moduleId, chunkId, i = 0, resolves = [];
  for(;i < chunkIds.length; i++) {
    chunkId = chunkIds[i];
    if(installedChunks[chunkId]) {
      resolves.push(installedChunks[chunkId][0]); // 前文中提到的 resolve Function
    }
    installedChunks[chunkId] = 0; // 并标记所有 chunk 为已加载
  }

  // 把所有的模块加入 modules 的对象中, 没错就是 __webpack_require__.m 对应的那个属性
  for(moduleId in moreModules) {
    if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
      modules[moduleId] = moreModules[moduleId];
    }
  }

  // 执行一下原来的 push 函数
  if(parentJsonpFunction) parentJsonpFunction(data);

  // resolve 此模块的 chunk 对应的 Promise.
  while(resolves.length) {
    resolves.shift()();
  }
};

所以一切就明朗啦,模块加载成功后,模块内部代码执行, webpackJsonpCallback 函数的执行会把模块的内容被插入到 __webpack_require__.m 中,并 resolve 此模块加载的 Promise。就这样异步模块和同步模块一样, 被加载到了 __webpack_require__.m 这个对象中了,接着只需要对其调用 __webpack_require__ 函数就可以按照同步模块的 load 流程进行初 load 了。在看一眼异步模块加载的这句代码:

// 异步模块的处理
__webpack_require__.e("const").then(__webpack_require__.bind(null, "./const.js")).then(Component => {
  Component.default()
  console.log(Component, Component.name)
})

果然看到了 __webpack_require__ 的身影了。

最后我来上个整体的流程图:

入口 js 执行后,把 window["webpackJsonp"]push 方法被 hook 成了 webpackJsonpCallback, 接着通过调用 __webpack_require__.e 函数利用 script 标签把异步模块加载进浏览器. 异步模块的代码开始执行, 它调用 window["webpackJsonp"].push 方法把自己的塞到了 __webpack_require__.m 里, 余下就和同步模块的流程一样了。