ChuChencheng / note

菜鸡零碎知识笔记
Creative Commons Zero v1.0 Universal
3 stars 0 forks source link

Webpack 打包结果分析 #32

Open ChuChencheng opened 4 years ago

ChuChencheng commented 4 years ago

如何分析 Webpack 打包

准确地说,本文是分析 Webpack 打包的结果,目的是看看 Webpack 如何将每个模块(文件)组合起来,在浏览器中是如何执行的打包代码,包括如何加装异步的块。

因此,只要写个简单的项目,分析其打包出来的代码即可。

准备一个精简的项目

按照 Webpack 官方 getting started 教程初始化一个项目,然后写入以下文件:

src/index.js:

// 同步引入
import syncHello from './sync-hello'

document.querySelector('#text').innerText += `${syncHello}\n`

// 异步引入
import(/* webpackChunkName: "async" */ './async-hello').then(({ default: asyncHello }) => {
  document.querySelector('#text').innerText += `${asyncHello}\n`
})

src/sync-hello.js:

import generateHello from './sync-util'

export default generateHello('sync code')

src/sync-util.js:

const generateHello = (source) => {
  return `Hello from ${source}`
}

export default generateHello

src/async-hello.js:

import generateHello from './sync-util'

export default generateHello('async code')

就这么简单的四个文件。

分析模块依赖关系

显然,每个文件作为一个模块的话,四个文件有以下关系:

     index.js(入口)
        /            \
sync-hello     async-hello
      /              /
      |             /
      sync-util

执行打包

package.json 中添加脚本:

webpack --mode development --config webpack.config.js

mode 记得设置为 development ,否则打包出来的代码会是压缩混淆过的,难以分析。

webpack.config.js 中,把 sourceMap 改一下:

module.exports = {
  devtool: 'inline-source-map'
}

这是防止 Webpack 使用 eval 来打包模块,也是为了方便分析模块的代码。

最后打包出来有两个文件,一个 main.js ,一个 async.js

image

分析打包结果

打开 dist/main.js 文件,可以看到内容充满了各种注释,四个加起来不到 20 行的代码,一个入口就有 200 多行,不过这些都是实现模块化的必要代码,让我们慢慢来分析。

整体结构

首先,从整体来看,可以发现整个 main.js 外层是一个自执行函数的结构:

(function (modules) {
  // webpack bootstrap
})({
  // modules
})

main.js 文件被加载到浏览器后,就会执行这个函数,这个函数也就相当于是 Webpack 的引导程序。

函数的参数 modules 显然是我们书写的各个模块,在后面以一个对象的形式传入,接着来看看我们写的模块被转换成什么样子

模块参数 modules

转到传入的参数 modules ,发现是个对象:

{
  "./src/index.js": (function (module, __webpack_exports__, __webpack_require__) {
    // 模块内容
  })
  "./src/sync-hello.js": (function (module, __webpack_exports__, __webpack_require__) {
    // 模块内容
  })
  "./src/sync-util.js": (function (module, __webpack_exports__, __webpack_require__) {
    // 模块内容
  })
}

可以看到一共是三个模块,都是同步加载的模块,异步的模块不在 main.js 里面。

我们从 "./src/sync-hello.js" 这个模块入手,因为它既有 import 也有 export

{
  "./src/sync-hello.js": (function(module, __webpack_exports__, __webpack_require__) {
    // 模块默认是严格模式
    "use strict";
    // 在 exports 中加上 __esModule 属性,表示是 ES 模块
    __webpack_require__.r(__webpack_exports__);
    // 导入 sync-util 模块
    var _sync_util__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/sync-util.js");
    // 导出此模块
    __webpack_exports__["default"] = (Object(_sync_util__WEBPACK_IMPORTED_MODULE_0__["default"])('sync code'));
  })
}

可以看到,我们在文件中写的 ES6 importexport 都没了,变成了使用 __webpack_require__, __webpack_exports__ 这两个参数,个人觉得,因为所有的同步模块都被打包到了同一个文件中,所以就不能再用 ES6 的模块导入导出方法,需要 Webpack 内部自己实现从同一个文件中引入不同的模块。

其导入导出的风格类似 CommonJS

导入类似 require 函数,不过参数并不是路径,而是一个 id ,就是 modules 参数对象的 key 值

导出则是在 __webpack_exports__ 上挂上导出的内容,类似 exports 对象。这里导出了一个 default 属性。

其他的模块也是类似的,修改了 importexport

引导函数

看完参数,就该看看函数本体了

从上述模块的改写可以知道,重点在于如何导入导出模块,因此我们重点看看 __webpack_require__ 这个函数

