阅读 341

webpack 4 源码主流程分析(十二):打包后文件解析

原文首发于 blog.flqin.com。如有错误,请联系笔者。分析码字不易,转载请表明出处,谢谢!

前言及总流程概览 里的 demo 为例, 前七章分析了打包过程,现在来分析它打包后的文件。

demo

//src/a.js
import { add } from 'Src/b';
import('./c.js').then(m => m.sub(2, 1));
const a = 1;
add(3, 2 + a);
复制代码
//src/b.js
import { mul } from '@fe_korey/test-loader?number=20!Src/e';
export function add(a, b) {
  return a + b + mul(10, 5);
}
export function addddd(a, b) {
  return a + b * b;
}
复制代码
//src/c.js
import { mul } from 'Src/d';
import('./b.js').then(m => m.add(200, 100)); //require.ensure() 是 webpack 特有的,已经被 import() 取代。
export function sub(a, b) {
  return a - b + mul(100, 50);
}
复制代码
//src/d.js
export function mul(a, b) {
  const d = 10000;
  return a * b + d;
}
复制代码
//webpack.config.js
var path = require('path');

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',
  resolve: {
    alias: {
      Src: path.resolve(__dirname, 'src/')
    }
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          {
            loader: 'babel-loader'
          }
        ]
      }
    ]
  }
};
复制代码
//babel.config.js
module.exports = {
  presets: ['@babel/env']
};
复制代码

@fe_korey/test-loader 是一个测试 loader,该 loader 作用为代码里的字符串 10000 替换为传入的 number

打包结果文件

根据项目配置及同步异步的关系,打包后一共生成两个文件:

  • bundle.xxxx.js

总代码:见 github

入口文件,该文件名根据配置:entryoutput.filename 生成,里面包含 webpack runtime 代码和同步模块代码。

如若配置了 html-webpack-plugin,那么在生成的 html 里将只会引入此 js 文件。

  • 0.xxxxxxxx.js

总代码:见 github

非入口文件,本例为异步 chunk 文件,该文件名根据配置: output.chunkFilename生成,里面包含异步模块代码。

代码执行流程

根据代码执行顺序来分析,html 文件只需引入了bundle.xxxx.js文件,则从该文件开始执行,如果有其他 import 后,会先跳到对应的 module 进行处理,即先序深度优先遍历算法递归该依赖树。

bundle 主体结构

(function(modules) {
  //runtime代码
})({
  './node_modules/@fe_korey/test-loader/loader.js?number=20!./src/d.js': function(module, __webpack_exports__, __webpack_require__) {
    //...模块代码d
  },
  './src/a.js': function(module, __webpack_exports__, __webpack_require__) {
    //...模块代码a
  },
  './src/b.js': function(module, __webpack_exports__, __webpack_require__) {
    //...模块代码b
  }
});
复制代码

主体结构为一个自执行函数,函数体为 runtime 函数,参数为 modules 对象,各模块以 key-value 的形式一起存在该 modules 对象里。当前 key 为模块的路径,value 为包裹模块代码的一个函数。

runtime 函数

runtime 指的是 webpack 的运行环境(具体作用就是模块解析, 加载) 和 模块信息清单(表现在 jsonpScriptSrc 方法里)。

配置项 optimization.runtimeChunk 可以设置 webpackruntime 这部分代码单独打包。

runtime 函数主体结构

function(modules){
  function webpackJsonpCallback(data){
    //...
  }
  // 设置 script src  __webpack_require__.p 即为 output.publicPath 配置
  function jsonpScriptSrc(chunkId){
    return __webpack_require__.p + "" + ({}[chunkId]||chunkId) + "." + {"0":"d680ffbe"}[chunkId] + ".js"
  }
  function __webpack_require__(moduleId){
    //...
  }
  var installedModules = {};

  var installedChunks = {"bundle": 0};

  // 定义一堆挂载在__webpack_require__上的属性
  //...

  // jsonp 初始化
  // ...

  return __webpack_require__(__webpack_require__.s = "./src/a.js");
}
复制代码

开始执行

代码开始执行:

var installedModules = {};
复制代码

