webpack打包文件分析
学习webpack原理通常都是从打包文件入手,从入口出发再深入探究。

单文件

最简单的情况下,项目中只src/index.js,同时index.js的代码还特别简单:

  1. const hello = 'hello~';
  2. console.log(hello);

webpack v4

运行webpack —mode development,从dist中可以看到main.js。删除掉不必要的注释,发现其结构是这样的:

  1. (function (modules) {
  2. // 一大堆逻辑
  3. })
  4. ({
  5. "./src/index.js":
  6. (function (module, exports) {
  7. eval("const hello='hello~';\n\n\nconsole.log(hello);\n\n\n//#sourceURL=webpack:///./src/index.js?");
  8. })
  9. });

可以看到这是一个IIFE(立即执行函数:Immediately-invoked function expression, IIFE)。
该IIFE如参数定义叫modules,实际传参是对象:

  1. {
  2. // 对象key
  3. "./src/index.js":
  4. // 对象value是一个函数
  5. (function (module, exports) {
  6. eval("const hello='hello~';\n\n\nconsole.log(hello);\n\n\n//#sourceURL=webpack:///./src/index.js?");
  7. })
  8. }

这个对象的key是文件路径,这个对象的值也是函数,函数体执行了eval,eval中的内容才是真正要执行的代码(为什么要用eval?关于eval )。
继续看这个函数function (modules) { },先看结构:

  1. // 结构说明
  2. function (modules) {
  3. // 模块缓存
  4. var installedModules = {};
  5. // 定义__webpack_require__
  6. function __webpack_require__(moduleId) { }
  7. // 定义 __webpack_require__的各种个样的属性
  8. // ... ...
  9. // 入口文件是 index.js
  10. // 调用__webpack_require__
  11. return __webpack_require__(__webpack_require__.s = "./src/index.js");
  12. }

从上述结构中,可以看到,核心其实还是在return这个地方,调用了webpack_require,传进去的参数是入口文件的地址”./src/index.js”。中间很大篇幅的代码量是在对webpack_require进行设置,这里暂时用不到,且先不细看(dev模式生成的代码中其实注释也挺明白)。
着重看看webpack_require里面发生了什么:

  1. // The require function
  2. function __webpack_require__(moduleId) {
  3. // Check if module is in cache
  4. // 检查moduleId是不是已经被缓存
  5. // 如果缓存了返回缓存
  6. if(installedModules[moduleId]) {
  7. return installedModules[moduleId].exports;
  8. }
  9. // Create a new module (and put it into the cache)
  10. var module = installedModules[moduleId] = {
  11. i: moduleId,
  12. l: false,
  13. exports: {}
  14. };
  15. // Execute the module function
  16. modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
  17. // Flag the module as loaded
  18. module.l = true;
  19. // Return the exports of the module
  20. return module.exports;
  21. }
  • 首先,入参moudleId就是传进来的’./src/index.js’,入口文件路径。
  • 检查模块缓存中是不是已经有了此模块,如果有,直接返回moudle.exports属性。如果没有,构造一个moudle对象:
    1. {
    2. i: moduleId, // id
    3. l: false, // 被加载了吗?
    4. exports: {} // 输出
    5. }

这个对象作为moudleId的key对应的value放入缓存中,所以这个缓存就是个Object形式的Map。

  • 调用moudles中对应moudleId的方法:
    1. modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

这个”modules.moduleId”是谁呢?就是IIFE中被作为参数传进来的object,对应的key为moudleId的值,是一个函数:

  1. // IIFE的参数 { "./src/index.js": ... }
  2. function (module, exports) {
  3. eval("const hello='hello~';\n\n\nconsole.log(hello);\n\n\n//#sourceURL=webpack:///./src/index.js?");
  4. }

所以上面函数中参数,在webpack_require中调用的时候分别对应:module对应module,exports对应module.exports。目前还看不出有什么作用,所以暂且不表。

  • 调用完成后(调用中函数中eval自然是被执行过了)设置loaded标记为true,表示已经加载了。最终返回了moudle.exports;

