webpack 的输出文件竟然这么妙!

2,098 阅读6分钟

序:

5月份的时候我的好朋友(@杨鹏)看了 github 上的博客年少时的孤芳自赏,特意跑来夸奖一番。他期待我更新,我回复到6月会更一篇。但是我的整个 6 月都在忙(懒)着一个项目的重构,导致只要有点时间就去 B 站消遣去了。确实好久不写了,杨鹏的夸赞当勉励,也当是督促。这一篇给杨鹏,祝好!

1. demo 代码

本篇所用 webpack 为 v4.x 版本

  • webpack.config.js
var path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  entry: {
    bundle: './src/a.js'
  },
  devtool: 'none',
  output: {
    path: __dirname + '/dist',
    filename: '[name].[chunkhash:4].js',
    chunkFilename: '[name].[chunkhash:8].js'
  },
  mode: 'development',
  plugins: [new HtmlWebpackPlugin()],
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          {
            loader: 'babel-loader',
            options: {
              presets: ['@babel/env']
            }
          }
        ]
      }
    ]
  }
};

  • 入口 a.js
import { add } from './b';
import('./c.js').then(m => m.minus(2, 1));

const A_NUM = 1;
let r = add(3, 2 + A_NUM);

console.log(r);

  • 模块 b.js
export const SOME_VAR = 'SOME_VAR'

export function add(a, b) {
  return a + b
}

  • 模块 c.js
import('./b.js').then(m => m.add(200, 100));

export function minus(a, b) {
  return a - b;
}
  • 模块 d.js
export const L = 'Aragaki Yui'

export function times(a, b) {
  return a * b
}

2. 打包输出文件

注意打包的模式是开发模式,不要混淆代码,我们还要读这些代码,输出文件如下

  • 0.a619de3d.js (下称 chunk)
  • bundle.b05e.js (下称 bundle)
  • index.html

3. 删除空注释

这一步是降低心理难度的重要手段,很多人都是被这一大堆的注释劝退的;所以把类似下面的注释都替换成空,没错,用你的IDE,Cmd + R;暂时移除以下注释:bundle 和 chunk 的处理相同。

  • bundle 里面的空注释,示例:
/******/
 
/***/
  • 模块前的注释,示例

个注释是用来提示模块导出了那些内容,暂时忽略

/*!******************!*\
  !*** ./src/a.js ***!
  \******************/
/*! no exports provided */

4. bundle (bundle.b05e.js)结构

4.1 整体结构

(function (modules) { 
  // webpack runtime 代码
})({
  // 这个是模块对象,下称为 modules, 注意提到 modules 就要想到这个对象!!!!
  // key 是模块的路径,注意,如果同一个模块使用了不同 loader,webpack 会认为这是两个模块,这个差别会体现在 key 上,key 包含了使用的 loader(如有)
  // value 就是被 webpack 包装处理过后的模块
  "./src/a.js": (function(module, __webpack_exports__, __webpack_require__) {}),
  "./src/b.js": (function(module, __webpack_exports__, __webpack_require__) {})
})

通过上面的代码块可以看出来,这个结构就是一个自执行函数(IIFE),它定义形参 modules,接收实参为一个对象,这个对象中 key 是模块路径,value 则是被 webpack 包装后的模块;

看具体的代码前,先要了解一个概念———— runtime;我们来看下中文官方文档的定义: runtime,.....主要是指:在浏览器运行过程中,webpack 用来连接模块化应用程序所需的所有代码。它包含:在模块交互时,连接模块所需的加载和解析逻辑。包括:已经加载到浏览器中的连接模块逻辑,以及尚未加载模块的延迟加载逻辑。

简言之,就是 webpack 用来处理连接、加载、执行 webpack 模块的代码;这些就是 bundle 中自执行函数的主要内容,这一段信息量有点大,我们还是由外入内的介绍一下这些变量、方法、以及方法上的属性的大致作用;

4.2 runtime 概览

1、 webpackJsonpCallback 方法