初始化 installedModules,保存所有创建过的 module,用于缓存判断。

// undefined:chunk未加载, null: chunk通过prefetch/preload提前获取过
// Promise:chunk正在加载, 0:chunk加载完毕
// 数组: 结构为 [resolve Function, reject Function, Promise] 的数组, 代表 chunk 在处于加载中
var installedChunks = {
  bundle: 0
};
复制代码

installedChunkskey-value 的形式,用于收集保存所有的 chunk,这里 bundle 就是指的当前 chunk,自然是已经加载好了的。

__webpack_require__ 属性

然后定义了一堆 __webpack_require__ 的属性:

// 异步处理
__webpack_require__.e = function requireEnsure(chunkId) {
  //后文单独分析
};

// 即为传入的modules:各模块组成的对象
__webpack_require__.m = modules;

// 即为installedModules:已经缓存的对象
__webpack_require__.c = installedModules;

// 在exports对象上添加属性,即 增加导出
__webpack_require__.d = function(exports, name, getter) {
  if (!__webpack_require__.o(exports, name)) {
    Object.defineProperty(exports, name, { enumerable: true, get: getter });
  }
};

// 在exports对象上添加 __esModule 属性,用于标识 es6 模块
__webpack_require__.r = function(exports) {
  if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
    Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
  }
  Object.defineProperty(exports, '__esModule', { value: true });
};

// 创建一个伪命名空间对象
__webpack_require__.t = function(value, mode) {
  //没用上,解释暂时略过
};

// 得到 getDefaultExport,即通过 __esModule 属性判断是否是 es6 来确定对应的默认导出方法
__webpack_require__.n = function(module) {
  var getter =
    module && module.__esModule
      ? function getDefault() {
          return module['default'];
        }
      : function getModuleExports() {
          return module;
        };
  __webpack_require__.d(getter, 'a', getter);
  return getter;
};

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

// 即为 publicPath,在output.publicPath配置而来
__webpack_require__.p = '';

// 错误处理
__webpack_require__.oe = function(err) {
  console.error(err);
  throw err;
};
复制代码

每个属性的作用已经写在注释上面。

jsonp 初始化

然后执行 jsonp 初始化:

var jsonpArray = (window['webpackJsonp'] = window['webpackJsonp'] || []); //初始化 window['webpackJsonp']对象
var oldJsonpFunction = jsonpArray.push.bind(jsonpArray); // 暂存 push 方法
jsonpArray.push = webpackJsonpCallback; //重写 jsonpArray 的 push 方法为 webpackJsonpCallback
jsonpArray = jsonpArray.slice(); //拷贝 jsonpArray(不带 push 方法)
for (var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]); //若入口文件加载前,chunks文件先加载了,遍历 jsonpArray 用 webpackJsonpCallback 执行
var parentJsonpFunction = oldJsonpFunction; //旧的 push 方法存入 parentJsonpFunction
复制代码

jsonp 初始化的主要作用就是给 window['webpackJsonp'] 重写了 push 方法为 webpackJsonpCallback 。接着执行:

return __webpack_require__((__webpack_require__.s = './src/a.js'));
复制代码

由入口文件 a 开始,传入 moduleID : "./src/a.js",执行方法 __webpack_require__

__webpack_require__

function __webpack_require__(moduleId) {
  // 判断该module是否已经被缓存到installedModules,如果有,则直接返回它的导出exports
  if (installedModules[moduleId]) {
    return installedModules[moduleId].exports;
  }
  // 定义module并缓存
  var module = (installedModules[moduleId] = {
    i: moduleId,
    l: false,
    exports: {}
  });
  // 执行module代码
  modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

  // 标志module已经读取完成
  module.l = true;

  return module.exports;
}
复制代码

__webpack_require__ 方法的主要作用就是创建缓存 module 后,执行该 module 的代码。其中 modules 即为上文所解释的各模块组成的对象。

执行各同步模块代码

modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); 执行模块 a 的代码。

// 模块 a
'use strict';
__webpack_require__.r(__webpack_exports__);
var Src_b__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__('./src/b.js');