整个流程其实很清楚。到目前为止,遗留的问题是:module, moudle.exports怎么用?这个问题后面分析多文件会弄明白。
另外,值得一提的是webpack v5在build单文件的时候可不像v4这么啰嗦

webpack v5

时代在变化。
和上述v4中用的相同的src代码,可以看到,v5版本的简单明了。直接一个IIFE完事:

  1. // v5
  2. (() => {
  3. eval("const hello = 'hello~';\n\nconsole.log(hello);\n\n//# sourceURL=webpack://y/./src/index.js?");
  4. })()

(简直就是: 开局一个IIFE,一刀99级)

多文件

把src改动:增加一个util.js

  1. const sayHello = (to = 'world') => {
  2. console.log(`hello ${to} ~`)
  3. }
  4. export { sayHello };

index.js中使用sayHello:

  1. import { sayHello } from './util'
  2. console.log(sayHello());

这次再看build的结果。结构还是一样的。

webpack v4

先看作为IIFE的参数传递那个对象modules:

  1. {
  2. "./src/index.js": (function (module, __webpack_exports__, __webpack_require__) {
  3. "use strict";
  4. eval("一堆东西...略");
  5. }),
  6. "./src/util.js": (function (module, __webpack_exports__, __webpack_require__) {
  7. "use strict";
  8. eval("一堆东西...略");
  9. })
  10. }

这个对象可现在有两个key,分别是”./src/index.js”、”./src/util.js”。key对应的值还是函数,函数的参数是和之前单文件稍有不同,另外还是eval中一堆代码。
不过因为设计多文件了,而代码中有多文件的调用关系,所以eval还是得看看的:
index.js中对应的eval:

  1. eval(`
  2. __webpack_require__.r(__webpack_exports__);
  3. var _util__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(\"./src/util.js\");
  4. console.log(Object(_util__WEBPACK_IMPORTED_MODULE_0__[\"sayHello\"])());
  5. `);

util.js中对应的eval:

  1. eval(`
  2. __webpack_require__.r(__webpack_exports__);
  3. __webpack_require__.d(__webpack_exports__, \"sayHello\", function() { return sayHello; });
  4. const sayHello = (to = 'world') => {console.log(`hello ${to} ~`)}
  5. `);

在这些被格式化的eval代码中,都用了webpack_require.r, .d这些方法,看来要进一步理解,还得继续看看这些函数了。另外,这里的webpack_exports记着是这样调用传下来的module.exports,是一个对象:
modules[moduleId].call(module.exports, module, module.exports, webpack_require); 说白了 webpack_require.r, .d这些方法其实就是在给这个对象设置属性。

看到在webpack_require.r 的注释中说道这个函数是在 define __esModule on exports:

  1. // define __esModule on exports
  2. __webpack_require__.r = function (exports) {
  3. if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
  4. Object.defineProperty(exports, Symbol.toStringTag, {
  5. value: 'Module'
  6. });
  7. }
  8. Object.defineProperty(exports, '__esModule', {
  9. value: true
  10. });
  11. };

上文中写道,最终其实传进来的是模块moudle对象的moudle.exports,这个moudle.exports就是这里的exports,那么这个函数中,对exports进行了属性设置__esModule设成了true。由此可见,这个方法应该不那么重要。
接着看看webpack_require.d:

  1. // define getter function for harmony exports
  2. __webpack_require__.d = function (exports, name, getter) {
  3. if (!__webpack_require__.o(exports, name)) {
  4. Object.defineProperty(exports, name, {
  5. enumerable: true,
  6. get: getter
  7. });
  8. }
  9. };