(function(modules) { // webpack 启动代码
    // 这个 webpackJsonpCallback 是通过 JSONP 加载那些按需加载(import(some-file.js).then(...))的 chunk 时的 JSONP 的回调 callback;
    // JSONP 就是创建一个 script 标签去加载 js 文件,而 JOSNOP callback 就是加载回来以后要做的事情
    function webpackJsonpCallback(data) {};

2、 installedModules

// 模块缓存,已经安装过的模块们,如果已经加安装过了就缓存在这个对象中,下次再访问这个模块走缓存就可以了
// 下面的 __webpack_require__ 就是用来安装模块的
var installedModules = {};

3、 installedChunks

// 这个已经安装过的 chunks,这个就有点复杂了;后面会细说加载异步 chunk 的过程;
// installedChunks 这个对象以 key-value 的形式保存已经安装的 chunk,key 是 chunk id,关于 value 有以下几种情况:
// value = undefined,表示该 chunk 未被加载过
// value = 0,chunk 已经加载完毕
// value = <Array> [Promise resolveFn, Promise rejectFn, Promise] 表示 chunk 正在加载中,关于为啥搞成这个数组结构后面的加载异步chunk 会细说
// value = null 表示 chunk preload 或者 prefetch 
    var installedChunks = {
            "bundle": 0
    };

4、 jsonpScriptSrc 方法

	// 为 script 标签 src 属性拼接 __webpack_require_.p,这个 p 属性就是 webpack.config.js 中 output.publicPath 
	function jsonpScriptSrc(chunkId) {
		return __webpack_require__.p + "" + ({}[chunkId]||chunkId) + "." + {"0":"a619de3d"}[chunkId] + ".js"
	}

5、 _webpack_require_ 方法

// webpack 运行时的主要方法,其作用创建并缓存 module 对象()后,执行这个 module 中的代码;
// 创建 module 是啥意思嘞?就是 __webpack_require__ 中的 { i: moduleId, l: false, exports: {} } 对象
function __webpack_require__(moduleId) {}

6、 __webpack_require__.e 静态属性

// 用于加载额外 chunk 的函数,比如按需加载的 chunk,这个里面就会有创建 script 标签然后去加载代码的具体逻辑,后面细说
__webpack_require__.e = function requireEnsure(chunkId) {};

7、 __webpack_require__.m 静态属性

// 暴露这个 runtime 接收到的 modules 对象(这个自执行函数接收到参数对象,看上面 4.1 )
__webpack_require__.m = modules;

8、 __webpack_require__.c 静态属性

// 暴露缓存的已经安装的模块们
__webpack_require__.c = installedModules;

9、 __webpack_require__.d 静态方法

// 在模块对象(module 上面__webpack_require__ 中创建的 module 对象,下同)的 exports 对象上增加属性,
// 以 getter 的形式定义导出(就是实现你代码中的通过 export 导出一个变量/常量/函数等)
__webpack_require__.d = function(exports, name, getter) {};

10、 __webpack_require__.r 静态方法

// 在模块对象(module) 增加 __esModule 属性,用于标识这个模块是个 ES6 模块
__webpack_require__.r = function(exports) {};

11、 __webpack_require__.t 静态方法

// 这个 t 先忽略掉,暂时用不到
__webpack_require__.t = function(value, mode) {};

12、 __webpack_require__.n 静态方法

// 得到 getDefaultExport 方法,抹平 ES6 的模块和非 ES6 模块的默认导出
__webpack_require__.n = function(module) {};

13、 __webpack_require__.o 静态方法

// 调用 hasOwnProperty 判断对象是否有某一属性
__webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };

14、 __webpack_require__.p 静态属性

// 这个就是 webpack.config.js 中 output.publicPath,上面 jsonpScriptSrc 方法就是拼接的这个值
__webpack_require__.p = "";

15、 __webpack_require__.oe 静态方法

// 错误处理,忽略
__webpack_require__.oe = function(err) { console.error(err); throw err; };

16、 JSONP 初始化

// JSONP 初始化,这是个精彩的部分,后面讲异步 chunk 加载的时候细说,但是先来看看这几步骤都在干啥
var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || []; // 初始化 window[webpackJsonp] 对象
var oldJsonpFunction = jsonpArray.push.bind(jsonpArray); // 暂存数组 push 方法,这个 push 就是 Array.prototype.push
jsonpArray.push = webpackJsonpCallback; // 重写 jsonpArray.push 方法(注意,这么重写不会改写数组原型)
jsonpArray = jsonpArray.slice(); // 赋值 jsonpArrray,这个复制不带 push 方法!
for(var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]); // 若chunk先于 bundle 这个入口加载,这个 jsonpArray 里面就不是空的,此时,遍历并调用 webpackJsonpCallback,相当于手动触发 jsonp callback
var parentJsonpFunction = oldJsonpFunction; // 旧 push 暂存于 parentJsonpFunciton

17、 __webpack_require__() 加载入口启动应用

// 加载入口 module 并返回 webpack_require__ 执行后的 exports 对象
// 从这里算是真正的开始跑我们开发的模块了
    return __webpack_require__(__webpack_require__.s = "./src/a.js");
})
/************************************************************************/
({
// 这就是 webpack runtime 的自执行函数接收到 modules 对象:提到 modules 请联想到这个对象
    "./src/a.js": (function(module, __webpack_exports__, __webpack_require__) {}),
    "./src/b.js": (function(module, __webpack_exports__, __webpack_require__) {})
});

4.3 __webpack_require__ 方法

前面的删除注释、把函数体里面的代码删除,都是在剔除支线剧情,让我们更多注意力放在我想表达的重点上,你可以更好的跟着作者的写作思路,如果你还在读,你会发现我已经由外入内了,从整个 js 文件到 webpack runtime 概览,从这个小的主题我就要进入到一个方法的代码块。

我们来重复一下 _webpack_require_ 方法的作用:接收指定 moduleId 作为参数,创建并缓存 module 对象,加载并执行 moduleId 代码,是 webpack runtime 的重要部分;