(function (modules) {
  // 模块缓存
  var installedModules = {}
  // The require function
  function __webpack_require__(moduleId) {

    // 检查是否在缓存中,如果是,则表示模块执行过了,直接返回缓存的结果
    if(installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }
    // 创建新模块并缓存
    var module = installedModules[moduleId] = {
      i: moduleId,
      l: false,
      exports: {}
    };

    // 执行模块函数
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

    // 标识模块已经加载过了
    module.l = true;

    // 返回模块的导出(exports)
    return module.exports;
  }
  // ...定义了一堆东西
  // Load entry module and return exports
  return __webpack_require__(__webpack_require__.s = "./src/index.js");
})({
  // modules
})

__webpack_require__ 中,可以看到,每个模块其实是一个对象 module ,其属性 exports 就是模块的导出, __webpack_require__ 的功能其实就是,根据 moduleId 在缓存中查找对应模块执行的结果,如果没有找到,则执行 modules 参数中对应 moduleId 的函数,把 module.exports 作为 __webpack_exports__ 参数传入,函数内部对 __webpack_exports__ 的修改,就是对 module.exports 的修改。 __webpack_require__ 最终返回的是 moduleId 对应模块的导出,也就是 module.exports

定义好 __webpack_require__ 后,引导函数最后执行了导入入口模块 ./src/index.js ,至此,一个 Webpack 应用就开始执行了。

异步模块怎么办

可能都知道, Webpack 导入异步模块是用了 jsonp ,那具体是个什么样的过程呢?

我们先看看有用到异步导入的模块,也就是 ./src/index.js ,在代码中是这么写的

import(/* webpackChunkName: "async" */ './async-hello').then(({ default: asyncHello }) => {
  document.querySelector('#text').innerText += `${asyncHello}\n`
})

经过 Webpack 打包,变成了:

__webpack_require__.e(/*! import() | async */ "async").then(__webpack_require__.bind(null, /*! ./async-hello */ "./src/async-hello.js")).then(({ default: asyncHello }) => {
  document.querySelector('#text').innerText += `${asyncHello}\n`
})

也就是说, import() 被转换成了:

__webpack_require__.e(chunkId).then(__webpack_require__.bind(null, moduleId))

那么我们来看看 __webpack_require__.e 是何方神圣

下面我把异步加载 jsonp 相关的代码都揪出来了,这段代码都在顶层的自执行函数,也就是引导函数中:

// 加载代码块的 jsonp 回调
function webpackJsonpCallback (data) {
  var chunkIds = data[0];
  var moreModules = data[1];

  // 把 data 参数数组的第二个元素添加到 modules 对象中(也就是引导函数的 modules 参数)
  // 把 chunkIds 里的元素都标记为已加载(即在 installedChunks 中置为 0 )
  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;
  }
  for (moduleId in moreModules) {
    if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
      modules[moduleId] = moreModules[moduleId];
    }
  }
  if (parentJsonpFunction) parentJsonpFunction(data);

  while (resolves.length) {
    resolves.shift()();
  }
};

// 存储已加载或正在加载的块(chunk)
// undefined = chunk 未加载, null = chunk preloaded/prefetched
// Promise = chunk 正在加载, 0 = chunk 已加载
var installedChunks = {
  "main": 0
};

// script path function
function jsonpScriptSrc(chunkId) {
  return __webpack_require__.p + "" + ({"async":"async"}[chunkId]||chunkId) + ".js"
}

// 由于 main.js 只包含入口的块(chunk),因此提供以下函数
// 加载额外(异步)块的函数
__webpack_require__.e = function requireEnsure (chunkId) {
  var promises = [];

  // JSONP chunk loading for javascript

  var installedChunkData = installedChunks[chunkId];
  if (installedChunkData !== 0) { // 0 表示 "已安装".

    // 一个 Promise 表示 "正在加载".
    if (installedChunkData) {
      promises.push(installedChunkData[2]);
    } else {
      // installedChunks[chunkId] === undefined , 表示此 chunk 未加载
      // 因此将 installedChunks[chunkId] 赋值为 Promise 表示正在加载这个 chunk
      var promise = new Promise(function (resolve, reject) {
        installedChunkData = installedChunks[chunkId] = [resolve, reject];
      });
      promises.push(installedChunkData[2] = promise);

      // start chunk loading
      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 = jsonpScriptSrc(chunkId);

      // create error before stack unwound to get useful stacktrace later
      var error = new Error();
      // script 标签下载、执行完成后的回调
      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;
        }
      };
      // 2 分钟超时
      var timeout = setTimeout(function () {
        onScriptComplete({ type: 'timeout', target: script });
      }, 120000);
      script.onerror = script.onload = onScriptComplete;
      // 把 script 标签插入 html ,开始下载异步模块
      document.head.appendChild(script);
    }
  }
  return Promise.all(promises);
};