.d中也defineProperty了,对象也是exports,定义的是什么呢?原来是对exports定义了一个name属性,属性的get是走getter函数,结合使用看看具体的,比如util.js对应的eval中:

  1. __webpack_require__.d(__webpack_exports__, 'sayHello', function() { return sayHello; })
  2. const sayHello = (to = 'world') => {console.log(`hello ${to} ~`)}

这样的话.d函数就相当于在exports对象上面定义了一个属性名sayHello, 当你去读取sayHello的时候,实际执行的是function() { return sayHello; },当然它会返回sayHello这个变量的值。

到此为止,似乎有点清晰了呢~
要彻底拨云见日,还得整个流程梳理下:

  • build后,整体是个IIFE。
  • IIFE的参数是模块ID对应模块内容,模块ID就是文件路径,内容核心是一个函数,函数中会调用eval代码,函数的传参moudle、webpack_exports和webpack_require。
  • IIFE的return中将入口index.js作为webpack_require的参数传递了进去。
  • webpack_require执行了moudles中key为./src/index.js的代码,其中eval在执行的过程中遇到了源码是import的地方,对应翻译为webpack_require(“./src/util.js”)
  • 继续看webpack_require(“./src/util.js”)的执行:
    1. 在执行的之后返回的就是”./src/util.js”对应的moudle的exports(这个moudle我们称之为当前模块);
    2. 执行中遇到源代码为exports的地方,实际调用了webpack_require.d,在这个函数中对当前模块的exports属性进行了设置,这样保证了上面提到的return出去的东西能从exports属性上面拿到导出的结果。
  • 再回到./src/index.js对应的eval,调用webpack_require(“./src/util.js”)之后拿到的就是”./src/util.js”模块对应的exports,那么根据代码逻辑,后续使用就好。
  • 入口文件执行完,返回入口文件的moudle.exports。

整个过程就是这样,就是从入口文件进去,遇到依赖模块了之后通过webpack_require去取依赖,此处涉及了缓存的逻辑(加载过的自然是不用加载了,直接拿就好了),依赖模块在第一次加载的过程中还定义了模块。
如此一个深度的依赖遍历,一层层深入,再一层层出来的过程,完成了整个程序的调用过程。

另外,在整个过程中,可以看到,webpack在自己实现Common js,我们的代码是用esMoudle写的,但是真正打包的import,export过程走的是commonjs类似,或者说,因为代码是在浏览器中执行,webpack用自己的逻辑webpack_requirewebpack_require.d、installedModules等实现了一套在浏览器端可运行的模块化方案。

webpack v5

在v5中,逻辑没有变化,就是上文中分析那样,但是生成的代码看上去清爽了很多:

  1. (() => { // webpackBootstrap
  2. "use strict";
  3. // 所有模块
  4. var __webpack_modules__ = ({
  5. "./src/index.js": ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
  6. eval("... ...略");
  7. }),
  8. "./src/util.js": ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
  9. eval("... ...略");
  10. })
  11. });
  12. // The module cache
  13. var __webpack_module_cache__ = {};
  14. // The require function
  15. function __webpack_require__(moduleId) {
  16. // Check if module is in cache
  17. if (__webpack_module_cache__[moduleId]) {
  18. return __webpack_module_cache__[moduleId].exports;
  19. }
  20. // Create a new module (and put it into the cache)
  21. var module = __webpack_module_cache__[moduleId] = {
  22. // no module.id needed
  23. // no module.loaded needed
  24. exports: {}
  25. };
  26. // Execute the module function
  27. __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
  28. // Return the exports of the module
  29. return module.exports;
  30. }
  31. /* webpack/runtime/define property getters */
  32. (() => {
  33. // define getter functions for harmony exports
  34. __webpack_require__.d = (exports, definition) => {};
  35. })();
  36. /* webpack/runtime/hasOwnProperty shorthand */
  37. (() => {
  38. __webpack_require__.o = (obj, prop) => Object.prototype.hasOwnProperty.call(obj, prop)
  39. })();
  40. /* webpack/runtime/make namespace object */
  41. (() => {
  42. // define __esModule on exports
  43. __webpack_require__.r = (exports) => {};
  44. })();
  45. // startup
  46. // Load entry module
  47. __webpack_require__("./src/index.js");
  48. // This entry module used 'exports' so it can't be inlined
  49. })();