在上面 webpack runtime 概览的最后发现,在 runtime 的最后调用了 __webpack_require__(webpack_require.s = './src/a.js');接下来看看方法里面发生了什么:

function __webpack_require__ (moduleId) {

    // 查看缓存,如果缓存中有了即返回缓存即可:installedModules 在上面 4.2 runtime 概览(2)
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports
    }

    // 创建并缓存 module 对象,后面被赋值的这个对象称为  module 对象
    var module = installedModules[moduleId] = {
      i: moduleId,
      l: false,
      exports: {}
    }

    // 执行 module 中的代码:
    // modules 就是 webpack runtime 这个自执行函数接收到的模块对象,
    // moduleId 是什么?从上面 runtime 最后调用 __webpack_require__ 的时候可以发现接收的参数即 moduleId,即 ./src/a.js,这就是说 moduleId 可以认为是个路径
    // call 则把模块中的 this 修改成 module.exports 并执行,执行时传入参数:module, module.exports, __webpack_require__ (有没有 node.js 中 CMD 的感觉了)
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__)

    // 标记 module 已经加载过,l 我猜猜是 loaded 的缩写
    module.l = true

    // 把 module 的 exports 导出
    return module.exports
  }

接着我们看看 modules[moduleId] 即 modules["./src/a.js"] 里面是什么:

  • modules
// 1. webpack runtime 接收到的 modules 就是这个样子:
var modules = ({
    "./src/a.js": (function(module, __webpack_exports__, __webpack_require__) {
      "use strict";
      __webpack_require__.r(__webpack_exports__);
      /* harmony import */ var _b__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./b */ "./src/b.js");
  
      __webpack_require__.e(/*! import() */ 0)
        .then(__webpack_require__.bind(null, /*! ./c.js */ "./src/c.js"))
        .then(function (m) {return m.minus(2, 1);});
      var A_NUM = 1;
      var r = Object(_b__WEBPACK_IMPORTED_MODULE_0__["add"])(3, 2 + A_NUM);
      console.log(r);
    }),
    "./src/b.js": (function(module, __webpack_exports__, __webpack_require__) {})
  });
  • 取 ./src/a.js
// 2. modules[moduleId] 
// 从上面可以看出 modules[./src/a.js] 得到的一个函数 

function (module, __webpack_exports__, __webpack__require__) {
    "use strict";
    __webpack_require__.r(__webpack_exports__); // 利用上面的 __webpack_require\__.r 方法将该模块的 exports 对象上增加 __esModule 属性,定性为 ES6 module 对象
    /* harmony import */ var _b__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./b */ "./src/b.js"); // 实现 import { add } from './b';
    
    // 实现 import('b.js').then(m => m.minus(2, 1);
    __webpack_require__.e(/*! import() */ 0) 
        .then(__webpack_require__.bind(null, /*! ./c.js */ "./src/c.js"))
        .then(function (m) {return m.minus(2, 1);});
    var A_NUM = 1;
    var r = Object(_b__WEBPACK_IMPORTED_MODULE_0__["add"])(3, 2 + A_NUM);
    console.log(r);
}
  • 和 src/a.js 源码对比一下
import { add } from './b';
import('./c.js').then(m => m.minus(2, 1));

const A_NUM = 1;
let r = add(3, 2 + A_NUM);

console.log(r);

我们可以轻松发现 webpack 都对代码做了些什么:

1)给模块包了一层函数 function (module, __webpack_exports_, __webpack_require_) {};这么做则是为了实现模块化,用闭包做隔离, webpack 自己实现了一个 CommonJS 规范;

2)利用上面的 __webpack_require__.r 方法将该模块的 exports 对象上增加 __esModule 属性,定性为 ES6 module 对象;看看 .r 方法(上面 4.2 runtime 概览(10)):

__webpack_require__.r = function (exports) {
    if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
      Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
    }
    Object.defineProperty(exports, '__esModule', { value: true })
}

3)实现 import { add } from './b'; 导入 b 模块上的 add 方法,我们发现 import 关键字被 __webpack_require__ 实现了,我们发现源码中的 add() 调用被处理成了 _b__WEBPACK_IMPORTED_MODULE_0__['add'],这说明 add 这个方法被放到了 _b__WEBPACK_IMPORTED_MODULE_0__ 对象上,那么这里思考一下,b 模块的导出怎么去了这个对象上的呢?

4)实现 import('c.js').then(m => m.minus(2, 1); import(c.js) 变成了 __webpack_require\__.e(/*! import() */ 0),而且增加了一个 then(__webpack_require\__.bind(null, 'c.js'')) 这又是在搞什么呢?这个后面揭晓;

5)调用得到的 add 方法,忽略

6)log 输出,忽略

  • 我们看看 webpack 处理后的 b.js 的样子:

b.js 同样在 modules 中,如下:

// moduels
var modules = ({
    "./src/a.js": (function(module, __webpack_exports__, __webpack_require__) {}),
    "./src/b.js": (function(module, __webpack_exports__, __webpack_require__) {
      "use strict";
      __webpack_require__.r(__webpack_exports__);
      /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "SOME_VAR", function() { return SOME_VAR; });
      /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "add", function() { return add; });
      var SOME_VAR = 'SOME_VAR';
      function add(a, b) {
        return a + b;
      }
    })
})