var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
// 重写 push 方法,因此 script 标签下载完成时执行的 jsonp 回调就是 webpackJsonpCallback
jsonpArray.push = webpackJsonpCallback;
jsonpArray = jsonpArray.slice();
for(var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
// parentJsonpFunction 就是数组原来的 push 方法
var parentJsonpFunction = oldJsonpFunction;

直接讲一下遇到异步模块的时候是怎么一个流程吧。

  1. 在加载入口之前,会先定义好 jsonp 回调 webpackJsonpCallback, 异步导入方法 __webpack_require__.e 还有 window["webpackJsonp"] 这个全局的变量,并重写 window["webpackJsonp"].push 方法为 webpackJsonpCallback
  2. 当在文件中导入异步模块,也就是调用了 import() ,由于 Webpack 打包前的转换,会变成调用 __webpack_require__.e
  3. __webpack_require__.e 中,对于没有下载的异步模块,会用 JS 新建 script 标签的方式去下载模块的代码,并创建一个 Promise ,把 Promise 存在一个缓存中
  4. script 标签下载完成后,会自动执行下载的脚本,而在异步的脚本中,代码是这样的:
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([["async"], {
  "./src/async-hello.js": (function (module, __webpack_exports__, __webpack_require__) {
    "use strict";
    __webpack_require__.r(__webpack_exports__);
    var _sync_util__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/sync-util.js");

    __webpack_exports__["default"] = (Object(_sync_util__WEBPACK_IMPORTED_MODULE_0__["default"])('async code'));
  })
}]);

其中会执行 window["webpackJsonp"].push 方法,也就是 webpackJsonpCallback

  1. webpackJsonpCallback 中,会把异步代码中的模块都保存到 modules 参数中,并且 resolve 存在缓存中对应块的 Promise
  2. 异步脚本执行完成后,在 __webpack_require__.e 中定义的 script 标签回调 onScriptComplete 就开始处理后续,如果异步 chunk 没有成功加载,则把缓存里,即 installedChunks[chunkId] 置为 undefined ,表示未加载,下次会重新再去下载这个 chunk 。

根据上述步骤,可以得到一些结论:

所以, __webpack_require__.e(chunkId) 返回的是一个 Promise ,当它 resolve 的时候,表示异步模块已经被注册到 modules 中,可以 require 了

因此 __webpack_require__.e(chunkId) 后, Webpack 内部还要再执行一个 then

__webpack_require__.e(chunkId).then(__webpack_require__.bind(null, moduleId))

相当于:

__webpack_require__.e(chunkId).then(() => {
  return __webpack_require__(moduleId)
})

最终返回一个新的 Promise , resolve 的值就是 __webpack_require__(moduleId) ,这样,下一个 then 就能接收到异步模块导出的值了。(关于在 then 里面 return 一个值会如何处理,参照 #26 )

跑起来看看?

dist 文件夹下新建一个 index.html ,引入打包后的 main.js

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>
</head>
<body>
  <div id="text"></div>
  <script src="./main.js"></script>
</body>
</html>

直接用浏览器打开这个文件,可以看到正确的结果

image

在开发者工具的 Network 面板中,可以看到请求了三个文件: index.html, main.js, async.js

在 Elements 面板中,展开 head 标签,可以看到多了个 src 为 async.jsscript 标签

image

一切正常。

ChuChencheng commented 4 years ago

打包之前的步骤还包括如何解析模块、生成依赖图等步骤

解析模块大概就是个把 ES 模块的语法转换成 __webpack_require__ 等自有模块方法的过程,涉及如何将代码转换为 AST

在生成依赖图的时候,会从入口出发,解析入口模块,根据 AST 获取它导入的依赖,再去解析各个依赖,是一个循环遍历的过程

而打包时,就会根据上面步骤生成的依赖图,去赋值 modules 参数,注册相应的模块。

ChuChencheng commented 4 years ago

关于 jsonp 加载异步模块,这边稍微总结几句话

  1. 在浏览器端已经加载好的代码中,定义好 jsonp 的回调函数
  2. 遇到导入异步模块的时候,新建一个 Promise ,并保存这个 Promise 到缓存中
  3. 在 jsonp 回调函数中,根据 chunk 的 id 获取第 2 步中创建的 Promise 并 resolve ,由此可以获取异步模块加载完成的时机