可以看到打包代码主体还是IIFE,但是这次所有模块并不是通过IIFE的参数传递进去,而是直接定义了个变量在IIFE内部叫webpack_modules,这点差不多就是最大的区别了,别的没有什么。

异步import

我们用这样的语法实现异步加载:

  1. import('url').then(data => {
  2. });

那么webpack自然就帮我们实现了支持这样语法的逻辑,现在把src中的代码修改如下:

  1. // index.js
  2. import ('./async').then(data => {
  3. console.log('async import:', data)
  4. })
  5. // async.js
  6. export const aData = 'i am async data';

然后打包分析看看webpack怎么实现的异步加载。

webpack v4

在v4版本中,我们发现凡事有异步加载的文件都会拆分出来,而且命名诡异,我们得到了两个文件:main.js 和 0.js。

整体看下结构,main.js:

  1. (function (modules) { // webpackBootstrap
  2. // install a JSONP callback for chunk loading
  3. function webpackJsonpCallback(data) {
  4. // ... ...
  5. };
  6. // The module cache
  7. var installedModules = {};
  8. // object to store loaded and loading chunks
  9. var installedChunks = {
  10. "main": 0
  11. };
  12. // script path function
  13. function jsonpScriptSrc(chunkId) {
  14. // ... ...
  15. }
  16. // The require function
  17. function __webpack_require__(moduleId) {
  18. // ... ...
  19. return module.exports;
  20. }
  21. // This file contains only the entry chunk.
  22. // The chunk loading function for additional chunks
  23. __webpack_require__.e = function requireEnsure(chunkId) {
  24. // ... ...
  25. return Promise.all(promises);
  26. };
  27. // expose the modules object (__webpack_modules__)
  28. __webpack_require__.m = modules;
  29. // expose the module cache
  30. __webpack_require__.c = installedModules;
  31. // define getter function for harmony exports
  32. __webpack_require__.d = function (exports, name, getter) {
  33. // ... ...
  34. };
  35. // define __esModule on exports
  36. __webpack_require__.r = function (exports) {
  37. // ... ...
  38. };
  39. // 省略了点没怎么看的
  40. // ... ...
  41. var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
  42. var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
  43. jsonpArray.push = webpackJsonpCallback;
  44. jsonpArray = jsonpArray.slice();
  45. for (var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
  46. var parentJsonpFunction = oldJsonpFunction;
  47. // Load entry module and return exports
  48. return __webpack_require__(__webpack_require__.s = "./src/index.js");
  49. })
  50. ({
  51. "./src/index.js":
  52. (function (module, exports, __webpack_require__) {
  53. eval(`
  54. __webpack_require__.e(0)
  55. .then(__webpack_require__.bind(null, \"./src/async_data.js\"))
  56. .then(data => {
  57. console.log('async import:', data)
  58. })
  59. //# sourceURL=webpack:///./src/index.js?
  60. `);
  61. })
  62. });

光看结构就复杂了不少,但是本质还是IIFE,这个没变,但是看到我们调用IIFE的参数中,这个对象只有一个属性,就是入口文件’./src/index.js’和它对应的代码。
这部分流程和之前是一样的也就是在webpack_reqire中调用:

  1. modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

从而先执行入口文件的代码,也就是eval部分:

  1. __webpack_require__.e(0)
  2. .then(__webpack_require__.bind(null, "./src/async_data.js"))
  3. .then(data => {
  4. console.log('async import:', data)
  5. })

这时候需要去看webpack_require.e的代码了:

  1. // This file contains only the entry chunk.
  2. // The chunk loading function for additional chunks
  3. __webpack_require__.e = function requireEnsure(chunkId) {
  4. var promises = [];
  5. // JSONP chunk loading for javascript
  6. var installedChunkData = installedChunks[chunkId];
  7. if (installedChunkData !== 0) { // 0 means "already installed".
  8. // a Promise means "currently loading".
  9. if (installedChunkData) {
  10. promises.push(installedChunkData[2]);
  11. } else {
  12. // setup Promise in chunk cache
  13. var promise = new Promise(function (resolve, reject) {
  14. installedChunkData = installedChunks[chunkId] = [resolve, reject];
  15. });
  16. promises.push(installedChunkData[2] = promise);
  17. // start chunk loading
  18. // 加载chunk用的是script标签
  19. // 1. 构造标签设置各种属性
  20. var script = document.createElement('script');
  21. var onScriptComplete;
  22. script.charset = 'utf-8';
  23. script.timeout = 120;
  24. if (__webpack_require__.nc) {
  25. script.setAttribute("nonce", __webpack_require__.nc);
  26. }
  27. script.src = jsonpScriptSrc(chunkId);
  28. // create error before stack unwound to get useful stacktrace later
  29. var error = new Error();
  30. // 3. 完成回调
  31. onScriptComplete = function (event) {
  32. // avoid mem leaks in IE.
  33. script.onerror = script.onload = null;
  34. clearTimeout(timeout);
  35. var chunk = installedChunks[chunkId];
  36. if (chunk !== 0) {
  37. if (chunk) {
  38. var errorType = event && (event.type === 'load' ? 'missing' : event.type);
  39. var realSrc = event && event.target && event.target.src;
  40. error.message = 'Loading chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')';
  41. error.name = 'ChunkLoadError';
  42. error.type = errorType;
  43. error.request = realSrc;
  44. chunk[1](error);
  45. }
  46. installedChunks[chunkId] = undefined;
  47. }
  48. };
  49. var timeout = setTimeout(function () {
  50. onScriptComplete({
  51. type: 'timeout',
  52. target: script
  53. });
  54. }, 120000);
  55. script.onerror = script.onload = onScriptComplete;
  56. // 2. 请求
  57. document.head.appendChild(script);
  58. }
  59. }
  60. return Promise.all(promises);
  61. };