对比一下 b.js 的源码

export const SOME_VAR = 'SOME_VAR'

export function add(a, b) {
  return a + b
}

我们可以轻易看出 webpack 对 b.js 做了些什么:

1)__webpack_require\__.r 标识该模块是一个 ES6 的模块;

2)实现 export const SOME_VAR = 'SOME_VAR' ;我们发现 webpack 是通过 __webpack_require__.d 实现的 export 关键字,即导出,接着我们看 .d 方法(4.2 runtime 概览(9));d 方法就是通过在 module.exports 对象(_webpack_require_ 创建的)配置 getter 实现 export;这么做的好处在哪里?这种 export 一个变量(包括函数),好处是这个 getter 访问的都是原来模块作用域中的变量,自动连接到模块的作用域,若此设计不可谓不机智;

__webpack_require__.d = function (exports, name, getter) {
    // __webpack_require__.o 是 hasOwnProperty 方法,判断是否有私有属性的
    if (!__webpack_require__.o(exports, name)) {
      Object.defineProperty(exports, name, { enumerable: true, get: getter })
    }
}
  • 简单回顾一下,webpack 怎么让代码跑起来的?

1)webpack 有自己的runtime,其中的 _webpack_require_ 可以创建、缓存 module,然后加载并运行 module。在 runtime 的末尾传入入口模块的路径并执行 _webpack_require_ 方法;

2)webpack 的 runtime 接受到了一个 modules 对象,其中 key 是模块路径,value 则是被 webpack 处理过的函数,这个函数就是模块主体;

3)webpack 通过 __webpack_require_.r 定义 ES6 模块,通多 __webpack_require_.d 方法实现 export,通过 _webpack_require_ 实现 import 等措施实现了一个 CommonJS 规范的模块系统;

5. 非入口 chunk 的加载

5.1 细说 webpack_require.e 方法

就说说 webpack 是如何加载非入口 chunk,非入口 chunk 的加载需要 __webpack_require__.e 方法。在前面的 demo 中,a.js 源码中通过 import(c.js) 语法实现按需加载,被按需加载的模块会以一个单独的 chunk 生成一个文件 ———— 0.a619de3d.js;在看这个 chunk 之前,我们先看看 a.js 又是如何实现的 import() 语法:

var modules = {
    "./src/a.js": (function(module, __webpack_exports__, __webpack_require__) {
        // ... 
        // 这里就是实现的 import(c.js).then((m) => ....)
        __webpack_require__.e(/*! import() */ 0)
          .then(__webpack_require__.bind(null, /*! ./c.js */ "./src/c.js"))
          .then(function (m) {
            return m.minus(2, 1);
          });
    }),
}

从上面的代码可以看出,__webpack_require_.e(/*! import() */ 0).then(__webpack_require_.bind(null, /*! ./c.js */ "./src/c.js")) 实现的 import(c.js);接下来就该看看 e 方法的真实面目:

__webpack_require__.e = function requireEnsure(chunkId) {
    // 1. 参数 chunkId: 用于加载的 chunk 的 chunkId,从上面调用该方法中可以看出要的加载 c.js 的对应的 chunkId 是 0,这个 chunkId 是 webpack 生成的,这里不需要关心;细心的朋友发现 chunk 文件除了 chunkId 0 后面还有 hash,这个 hash 哪里来的呢?这个后面下面揭晓

    // 2. promise 队列,可以加载多个 chunk;
    var promises = [];
    
    // 3. 尝试从已经安装过的 chunk 中取用参数 chunkId,这个 installedChunks 的作用在上文 4.2 runtime 概览(3)
    var installedChunkData = installedChunks[chunkId];
    if(installedChunkData !== 0) { // 这里判断非0,因为 0 表示已经安装过了
        // 如果从 installedChunks 中取到的 installedChunkData 是个 promise 则说明正在加载这个 chunk;
        if(installedChunkData) {
           // 这里有个精巧的设计,回到上面 4.2 runtime 概览(3)中关于 installedChunk 的 value 的详述中,
            // 只有一种情况不是 falsy 的值,其他的value如 0,undefined、null 都是 falsy,
            // 所以这里他敢判断 if(installedChunData) 而不用做细致判断
            // 就一个字,绝
            promises.push(installedChunkData[2]);
        } else {
            // else 说明这里就说明都是些 falsy 的值,说明该去乖乖的加载这个 chunkId 对应的 chunk 文件

            // 这里就要回达 4.2 runtime 概览(3)中为啥 installedChunks 的 value 有一种情况是一个数组:
            // [Promise resolveFn, Promise, rejectFn, promise] 
            // 就是从 这个 __webpack_require__.e 方法的这里创建的,就在下面的这几行代码
            var promise = new Promise(function(resolve, reject) {
                installedChunkData = installedChunks[chunkId] = [resolve, reject]; //就是这里给数组中加上 resolve, reject~~~
            }); 
            promises.push(installedChunkData[2] = promise); // 这里给数组加上 promise 对象
    
            // 所谓 JSONP 就是新建个 script 标签去加载这个 chunkId 对应的文件(你是不是想说:就这?。。。jq 直呼内行)
            var script = document.createElement('script');
            var onScriptComplete;
    
            script.charset = 'utf-8';
            script.timeout = 120;
            if (__webpack_require__.nc) {
                script.setAttribute("nonce", __webpack_require__.nc);
            }

            // 给 script src 属性赋值,浏览器就会去发起一个 get 请求去加载 src 对应的资源;
            // 还记得这个方法开头我们说只有 chunkId 0,还有 hash 来着,hash 从哪儿来?这里就是在 jsonpScriptSrc 方法里面拼接的;
            script.src = jsonpScriptSrc(chunkId);
    
            // create error before stack unwound to get useful stacktrace later
            var error = new Error();
            onScriptComplete = function (event) {
                // avoid mem leaks in IE.
                script.onerror = script.onload = null;
                clearTimeout(timeout);
                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;
                        error.message = 'Loading chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')';
                        error.name = 'ChunkLoadError';
                        error.type = errorType;
                        error.request = realSrc;
                        chunk[1](error);
                    }
                    installedChunks[chunkId] = undefined;
                }
            };
            // 人肉处理超时, 120s 后手动超时
            var timeout = setTimeout(function(){
                onScriptComplete({ type: 'timeout', target: script });
            }, 120000);
            script.onerror = script.onload = onScriptComplete;

            // 把 script 标签插入到 dom 中 
            document.head.appendChild(script);
        }
    }
 
    // 调用 Promise.all() 等所有被加载的 chunk 都完成了才会 resolve
    // 最后返回的是一个 promise
    return Promise.all(promises);
};

总结一下上面这段异步按需加载模块的操作: 1)首先源码中 import(c.js).then 被 webpack 处理成了 __webpack_require__.e(chunkId).then(__webpack_require__.bind(null, c.js)) 2)__webpack_require__.e 则会通过 JSONP 加载对应的 chunk 并执行,返回加载 chunk 时创建的 promise 对象; 3)上面 1)中的 then 的回调 __webpack_require\__.bind(null, c.js) 接着执行,这个回调的作用其实就是调用 __webpack_require\__ 去加载 c.js; 4)接着上面的从 chunk 加载按需加载的 c.js,紧接着再次调用 then 方法,这个 then 方法中的回调才会真正调用 c.js 中的方法,到这里被按需加载的 c.js 中的方法完成调用;

你以为到这里就结束了是么,怎(我)么(也)可(想)能(阿)?

我们来思考一个问题:__webpack_require__ 是从 modules 对象或者 installedModules 缓存中获取模块的,那么这个 chunk 代码做了什么,runtime 又做了什么,才使得 then(__webpack_require__.bind(null, c.js)) 取到预期的方法呢?

这个问题的答案要着眼于两方面:

  1. chunk 里面的代码;
  2. runtime 中 JSONP 处理;

5.2 chunk 概览及 JSONP

1、chunk 中的代码

// 1. 确保 window[webpackJsonp] 这个数组的存在,如果没有就赋值一个,有的话就取用
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([[0],{
  "./src/c.js": (function(module, __webpack_exports__, __webpack_require__) {
      "use strict";
      __webpack_require__.r(__webpack_exports__);
      /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "minus", function() { return minus; });
      Promise.resolve(/*! import() */).then(__webpack_require__.bind(null, /*! ./b.js */ "./src/b.js")).then(function (m) {
        return m.add(200, 100);
      });
      function minus(a, b) {
        return a - b;
      }
  })
}]);
// 2. 接着就是向 window[webpackJsonp] 这个数组中 push 了一项,这里需要重点关注一下这个数组的 push 方法和被 push 的数据结构:
// 2.1 这个 push 方法不一定是数组原生的 push 方法(Array.prototype.push),这一点在后面的 runtime 处详述;
// 2.2 这个数组项是个数组,数组第一项又是个数组 [0], 第二项是个对象,对象的结构和 modules 相同,key 是资源路径,value 是个webpack 包装函数:
// 所以 window[webpackJsonp] 的大致结构:[[[0], { ./src/c.js: (function(module, __....) {}) }]]

2、runtime 中的 JSONP 处理

在上面 4.2 runtime 概览(16) 这个小标题,说到了 JSONP 初始化,看看这段代码:

(function (modules) {
    // ... webpack runtime 代码
    var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || []; // 初始化 window[webpackJsonp],如果没有就初始化
    var oldJsonpFunction = jsonpArray.push.bind(jsonpArray); // 暂存数组 push 方法,这个 push 就是 Array.prototype.push
    jsonpArray.push = webpackJsonpCallback; // 重写 jsonpArray.push 方法(注意,这么重写不会改写数组原型)
    jsonpArray = jsonpArray.slice(); // 赋值 jsonpArrray,这个复制不带 push 方法!
    for(var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]); // 本例子中,执行到这里时,jsonpArray 中是空的,所以这个 for 循环暂时不执行;若chunk先于 bundle 这个入口加载,这个 jsonpArray 里面就不是空的,此时,遍历并调用 webpackJsonpCallback,相当于手动触发 jsonp callback;
    var parentJsonpFunction = oldJsonpFunction; // 旧 push 暂存于 parentJsonpFunciton
    // ... other runtime processing
})

这里有必要强调一点,在本文对应的 demo 中,runtime 所在的入口 bundle 的执行顺序是先于后面按需加载的 chunk 的。所以执行过这段代码后,window[webpackJsonp] 已经初始化,同时 jsonpArraypush 方法被改写成了 webpackJsonpCallback 方法;

所以,等到后面 chunk 被加载回来后,执行 chunk 代码:(window["webpackJsonp"] = window["webpackJsonp"] || []).push([[0],{.. 时,执行的这个 push 其实是 webpackJsonpCallback 方法。

你一定在想,为什么会有这个操作?对于我这个搬砖的人来说,只能说:绝!

其实他改写 push 是配合着下面的 for 循环用的,目的在于不管是入口 bundle 先下载还是 chunk 先加载,都能让代码得到正确的执行。先加载 bundle,就是咱们上面一直说的情况,这个不多说,chunk 的 push 方法就是 webpackJsonpCallback 方法,调用 push 就是调用 webpackJsonpCallback。

如果先加载 chunk(例如某些预加载之类的技术下),此时 chunk 中的代码先执行了,这个时候 chunk 会初始化 window[webpackJsonp] 这个数组,而 push 方法就是原生的数组方法 push,很朴素的把数组项添加到 window[webpackJsonp] 数组中,chunk 的使命暂时完成了;接下就等 bundle 加载并执行 runtime 了,等到执行到上面的 runtime 的时候,window[webpackJsonp] 数组已经被初始化,所以后面对 jsonpArray 的 for 循环就可以工作了,这个时候,对 window[webpackJsonp] 中的每一项调用一次 webpackJsonpCallback

这就保证了,不管 chunk 何时加载,都能让代码以预期的方式工作。绝,连城诀!接下来看看这个 webpackJsonpCallback 做了些什么吧:

function webpackJsonpCallback(data) {
    // 1. 这个 data 就是上面说 chunk 代码的时 push 的数组项,结构类似:[[[0], { ./src/c.js: (function(module, __....) {}) }]]
    var chunkIds = data[0]; // chunkIds 就是上面的 [0] 
    var moreModules = data[1]; // moreModules 就是上面的 { ./src/c.js: (fun....) } 这个对象

    // 2. 遍历 chunkIds,如果在 installedChunks 是个非 falsy 的值,说明这个 chunkId 对应的 chunk 正在加载了,
    // 把它的 resolve push 到 resolves 这个数组(注意:installedChunks 中若 chunkId 所对应 chunk 正在加载,其value [Promise resolveFn, Promise rejectFn, promsie ])
    // 把 installedChunks[chunkId] 的值修改为 0,标识该 chunk 已经被加载过!
    var moduleId, chunkId, i = 0, resolves = [];
    for(;i < chunkIds.length; i++) {
        chunkId = chunkIds[i];
        if(Object.prototype.hasOwnProperty.call(installedChunks, chunkId) && installedChunks[chunkId]) {
            resolves.push(installedChunks[chunkId][0]);
        }
        installedChunks[chunkId] = 0;
    }

    // 这里回答:runtime 做了啥,才能让 chunk 代码里面的 module 可以被 runtime 中的 __webpack_require__ 获取到 的问题:
    // 是因为在这里会把 chunk 中所携带的 module 整合到 webpack runtime 接收到 modules 对象中,在下一个事件循环中 __webpack_require__ 从 modules 中取自然就可以取到了
    for(moduleId in moreModules) {
        if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
            modules[moduleId] = moreModules[moduleId];
        }
    }
    
    // 3. 这一步就是用来保证 bundle 先执行的时候,由于 runtime 执行,
    // 数组 push 被改写成 webpackJsonpCallback 后,chunk 加载后,能够正常的把 chunk 相关信息添加到 window[webpackJsonp] 这个数组中
    if(parentJsonpFunction) parentJsonpFunction(data);

    // 4. resolves 中存放的都是加载 chunk时 __webpack_require__.e 方法创建的特殊数组项:
    // (形如[Promise resolve, Promise reject, promise])的第 0 项,即 resolve,
    // 它决定了 __webpack_require__.e 方法最后 return 出去的 Promise.all(promise) 的 promise 对象是否 resolve,
    // 进而决定了编译后的 a.js 模块中:  __wepack_require__.e(0).then(__webpack_require__.bind(c.js)).then(...) 的执行
    // 如果 resolves 不为空,则清空这个队列,使得 __webpack_require__.e 方法最后 return 出去的 Promise.all(promise) promise 得以 resolve 
    while(resolves.length) {
        resolves.shift()();
    }
};

6. splitChunks 之后的输出文件

6.1 修改项目代码

  • 修改 webpack.config.js 追加 splitChunk 配置(optimization.splitChunks):
var path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  entry: {
    m1: './src/m1.js',
    m2: './src/m2.js'
  },
  devtool: 'none',
  output: {
    path: __dirname + '/dist',
    filename: '[name].[chunkhash:4].js',
    chunkFilename: '[name].[chunkhash:8].js'
  },
  mode: 'development',
  plugins: [new HtmlWebpackPlugin()],
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          {
            loader: 'babel-loader',
            options: {
              presets: ['@babel/env']
            }
          }
        ]
      }
    ]
  },
  optimization: {
    splitChunks: {
      chunks: 'all',
      minSize: 0,
      maxSize: 0,
      minChunks: 1,
      name: true,
      automaticNameDelimiter: '~',
      cacheGroups: {
        default: {
          chunks: 'all',
          minChunks: 2,
          priority: -10
        }
      }
    },
    // runtimeChunk: { name: 'runtime' }
  }
};

  • 在 src 下追加两个文件m1.js 、m2.js,作为多入口:

1、 m1.js

import { times } from './d';
console.log(times(10, 4));

2、 m2.js

import { times } from './d';
console.log(times(2, 12));

6.2 打包输出文件

这两个入口都依赖了 d.js 这个模块,这个模块将会被拆成一个单独的 chunk —— dd~m1~m2.a7ba197d.js;下列就是修改配置后的文件输出:

  1. index.html
  2. m1.33c1.js
  3. m2.50e9.js
  4. ddm1m2.a7ba197d.js

我们使用的 html-webpack-plugin,它会帮我你把 js 按顺序插入到 html 文件里;这里需要先看下 index.html 代码,关注一下这些 js 被引入的顺序;

<!DOCTYPE html>
<html>
<head>
	<meta charset="UTF-8">
	<title>Webpack App</title>
</head>
<body>
    <script type="text/javascript" src="dd~m1~m2.a7ba197d.js"></script>
    <script type="text/javascript" src="m1.33c1.js"></script>
    <script type="text/javascript" src="m2.50e9.js"></script>
</body>
</html>

很明显的看出这个公共的 chunk 被最先引入,代码执行的时候也是先去加载并执行它;这和我们前面的例子略有区别,主要体现在:

  1. chunk 被优先引入,而入口 bundle 被后置;

  2. 在新生成的 bundle m1.xxx.js 和 m2.xxx.js 的 runtime 的最后不再返回 __webpack_require__(入口),而是返回了 checkDeferredModules([m1.33c1.js, dd~m1....js])

  3. runtime 新增 checkDeferredModules 方法,同时 webpackJsonpCallback 中也作出了相应的调整,在末尾增加了两行代码:

deferredModules.push.apply(deferredModules, executeModules || []);
return checkDeferredModules();

6.3 执行过程分析

1、首先加载并执行 dd~m1~m2.a7ba197d.js 文件,代码如下:

(window["webpackJsonp"] = window["webpackJsonp"] || []).push([["dd~m1~m2"],{
  "./src/d.js": (function(module, __webpack_exports__, __webpack_require__) {
      "use strict";
      __webpack_require__.r(__webpack_exports__);
      /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "L", function() { return L; });
      /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "times", function() { return times; });
      var L = 'Aragaki Yui';
      function times(a, b) {
        return a * b;
      }
  })
}]);

分析发现,这个 chunk 和前面例子中按需加载的 chunk 没有特别大的差别,仍然是通过向 window[webpackJsonp] 中添加数组项;值得强调的是,这里因为 chunk 先于入口加载并执行,所以这个 window[webpackJsonp] 是在 chunk 中初始化,同时这个 push 方法也是数组原生的 push 方法;

2、加载入口文件 m1.33c1.js ,代码如下:

我们简化了这个入口文件,把注释和与前面未进行 splitChunks 相同的方法和变量删除掉,我们聚焦于不同点:

  • 变量 deferredModules
  • webpackJsonpCallback 方法
  • checkDeferredModules 方法
