webpack打包文件分析
学习webpack原理通常都是从打包文件入手,从入口出发再深入探究。
单文件
最简单的情况下,项目中只src/index.js,同时index.js的代码还特别简单:
const hello = 'hello~';
console.log(hello);
webpack v4
运行webpack —mode development,从dist中可以看到main.js。删除掉不必要的注释,发现其结构是这样的:
(function (modules) {
// 一大堆逻辑
})
({
"./src/index.js":
(function (module, exports) {
eval("const hello='hello~';\n\n\nconsole.log(hello);\n\n\n//#sourceURL=webpack:///./src/index.js?");
})
});
可以看到这是一个IIFE(立即执行函数:Immediately-invoked function expression, IIFE)。
该IIFE如参数定义叫modules,实际传参是对象:
{
// 对象key
"./src/index.js":
// 对象value是一个函数
(function (module, exports) {
eval("const hello='hello~';\n\n\nconsole.log(hello);\n\n\n//#sourceURL=webpack:///./src/index.js?");
})
}
这个对象的key是文件路径,这个对象的值也是函数,函数体执行了eval,eval中的内容才是真正要执行的代码(为什么要用eval?关于eval )。
继续看这个函数function (modules) { },先看结构:
// 结构说明
function (modules) {
// 模块缓存
var installedModules = {};
// 定义__webpack_require__
function __webpack_require__(moduleId) { }
// 定义 __webpack_require__的各种个样的属性
// ... ...
// 入口文件是 index.js
// 调用__webpack_require__
return __webpack_require__(__webpack_require__.s = "./src/index.js");
}
从上述结构中,可以看到,核心其实还是在return这个地方,调用了webpack_require,传进去的参数是入口文件的地址”./src/index.js”。中间很大篇幅的代码量是在对webpack_require进行设置,这里暂时用不到,且先不细看(dev模式生成的代码中其实注释也挺明白)。
着重看看webpack_require里面发生了什么:
// The require function
function __webpack_require__(moduleId) {
// Check if module is in cache
// 检查moduleId是不是已经被缓存
// 如果缓存了返回缓存
if(installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
// Create a new module (and put it into the cache)
var module = installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {}
};
// Execute the module function
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
// Flag the module as loaded
module.l = true;
// Return the exports of the module
return module.exports;
}
- 首先,入参moudleId就是传进来的’./src/index.js’,入口文件路径。
- 检查模块缓存中是不是已经有了此模块,如果有,直接返回moudle.exports属性。如果没有,构造一个moudle对象:
{
i: moduleId, // id
l: false, // 被加载了吗?
exports: {} // 输出
}
这个对象作为moudleId的key对应的value放入缓存中,所以这个缓存就是个Object形式的Map。
- 调用moudles中对应moudleId的方法:
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
这个”modules.moduleId”是谁呢?就是IIFE中被作为参数传进来的object,对应的key为moudleId的值,是一个函数:
// IIFE的参数 { "./src/index.js": ... }
function (module, exports) {
eval("const hello='hello~';\n\n\nconsole.log(hello);\n\n\n//#sourceURL=webpack:///./src/index.js?");
}
所以上面函数中参数,在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完事:
// v5
(() => {
eval("const hello = 'hello~';\n\nconsole.log(hello);\n\n//# sourceURL=webpack://y/./src/index.js?");
})()
(简直就是: 开局一个IIFE,一刀99级)
多文件
把src改动:增加一个util.js
const sayHello = (to = 'world') => {
console.log(`hello ${to} ~`)
}
export { sayHello };
index.js中使用sayHello:
import { sayHello } from './util'
console.log(sayHello());
webpack v4
先看作为IIFE的参数传递那个对象modules:
{
"./src/index.js": (function (module, __webpack_exports__, __webpack_require__) {
"use strict";
eval("一堆东西...略");
}),
"./src/util.js": (function (module, __webpack_exports__, __webpack_require__) {
"use strict";
eval("一堆东西...略");
})
}
这个对象可现在有两个key,分别是”./src/index.js”、”./src/util.js”。key对应的值还是函数,函数的参数是和之前单文件稍有不同,另外还是eval中一堆代码。
不过因为设计多文件了,而代码中有多文件的调用关系,所以eval还是得看看的:
index.js中对应的eval:
eval(`
__webpack_require__.r(__webpack_exports__);
var _util__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(\"./src/util.js\");
console.log(Object(_util__WEBPACK_IMPORTED_MODULE_0__[\"sayHello\"])());
`);
util.js中对应的eval:
eval(`
__webpack_require__.r(__webpack_exports__);
__webpack_require__.d(__webpack_exports__, \"sayHello\", function() { return sayHello; });
const sayHello = (to = 'world') => {console.log(`hello ${to} ~`)}
`);
在这些被格式化的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:
// define __esModule on exports
__webpack_require__.r = function (exports) {
if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
Object.defineProperty(exports, Symbol.toStringTag, {
value: 'Module'
});
}
Object.defineProperty(exports, '__esModule', {
value: true
});
};
上文中写道,最终其实传进来的是模块moudle对象的moudle.exports,这个moudle.exports就是这里的exports,那么这个函数中,对exports进行了属性设置__esModule设成了true。由此可见,这个方法应该不那么重要。
接着看看webpack_require.d:
// define getter function for harmony exports
__webpack_require__.d = function (exports, name, getter) {
if (!__webpack_require__.o(exports, name)) {
Object.defineProperty(exports, name, {
enumerable: true,
get: getter
});
}
};
.d中也defineProperty了,对象也是exports,定义的是什么呢?原来是对exports定义了一个name属性,属性的get是走getter函数,结合使用看看具体的,比如util.js对应的eval中:
__webpack_require__.d(__webpack_exports__, 'sayHello', function() { return sayHello; })
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”)的执行:
- 在执行的之后返回的就是”./src/util.js”对应的moudle的exports(这个moudle我们称之为当前模块);
- 执行中遇到源代码为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_require、webpack_require.d、installedModules等实现了一套在浏览器端可运行的模块化方案。
webpack v5
在v5中,逻辑没有变化,就是上文中分析那样,但是生成的代码看上去清爽了很多:
(() => { // webpackBootstrap
"use strict";
// 所有模块
var __webpack_modules__ = ({
"./src/index.js": ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
eval("... ...略");
}),
"./src/util.js": ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
eval("... ...略");
})
});
// The module cache
var __webpack_module_cache__ = {};
// The require function
function __webpack_require__(moduleId) {
// Check if module is in cache
if (__webpack_module_cache__[moduleId]) {
return __webpack_module_cache__[moduleId].exports;
}
// Create a new module (and put it into the cache)
var module = __webpack_module_cache__[moduleId] = {
// no module.id needed
// no module.loaded needed
exports: {}
};
// Execute the module function
__webpack_modules__[moduleId](module, module.exports, __webpack_require__);
// Return the exports of the module
return module.exports;
}
/* webpack/runtime/define property getters */
(() => {
// define getter functions for harmony exports
__webpack_require__.d = (exports, definition) => {};
})();
/* webpack/runtime/hasOwnProperty shorthand */
(() => {
__webpack_require__.o = (obj, prop) => Object.prototype.hasOwnProperty.call(obj, prop)
})();
/* webpack/runtime/make namespace object */
(() => {
// define __esModule on exports
__webpack_require__.r = (exports) => {};
})();
// startup
// Load entry module
__webpack_require__("./src/index.js");
// This entry module used 'exports' so it can't be inlined
})();
可以看到打包代码主体还是IIFE,但是这次所有模块并不是通过IIFE的参数传递进去,而是直接定义了个变量在IIFE内部叫webpack_modules,这点差不多就是最大的区别了,别的没有什么。
异步import
我们用这样的语法实现异步加载:
import('url').then(data => {
});
那么webpack自然就帮我们实现了支持这样语法的逻辑,现在把src中的代码修改如下:
// index.js
import ('./async').then(data => {
console.log('async import:', data)
})
// async.js
export const aData = 'i am async data';
webpack v4
在v4版本中,我们发现凡事有异步加载的文件都会拆分出来,而且命名诡异,我们得到了两个文件:main.js 和 0.js。
整体看下结构,main.js:
(function (modules) { // webpackBootstrap
// install a JSONP callback for chunk loading
function webpackJsonpCallback(data) {
// ... ...
};
// The module cache
var installedModules = {};
// object to store loaded and loading chunks
var installedChunks = {
"main": 0
};
// script path function
function jsonpScriptSrc(chunkId) {
// ... ...
}
// The require function
function __webpack_require__(moduleId) {
// ... ...
return module.exports;
}
// This file contains only the entry chunk.
// The chunk loading function for additional chunks
__webpack_require__.e = function requireEnsure(chunkId) {
// ... ...
return Promise.all(promises);
};
// expose the modules object (__webpack_modules__)
__webpack_require__.m = modules;
// expose the module cache
__webpack_require__.c = installedModules;
// define getter function for harmony exports
__webpack_require__.d = function (exports, name, getter) {
// ... ...
};
// define __esModule on exports
__webpack_require__.r = function (exports) {
// ... ...
};
// 省略了点没怎么看的
// ... ...
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;
// Load entry module and return exports
return __webpack_require__(__webpack_require__.s = "./src/index.js");
})
({
"./src/index.js":
(function (module, exports, __webpack_require__) {
eval(`
__webpack_require__.e(0)
.then(__webpack_require__.bind(null, \"./src/async_data.js\"))
.then(data => {
console.log('async import:', data)
})
//# sourceURL=webpack:///./src/index.js?
`);
})
});
光看结构就复杂了不少,但是本质还是IIFE,这个没变,但是看到我们调用IIFE的参数中,这个对象只有一个属性,就是入口文件’./src/index.js’和它对应的代码。
这部分流程和之前是一样的也就是在webpack_reqire中调用:
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
从而先执行入口文件的代码,也就是eval部分:
__webpack_require__.e(0)
.then(__webpack_require__.bind(null, "./src/async_data.js"))
.then(data => {
console.log('async import:', data)
})
这时候需要去看webpack_require.e的代码了:
// This file contains only the entry chunk.
// The chunk loading function for additional chunks
__webpack_require__.e = function requireEnsure(chunkId) {
var promises = [];
// JSONP chunk loading for javascript
var installedChunkData = installedChunks[chunkId];
if (installedChunkData !== 0) { // 0 means "already installed".
// a Promise means "currently loading".
if (installedChunkData) {
promises.push(installedChunkData[2]);
} else {
// setup Promise in chunk cache
var promise = new Promise(function (resolve, reject) {
installedChunkData = installedChunks[chunkId] = [resolve, reject];
});
promises.push(installedChunkData[2] = promise);
// start chunk loading
// 加载chunk用的是script标签
// 1. 构造标签设置各种属性
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();
// 3. 完成回调
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.onerror = script.onload = onScriptComplete;
// 2. 请求
document.head.appendChild(script);
}
}
return Promise.all(promises);
};
这是一段很长的代码,但是简单的理解下,就是根据chuckId,也就是0去利用script标签,到这个地方script.src = jsonpScriptSrc(chunkId);去请求脚本;那么可以想象当document.head.appendChild(script);请求下来的时候执行的就是上面的完成回调onScriptComplete,同时写在脚本中的js就会被执行。
所以是时候看看这个script.src是什么了,其实就是0.js:
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([
[0], {
"./src/async_data.js":
/*! exports provided: data */
(function (module, __webpack_exports__, __webpack_require__) {
"use strict";
eval(`
__webpack_require__.r(__webpack_exports__);
/* harmony export (binding) */
__webpack_require__.d(
__webpack_exports__,
\"data\",
function() { return data; }
);
const data = 'async get data';
//# sourceURL=webpack:///./src/async_data.js?
`);
})
}
]);
可以看到,当这个script加载完成,执行的就是这个:
window["webpackJsonp"].push([
[0],
{
// ... ...
}
]);
看上去是向window.webpackJsonp里面push了个二维数组,那么window.webpackJsonp是个数组吗?这里调用的是数组的push方法??
其实不是的,这个push并不是array.prototype.push,这个push是main中修改过的:
var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
// here ----->
jsonpArray.push = webpackJsonpCallback;
jsonpArray = jsonpArray.slice();
for (var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
var parentJsonpFunction = oldJsonpFunction;
这里看到jsonpArray.push = webpackJsonpCallback,也就是说jsonpArray.push是webpackJsonpCallback,那么这个是谁,就是它window.webpackJsonp,所以上面window[“webpackJsonp”].push(二维数组),这个push就是在调用webpackJsonpCallback。所以看看webpackJsonpCallback:
function webpackJsonpCallback(data) {
// data就是那个二维数组
var chunkIds = data[0];
var moreModules = data[1];
// add "moreModules" to the modules object,
// then flag all "chunkIds" as loaded and fire callback
var moduleId, chunkId, i = 0,
resolves = [];
for (; i < chunkIds.length; i++) {
chunkId = chunkIds[i];
if (Object.prototype.hasOwnProperty.call(installedChunks, chunkId) && installedChunks[chunkId]) {
// installedChunks之前被赋值为
// 在 __webpack_reqire__.e 中
// installedChunkData = installedChunks[chunkId] = [resolve, reject];
resolves.push(installedChunks[chunkId][0]);
}
installedChunks[chunkId] = 0;
}
for (moduleId in moreModules) { // mouduleID不是0,而是./src/async_data.js
if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
// 这句话最重要
// moudles['./src/async_data.js'] 被赋值了
modules[moduleId] = moreModules[moduleId];
}
}
if (parentJsonpFunction) parentJsonpFunction(data);
// 调用resoleve
while (resolves.length) {
resolves.shift()();
}
};
这段代码想做的事情就是把data,这个二维数组中的data[1],这个东西扩展到modules里面去:
{
"./src/async_data.js":
(function (module, __webpack_exports__, __webpack_require__) {
eval(/*.......*/)
})
}
这段代码想做的正是把这个东西扩展到modules里面去。即这样 modules[moduleId] = moreModules[moduleId]; 等到都扩展完了调用下resolves,这个resovle是webpack_reqire.e里面设置installedChunks而来的installedChunks[chunkId] = [resolve, reject];
resolev了之后resovle是webpack_reqire.ereturn的Promise.all就到结束了,下面就又回到这个流程了:
__webpack_require__.e(0)
.then(__webpack_require__.bind(null, "./src/async_data.js"))
.then(data => {
console.log('async import:', data)
})
这会我们已经把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这样命名的了,默认是代码文件路径作为文件名,当然具体什么规则是能配置的,这样有利于我们做缓存优化。
不同的地方有:
- 不像v4把模块作为IIFE的参数传入,v5直接在函数内部;
- 异步chunk的加载对象不再是window.webpackJsonp,v5变成了self.webpackChunky; (self虽然和window都一样,实际上和globalThis也是一样的,但是self不见得比window好,而且也能被重写,本质上也没有解决啥问题)