__webpack_require__
  .e(0)
  .then(__webpack_require__.bind(null, './src/c.js'))
  .then(function(m) {
    return m.sub(2, 1);
  });
var a = 1;
Object(Src_b__WEBPACK_IMPORTED_MODULE_0__['add'])(3, 2 + a);
复制代码

代码里 __webpack_require__('./src/b.js') 会去执行模块 b 的代码:

//模块 b
'use strict';
__webpack_require__.r(__webpack_exports__);
__webpack_require__.d(__webpack_exports__, 'add', function() {
  return add;
});
__webpack_require__.d(__webpack_exports__, 'addddd', function() {
  return addddd;
});
var _fe_korey_test_loader_number_20_Src_d__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__('./node_modules/@fe_korey/test-loader/loader.js?number=20!./src/d.js');

function add(a, b) {
  return a + b + Object(_fe_korey_test_loader_number_20_Src_d__WEBPACK_IMPORTED_MODULE_0__['mul'])(10, 5);
}
function addddd(a, b) {
  return a + b * b;
}
复制代码

代码里在导出了两个方法后,去执行模块 d 的代码:

//模块 d
'use strict';
__webpack_require__.r(__webpack_exports__);
__webpack_require__.d(__webpack_exports__, 'mul', function() {
  return mul;
});
function mul(a, b) {
  var d = 20;
  return a * b + d;
}
复制代码

模块 d 代码导出了 mul

import() 的处理

各自模块执行完后,回到模块 a 里执行:

__webpack_require__
  .e(0)
  .then(__webpack_require__.bind(null, './src/c.js'))
  .then(function(m) {
    return m.sub(2, 1);
  });
复制代码

该打包后的代码为动态加载,源代码为:

import('./c.js').then(m => m.sub(2, 1));
复制代码

__webpack_require__.e

__webpack_require__.e 实现异步加载模块,方法为:

// promise队列,等待多个异步 chunk都加载完成才执行回调
var promises = [];

// 先判断是否加载过该 chunk
var installedChunkData = installedChunks[chunkId];
if (installedChunkData !== 0) {
  // 0 means "already installed".

  // a Promise means "currently loading". 目标 chunk 正在加载,则将 promise push到 promises 数组
  if (installedChunkData) {
    promises.push(installedChunkData[2]);
  } else {
    // 新建一个Promise去异步加载目标chunk
    var promise = new Promise(function(resolve, reject) {
      installedChunkData = installedChunks[chunkId] = [resolve, reject]; //这里设置 installedChunks[chunkId]
    });
    promises.push((installedChunkData[2] = promise)); // installedChunks[chunkId]  = [resolve, reject, promise]

    var script = document.createElement('script');
    var onScriptComplete;

    script.charset = 'utf-8';
    script.timeout = 120;
    if (__webpack_require__.nc) {
      script.setAttribute('nonce', __webpack_require__.nc);
    }
    // 设置src
    script.src = jsonpScriptSrc(chunkId);

    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;
      }
    };
    // 设置超时处理
    var timeout = setTimeout(function() {
      onScriptComplete({ type: 'timeout', target: script });
    }, 120000);
    //script标签的onload事件都是在外部js文件被加载完成并执行完成后(异步不算)才被触发
    script.onerror = script.onload = onScriptComplete;
    // script标签加入文档
    document.head.appendChild(script);
  }
}
return Promise.all(promises);
复制代码

参数 0chunkId,在方法 __webpack_require__.e 里,主要功能就是模拟 jsonp 去异步加载目标 chunk 文件 0,返回一个 promise 对象。

然后加载异步文件 0.e3296d88.js 并执行。

加载非入口文件0.e3296d88.js

非入口文件主体结构

(window['webpackJsonp'] = window['webpackJsonp'] || []).push([
  [0],
  {
    './src/c.js': function(module, __webpack_exports__, __webpack_require__) {
      //模块 c
    },

    './src/d.js': function(module, __webpack_exports__, __webpack_require__) {
      //模块 d
    }
  }
]);
复制代码

在模块加载后,就会立即执行的 window['webpackJsonp'].push() 。由 jsonp 初始化可知, 即执行 bundle 文件里的 webpackJsonpCallback 方法。