(function(modules) { // webpackBootstrap
	// install a JSONP callback for chunk loading
	function webpackJsonpCallback(data) {
		// ... 同前 webpackJsonpCallback 逻辑,略去
        var executeModules = data[2];

		// 这一行代码目前还没看出啥作用,看起来是这个 webpackJsonpCallback 被调用时,比如 window[webpackJsonp].push 或者 
        // webpackJsonpCallback(jsonpArray[i]) 的时候可以传入一个 defferedModule 项;
        // webpack 的原文注释字面意思是"从已经加载过 chunk 中把 entry modules 加入到 deferredModules"
		deferredModules.push.apply(deferredModules, executeModules || []);

		// webpackJsonpCallback 是加载 chunk 后执行的,意在每次加载 chunk 后都 check 一下 defrredModules 中的依赖们是否加载完了,加载完了就调用入口 module 执行;
        // 这样有个好处就是不管加载入口还是先加载依赖,最后的代码执行顺序都能得正确执行;
		return checkDeferredModules();
	};
	function checkDeferredModules() {
	    // 方法作用:
        // deferredModules 中的每一项都是一个数组形如:[入口 chunk1, 依赖chunk2, 依赖 chunk3...]
        // checkDeferredModules 的作用就是检查 defferredModules 中的每一项,的依赖部分(从第二项开始)是否都已经安装完成(installedChunks[chunkIed] === 0)
        // 如果都安装完成了,则调用数组项的入口 chunk


        // 变量 result,用于接收入口 chunk 执行后的结果,最后返回它
		var result;

        // 遍历 deferredModules,这个 deferredModules 可以有很多项,所以需要遍历一下
		for(var i = 0; i < deferredModules.length; i++) {
			var deferredModule = deferredModules[i]; // defferredModule 形如: [入口 chunk1, 依赖chunk2, 依赖 chunk3...]
			var fulfilled = true; // 标识符,标识所有的依赖 chunk 都已经被安装
			for(var j = 1; j < deferredModule.length; j++) {
				var depId = deferredModule[j];
				if(installedChunks[depId] !== 0) fulfilled = false; // installedChunks 中对应这一项的值不为 0 说明没有安装完,安装完这个值就是 0 了,关于 installedChunks 在上文 4.2 runtime 概览(3、installedChunks)
			}
			// 经历了上面的 for 循环,如果 fulfilled 还是 true 说明所有的依赖 chunk 都已经加载完成了,就可以执行入口 chunk 了
			if(fulfilled) {
                // 此时从 deferredModules(注意有 s,不是 deferredMdoule) 中删除掉这一项,因为这一项已经 check 过了,
                // 下面一行代码立刻就会执行这一项的入口,所以 checkDeferredModules 对它的工作已经完成了;
                // 如果不删会怎样?后面只要有新的入口,比如 m2.js,添加到 defferedModules 数组就会触发重新 check,如果不删除就会让已经执行过 chunk 再执行一遍,相当于同一段逻辑执行两次;
				deferredModules.splice(i--, 1);
                // __webpack_require__ 调用入口模块,并将其返回结果赋值给 result 
				result = __webpack_require__(__webpack_require__.s = deferredModule[0]);
			}
		}
        // 开开心心拿着入口 module 返回结果返回
		return result;
	}

    // 这个变量时因为 slitChunks 多出来的,这个变量是一个数组,其第一项表示要加载的 module,从第二项开始后面的都是第一项依赖的模块;
    // 从这个例子上来说,第一项是入口 chunk(例如本例中的 m1.xxx.js),后面的是入口依赖的 chunk(如 dd~m1~m2.....js) 
    // 现在这个数组是空的,什么时候里面才会值呢?在 runtime 结尾处,会把入口 ./src/m1.js 和 依赖的 dd~m1~m2 push 进去;
    var deferredModules = [];

	function __webpack_require__(moduleId) { /* 同前 __webpack_require__ 略去 */ }
    
	// JSONP 初始化.....
	var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
	var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
	jsonpArray.push = webpackJsonpCallback;
	jsonpArray = jsonpArray.slice();
	for(var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
	var parentJsonpFunction = oldJsonpFunction;

	// 这里!把入口 ./src/m1.js 和 依赖的 chunk pushu 到 deferredModules 中;
    // 紧接着调用 checkDeferredModules 方法,接着去这个方法里面看看这个方法都发生了啥
	deferredModules.push(["./src/m1.js","dd~m1~m2"]);
	return checkDeferredModules();
})
/************************************************************************/
({
  // 这个对象也是 modules 
  "./src/m1.js": (function(module, __webpack_exports__, __webpack_require__) {
    "use strict";
    __webpack_require__.r(__webpack_exports__);
    /* harmony import */ var _d__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./d */ "./src/d.js");
    
    console.log(Object(_d__WEBPACK_IMPORTED_MODULE_0__["times"])(10, 4));
  })

});

另一个入口 m2.js 同理,在此不再赘述。

  • TIPS: 关于先加载 chunk 还是先加载入口,可以去 index.html 中调整一下 chunk 和 入口的 script 标签顺序,就可以发先控制台的输出仍然是正常的;

7. 总结

本篇详述了 webpack 打包输出文件,又赏析了他设计的精妙:

1)巧妙实现 CMD 规范 __webpack_require____webpack_require__.d,shim 浏览器的模块系统;

2)通过 runtime 的 改写 push 方法 并结合 for 循环调用 webpackJsonpCallback 实现无论入口和chunk的顺序如何,最后代码都能顺利执行

3)webpack 实现细节的精妙,比如 installedChunked 的数据结构的设计,只有一种正在加载的是数组,其余都是 falsy 值;

4)本篇篇幅有点长,建议阅读 2-3 遍;

5)码字不容易,看完了,点个赞呗