这是一段很长的代码,但是简单的理解下,就是根据chuckId,也就是0去利用script标签,到这个地方script.src = jsonpScriptSrc(chunkId);去请求脚本;那么可以想象当document.head.appendChild(script);请求下来的时候执行的就是上面的完成回调onScriptComplete,同时写在脚本中的js就会被执行。
所以是时候看看这个script.src是什么了,其实就是0.js:

  1. (window["webpackJsonp"] = window["webpackJsonp"] || []).push([
  2. [0], {
  3. "./src/async_data.js":
  4. /*! exports provided: data */
  5. (function (module, __webpack_exports__, __webpack_require__) {
  6. "use strict";
  7. eval(`
  8. __webpack_require__.r(__webpack_exports__);
  9. /* harmony export (binding) */
  10. __webpack_require__.d(
  11. __webpack_exports__,
  12. \"data\",
  13. function() { return data; }
  14. );
  15. const data = 'async get data';
  16. //# sourceURL=webpack:///./src/async_data.js?
  17. `);
  18. })
  19. }
  20. ]);

可以看到,当这个script加载完成,执行的就是这个:

  1. window["webpackJsonp"].push([
  2. [0],
  3. {
  4. // ... ...
  5. }
  6. ]);

看上去是向window.webpackJsonp里面push了个二维数组,那么window.webpackJsonp是个数组吗?这里调用的是数组的push方法??
其实不是的,这个push并不是array.prototype.push,这个push是main中修改过的:

  1. var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
  2. var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
  3. // here ----->
  4. jsonpArray.push = webpackJsonpCallback;
  5. jsonpArray = jsonpArray.slice();
  6. for (var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
  7. var parentJsonpFunction = oldJsonpFunction;

这里看到jsonpArray.push = webpackJsonpCallback,也就是说jsonpArray.push是webpackJsonpCallback,那么这个是谁,就是它window.webpackJsonp,所以上面window[“webpackJsonp”].push(二维数组),这个push就是在调用webpackJsonpCallback。所以看看webpackJsonpCallback:

  1. function webpackJsonpCallback(data) {
  2. // data就是那个二维数组
  3. var chunkIds = data[0];
  4. var moreModules = data[1];
  5. // add "moreModules" to the modules object,
  6. // then flag all "chunkIds" as loaded and fire callback
  7. var moduleId, chunkId, i = 0,
  8. resolves = [];
  9. for (; i < chunkIds.length; i++) {
  10. chunkId = chunkIds[i];
  11. if (Object.prototype.hasOwnProperty.call(installedChunks, chunkId) && installedChunks[chunkId]) {
  12. // installedChunks之前被赋值为
  13. // 在 __webpack_reqire__.e 中
  14. // installedChunkData = installedChunks[chunkId] = [resolve, reject];
  15. resolves.push(installedChunks[chunkId][0]);
  16. }
  17. installedChunks[chunkId] = 0;
  18. }
  19. for (moduleId in moreModules) { // mouduleID不是0,而是./src/async_data.js
  20. if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
  21. // 这句话最重要
  22. // moudles['./src/async_data.js'] 被赋值了
  23. modules[moduleId] = moreModules[moduleId];
  24. }
  25. }
  26. if (parentJsonpFunction) parentJsonpFunction(data);
  27. // 调用resoleve
  28. while (resolves.length) {
  29. resolves.shift()();
  30. }
  31. };