webpackJsonpCallback

function webpackJsonpCallback(data) {
  var chunkIds = data[0];
  var moreModules = data[1]; //异步 chunk 的各模块组成的对象

  var moduleId,
    chunkId,
    i = 0,
    resolves = [];
  // 这里收集 resolve 并将所有 chunkIds 标记为已加载
  for (; i < chunkIds.length; i++) {
    chunkId = chunkIds[i];
    if (Object.prototype.hasOwnProperty.call(installedChunks, chunkId) && installedChunks[chunkId]) {
      resolves.push(installedChunks[chunkId][0]); //将 resolve push 到 resolves 数组中
    }
    installedChunks[chunkId] = 0; //标记为已加载
  }
  // 遍历各模块组成的对象,将每个模块都加到 modules
  for (moduleId in moreModules) {
    if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
      modules[moduleId] = moreModules[moduleId];
    }
  }
  // 执行保存的旧 push 方法,可能是 array.push (即 push 到 window.webpackJsonp),也可能是前一个并行执行了 runtime 的 bundle 的 webpackJsonpCallback,即递归执行 webpackJsonpCallback,如多入口同时 import 同一个 module 的情况。
  if (parentJsonpFunction) parentJsonpFunction(data);

  //循环触发 resolve 回调
  while (resolves.length) {
    resolves.shift()();
  }
}
复制代码

webpackJsonpCallback 方法主要将异步的 chunk 里的所有模块都加到 modules 后,改变 installedChunks[chunkId] 的状态为 0(即已加载),然后执行之前创建的 promiseresolve()

执行 resolve 的回调 then 方法

回到模块 a 根据 promise 的定义,执行 resolve 后,就会去执行对应的 then 方法:

//...
then(__webpack_require__.bind(null, './src/c.js'));
//...
复制代码

即执行模块 c

'use strict';
__webpack_require__.r(__webpack_exports__);
__webpack_require__.d(__webpack_exports__, 'sub', function() {
  return sub;
});
var Src_d__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__('./src/d.js');

Promise.resolve()
  .then(__webpack_require__.bind(null, './src/b.js'))
  .then(function(m) {
    return m.add(200, 100);
  });
function sub(a, b) {
  return a - b + Object(Src_d__WEBPACK_IMPORTED_MODULE_0__['mul'])(100, 50);
}
console.log('c');
复制代码

模块 c 里引入了模块 d,这里的模块 d 与前文的模块 d 虽然是一样的,但由于用的 loader 不一样,所以会认为是两个不同的模块,故会再次加载,互不影响。这里模块 d 不在累述。

然后执行:

Promise.resolve()
  .then(__webpack_require__.bind(null, './src/b.js'))
  .then(function(m) {
    return m.add(200, 100);
  });
复制代码

Promise.resolve 方法允许调用时不带参数,直接返回一个resolved 状态的 Promise 对象。即执行 then 方法,即 __webpack_require__.bind(null, './src/b.js')。然后在 __webpack_require__ 方法里判断缓存有模块 b,则直接返回模块 b 对应的 exports。到此异步加载完成。

根据微任务队列的先后顺序,先执行模块 a 的第二个 then 回调,然后执行模块 c 的第二个 then 回调,都执行完成后,执行加载完成回调 onScriptComplete。到此代码运行完成。

异步加载小结

再次梳理下异步加载的关键思路:

  1. 通过 __webpack_require__ 加载运行入口 module
  2. 模块代码里遇到 import()即执行 __webpack_require__.e 加载异步 chunk
  3. __webpack_require__.e 使用模拟 jsonp 的方式及创建 script 标签来加载异步 chunk,并为每个 chunk 创建一个 promise
  4. 等到异步 chunk 被加载后,会执行 window['webpackJsonp'].push,即 webpackJsonpCallback 方法
  5. webpackJsonpCallback 里将异步 chunk 里的 module 加入到 modules, 并触发前面创建 promiseresolve 回调,然后执行其 then 方法即 __webpack_require__ 去加载新的 module

扩展 使用 splitChunks 切割后的文件解析

demo

// a.js
import { mul } from './d';
// b.js
import { mul } from './d';
// d.js为普通同步文件
复制代码
//webpack.config.js
{
  "entry": {
    "bundle1": "./src/e.js",
    "bundle2": "./src/f.js"
  },
  "plugins": [new HtmlWebpackPlugin()],
  //...
  "optimization": {
    "splitChunks": {
      "chunks": "all",
      "minSize": 0, //当模块小于这个值时,就不拆
      "maxSize": 0, //当模块大于这个值时,尝试拆分
      "minChunks": 1, //重复一次就打包
      "name": true, //是否以cacheGroups中的filename作为文件名
      "automaticNameDelimiter": "~", //打包的chunk名字连接符
      "cacheGroups": {
        "default": {
          "chunks": "all",
          "minChunks": 2,
          "priority": -10
        }
      }
    }
  }
}
复制代码

引入插件 HtmlWebpackPlugin 辅助分析。

打包后代码见 github,以下只做关键点记录:

  • html 会引入每个入口 bundle 生成的 js 和公共部分的 js
  <script type="text/javascript" src="default~bundle1~bundle2.183bf5f4.js"></script><script type="text/javascript" src="bundle1.10ad.js"></script><script type="text/javascript" src="bundle2.c333.js"></script></body>
复制代码
  • jsonp 初始化阶段,执行:
for (var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
复制代码

和在 webpackJsonpCallback 方法里执行:

if (parentJsonpFunction) parentJsonpFunction(data);
复制代码

可以保证无论页面先加载入口文件还是非入口文件,都可以将依赖 module 同步到各自的 chunk 里。

  • 两个入口文件 bundle1.xxxx.js bundle2.xxxx.jsruntime 代码里会多出一个新的变量 deferredModules
var deferredModules = [];
//...
deferredModules.push(['./src/e.js', 'default~bundle1~bundle2']);
return checkDeferredModules();
复制代码

该变量为一个数组,第一个变量是需要加载的 module,后面的变量就是要加载本 module 所需的其他依赖 module。然后在 runtime 的末尾执行:return checkDeferredModules();

  • checkDeferredModules
function checkDeferredModules() {
  var result;
  for (var i = 0; i < deferredModules.length; i++) {
    var deferredModule = deferredModules[i];
    var fulfilled = true;
    for (var j = 1; j < deferredModule.length; j++) {
      var depId = deferredModule[j];
      if (installedChunks[depId] !== 0) fulfilled = false; //判断依赖模块有没有加载过
    if (fulfilled) {
      deferredModules.splice(i--, 1);
      result = __webpack_require__((__webpack_require__.s = deferredModule[0])); //如果所有依赖模块都加载了(即modules里有依赖模块),则就可以读取目标的module了
    }
  }

  return result;
}
复制代码

该方法主要检查依赖的 module 是否加载过,若都加载了则加载目标 module

  • webpackJsonpCallback 格外代码
//...
deferredModules.push.apply(deferredModules, executeModules || []);
return checkDeferredModules();
复制代码

该方法增加了这两句代码,用于在调用 webpackJsonpCallback 时(即 window["webpackJsonp"].pushwebpackJsonpCallback(jsonpArray[i])),有其他依赖的时候可以再去调用 checkDeferredModules 进行依赖检查。

splitChunks 切割后加载小结

再次梳理下 splitChunks 切割后的关键思路:

  1. 根据 script 标签先后顺序,html 先加载公共依赖 default~bundle1~bundle2.xx.js,即在 window["webpackJsonp"]push 了该 module
  2. html 加载 bundle1.js,在 jsonp 初始化里调用 webpackJsonpCallback(jsonpArray[i]) 将公共依赖模块加到 modules 里并改变其状态为已加载后,调用 checkDeferredModules(),但 deferredModules 为空,所以没有任何操作。
  3. 然后回到 runtime 里继续执行,将当前 module 和依赖 module pushdeferredModules 里,再次调用 checkDeferredModules,此时判断各依赖模块状态均为已加载后,加载当期 module
  4. html 加载 bundle2 文件,此后逻辑跟 bundle1 一致。