这段代码想做的事情就是把data,这个二维数组中的data[1],这个东西扩展到modules里面去:

  1. {
  2. "./src/async_data.js":
  3. (function (module, __webpack_exports__, __webpack_require__) {
  4. eval(/*.......*/)
  5. })
  6. }

这段代码想做的正是把这个东西扩展到modules里面去。即这样 modules[moduleId] = moreModules[moduleId]; 等到都扩展完了调用下resolves,这个resovle是webpack_reqire.e里面设置installedChunks而来的installedChunks[chunkId] = [resolve, reject];
resolev了之后resovle是webpack_reqire.ereturn的Promise.all就到结束了,下面就又回到这个流程了:

  1. __webpack_require__.e(0)
  2. .then(__webpack_require__.bind(null, "./src/async_data.js"))
  3. .then(data => {
  4. console.log('async import:', data)
  5. })

这会我们已经把modules模块扩展上了我们新载入的的路径key和代码value,所以,这一步就和同步的流程一样了webpack_require.bind(null, “./src/async_data.js”),相当于webpack_require(“./src/async_data.js”)。
正常加载就好~
到这里v4的异步import算简单的过了下,总结下思路,就是当遇到异步加载的代码,先使用script标签的方式,拿到异步文件最外层的代码,在执行拿到的文件时,执行window.webpackJsonpCallback。这个函数将异步代码作为moudles的新属性扩展,这样webpack_require.e的promise.all都ok后,就可以按照正常的webpack_require加载模块了。

v5的简单对比

学习了v4的流程后,看v5生成的文件将会简单很多,整体思路其实是一致的,没有太多变化。
使用webppack5异步加载的chunk最终生成的文件不再是以0,1这样命名的了,默认是代码文件路径作为文件名,当然具体什么规则是能配置的,这样有利于我们做缓存优化。
不同的地方有:

  1. 不像v4把模块作为IIFE的参数传入,v5直接在函数内部;
  2. 异步chunk的加载对象不再是window.webpackJsonp,v5变成了self.webpackChunky; (self虽然和window都一样,实际上和globalThis也是一样的,但是self不见得比window好,而且也能被重写,本质上也没有解决啥问题)