webpack 打包后代码及调试全解析
前言
平时出于习惯,看到网站就喜欢去打开调试别人的,学习下别人是怎么去写代码的。但是现代的打包工具往往都把代码压缩和优化过了。所以如何调试以 webpack 为代表的这类网站,去看看别人家代码是怎么个逻辑写出来的?以及探索整个网站的逻辑流程,就是我们今天文章讨论的话题。
这篇文章会比较长,我会做很多前置铺垫(我认为这是重要的)。如果你基础非常棒,急需知道关键调试方法,请直接跳到文末《前端调试思路》即可。
webpack 是一个用于现代 JavaScript 应用程序的_静态模块打包工具_。当 webpack 处理应用程序时,它会在内部构建一个 依赖图(dependency graph),此依赖图对应映射到项目所需的每个模块,并生成一个或多个 bundle。
这是官方对于 webpack 的一个起步介绍。在这里,我们得出了几个信息:
- 本质上就是一个打包代码的工具,没啥魔法。
- 依赖图谱是关键,记录着所有模块的联系。
- 它会生成一个或者多个代码“包裹”。
从单入口开始分析
打包分了很多模式。每种模式打包出来不太一样。但大概相同,主要先来说下单入口不拆包情况!
以下是使用的工具版本号:
- webpack v5.26.3
- webpack-cli v4.5.0
小试牛刀
先来一段简单代码热热身,感受下。
//index.js
console.log('这是个测试脚本!用于分析 webpack 打包后代码。');
使用 webpack --mode=development
模式打包后,产生以下代码(我去除了多余的注释):
//bundle.js
(() => {
var __webpack_modules__ = ({
"./src/index.js": (() => {
eval("console.log('这是个测试脚本!用于分析 webpack 打包后代码。');");
})
});
var __webpack_exports__ = {};//忽略,目前用不到它。
__webpack_modules__["./src/index.js"]();
})();
代码还是比较简单的,我们先来分析一下:
- 一个自执行匿名函数在最外面运行,启动了整个代码块。
- 定义了一个
__webpack_modules__
变量,里面储存了一个关键对象,key 值是文件相对路径,内容是一个匿名函数,然后用 eval 调用了我们写的代码。 - 直接调用了我们储存的入口文件名( key 值),整个内容运行完毕。
正式开始
接下来增加复杂度,新建三个文件:a.js
b.js
c.js
,然后用 index.js
将它们导入!
//index.js
import { a } from './a.js';
import { b } from './b.js';
import { c } from './c.js';
var obj = { a, b, c }
function main() {
obj.a()
obj.b()
obj.c()
}
main();
//a.js
function a() {
console.log('a');
}
export { a }
//b.js
function b() {
console.log('b');
}
export { b }
//c.js
function c() {
console.log('c');
}
export { c }
这些文件准备好了以后,我们开始尝试打包,同样清除多余注释后代码。
这回的代码量可能吓到了你,但是不用担心,我们一步一步来看它的步骤。我先把中文注释写了下来,方便查阅(初次阅读此处代码需要10分钟左右理解)。
//bundle.js
(() => { //整个文件都是一个自执行函数
"use strict";
var __webpack_modules__ = ({
"./src/a.js": ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"a\": () => (/* binding */ a)\n/* harmony export */ });\nfunction a() {\r\n console.log('a');\r\n}\r\n\r\n\n\n//# sourceURL=webpack://test/./src/a.js?");
}),
......
/**
* 为了节省文章空间,这里省略了后面其他包代码(可以自己尝试打包看看)
* 跟之前说的一样,这个变量里面储存了打包后的key和对应的代码。
*/
});
var __webpack_module_cache__ = {};
/**
* 模块缓存,缓存结果类似于:
* {
* "./src/index.js":{
* exports: 暴露出来的变量,
* id: 模块id
* loaded: 是否被我们读取完成
* }
* }
*/
function __webpack_require__(moduleId) {// 重点函数!!这里为我们生成了一个加载函数!
/**
* 先说一下基础知识:作用域。这里 __webpack_module_cache__ 定义在函数外面,所以相当于是整个自执行函数内部的全局变量。
* 检查模块是否有缓存(以前加载过),如果加载过,那我们就没必要处理了,因为 __webpack_module_cache__ 里面会有 exports 属性来保存读取到的。
*/
var cachedModule = __webpack_module_cache__[moduleId];
if (cachedModule !== undefined) {
/**
* 这里可以详细说下,为什么需要缓存,在我看来两点:
* 1.节省计算性能(这点大家很容易想到)。
* 2.防止依赖死循环(重要)。
* 举例:A 模块为入口,导入了 B 模块,B 模块反过来又导入了 A 模块。根据 ESM 规范(可以查看阮一峰 ES6 教程),B 模块内部是会打印出 undefined 。理由很简单:A 模块调用了 B 模块,此刻 A 还未执行完,B 模块此刻访问 A 模块,状态自然是 undefined。
* 我们可以看到 __webpack_require__ 函数,它如果碰到了这种循环加载的依赖,无限嵌套调用,很快 javascript 调用栈就会爆掉!而当我们使用了缓存,就可以从缓存直接返回结果,而无需再调用 __webpack_require__。避免了爆栈的问题发生!
*/
return cachedModule.exports;//如果有的话直接返回暴露出来的对象。
}
// 上面不符合以后,那我们就开始创建一个模块,并且缓存到 __webpack_module_cache__ 里面,同时把它们赋值到变量 module 上面
var module = __webpack_module_cache__[moduleId] = {
// no module.id needed
// no module.loaded needed
exports: {}
};
/**
* 第一轮会先执行入口函数,然后传入了三个参数:
* module(刚刚生成的缓存对象)。
* module.exports(还是这个对象,只不过exports拿来用了)。
* __webpack_require__(重要的加载函数,传入了自己,这个函数会被反复执行和调用,注意!)。
*/
__webpack_modules__[moduleId](module, module.exports, __webpack_require__);
// 整个加载函数最后执行完返回的是 exports ,暴露出来的对象。
return module.exports;
}
/* webpack/runtime/define property getters */
(() => {
// 类似这样的调用: __webpack_require__.d(__webpack_exports__, { "a": () => a});
__webpack_require__.d = (exports, definition) => {
for (var key in definition) {
// 先判断这个in出来的key值是不是自身,然后判断 exports 对象身上是不是导出过。
if (__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
/**
* 符合条件以后,对这个 key 值进行改造。当获取属性的时候,调用对应的函数。
* 为什么要这么做呢,我猜测是为了防止被误删除定义的函数。经过这一步操作,在定义一遍获取操作,相当于定义了两次key的返回值,即使修改也改不了。因为使用Object.defineProperty()定义的属性,默认是不可以被修改,不可以被枚举,不可以被删除的。
* */
Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
}
}
};
})();
/**
* 众所周知,in操作符是判断不出来是否某个属性在自己身上的,因为它还会去作用域链上找,直到找到为止。
* 所以需要判断是否属于自身属性,就需要用 hasOwnProperty 这个方法,那为什么不直接 XX.hasOwnProperty(属性名) 呢?
* 因为传参的方式代码阅读上更容易被理解。
* 这里其实相当于给这个方法起了个短名,更方便后面使用。
*/
(() => {
__webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
})();
/* webpack/runtime/make namespace object */
/**
* 这一步操作主要是给 exports 对象身上打上两个关键标记:
* __esModule 属性为 true
* 当对 exports 使用 Object.prototype.toString.call() 检测出来的结果就是 Module
*/
(() => {
// define __esModule on exports
__webpack_require__.r = (exports) => {
if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
//这里查看后面《关于 Symbol.toStringTag 引申》 《关于对象的操作修改》
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
}
Object.defineProperty(exports, '__esModule', { value: true });
};
})();
/**
* 这里是调用入口开始的地方!
*/
var __webpack_exports__ = __webpack_require__("./src/index.js");
})();
关于 Symbol.toStringTag
引申
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
这段代码本身的意义在于,当需要对它进行 Object.prototype.toString.call(判断对象)
类型分析的时候,是可以得出 [object Module]
类型。原因就在于Symbol.toStringTag
属性可以控制最后得出的类型。
详细查阅MDN:Symbol.toStringTag
其他语法实现拦截:
- Proxy
var a = {};
var aa = new Proxy(a,{
get () {
console.log(arguments);
}
})
Object.prototype.toString.call( aa );
会提示读取到了Symbol(Symbol.toStringTag)
属性,因此得出结论,这里访问的是这个key。
var a = {};
var aa = new Proxy(a,{
get (obj,props) {
if(props === 'Symbol(Symbol.toStringTag)')console.log('xz');
return 'xz';
}
})
Object.prototype.toString.call( aa );//修改成功
- Class
class ValidatorClass {
get [Symbol.toStringTag]() {
return "Validator";
}
}
Object.prototype.toString.call(new ValidatorClass()); // "[object Validator]"
关于对象的操作修改
修改总共有三种方式,详细查阅MDN:
- Object.defineProperty[ES5]
- Object.defineProperties[ES5]
- Reflect.defineProperty[ES6]
- Proxy[ES6]
它们之间的区别主要是以下:
proxy
返回一个新的对象,第一个参数是需要代理的对象,第二个参数是一个对象,里面配置拦截操作(非常丰富)。Object.defineProperty
修改原本对象,第二个参数是一个对象,里面配置拦截操作。使用Object.defineProperty()定义的属性,默认是不可以被修改,不可以被枚举,不可以被删除的。Reflect.defineProperty
第一个参数是修改对象,第二个参数是需要修改的key
,第三个参数是配置拦截操作。区别于Object
。defineProperty
返回一个对象,或者如果属性没有被成功定义,抛出一个TypeError
。 相比之下,Reflect.defineProperty
方法只返回一个Boolean
,来说明该属性是否被成功定义。Object.defineProperties
,第一个参数是对象,第二个参数是一个对象,里面可以对各个key值配置不同的options
。
为什么webpack需要用eval来生成代码?
之前不熟悉 webpack 打包后的代码,看到代码里居然有 eval 执行,eval 这个 api 本身是不太推荐使用的,但是 webpack 居然大行其道的使用了。
问了很多小伙伴,竟然问下去都一时语塞。先说我的结论(未必绝对正确):为了给 source map 定位,方便调试。
首先我们随便定一个代码块,加上 SourceURL 标识,打印的时候,控制台右侧就会出现对应的文字。
//# sourceURL=测试代码
console.log(111)
而在 webpack 里面,由于每个模块之间需要建立这种索引,例如__webpack_modules__
变量,因此每个代码块在执行的时候,使用 eval 内置了不同的 sourceURL 标识,方便调试定位。如果你不使用 sourcemap 功能,那么打包出来的代码里面就不会再有 evel 这种语句了。
多入口拆包文件分析
在多入口和拆包情况下,webpack会这样处理:
- 将所有的依赖放到一个全局对象的数组里(类似于webpackXXX命名),拆出来的依赖包代码都是这样,不断的push到全局对象的数组里。
- 一个类似于前面分析的单文件入口主要代码一样,有一个主要的调用启动,把它们都加进来,然后开始分析代码情况。
- 由于全局对象 webpackXXX 里面这个push方法被改写了,就可以很神奇的感应到一些新增依赖项目。push进来的新代码内容,webpack 定义的 require 函数就会开始启动分析流程,执行代码,直到最后结束。 主入口文件:
!function(e) {
function t(t) {//这里是webpack自己定义的一个push方式,所有的代码资产加载的时候,都会进入到这里面来走一圈
for (var n, l, a = t[0], f = t[1], i = t[2], c = 0, s = []; c < a.length; c++)
l = a[c],
Object.prototype.hasOwnProperty.call(o, l) && o[l] && s.push(o[l][0]),
o[l] = 0;
for (n in f)
Object.prototype.hasOwnProperty.call(f, n) && (e[n] = f[n]);
for (p && p(t); s.length; )
s.shift()();
return u.push.apply(u, i || []),
r()//这个r函数就是来执行代码加载的。主要执行就是从这里开始的。
}
function r() {
for (var e, t = 0; t < u.length; t++) {
for (var r = u[t], n = !0, a = 1; a < r.length; a++) {
var f = r[a];
0 !== o[f] && (n = !1)
}
n && (u.splice(t--, 1),
e = l(l.s = r[0]))//这里是正式执行分析,刚开始的入口函数开始执行,赋值完返回赋值的结果开始调用 l 函数,也就是我们的webpack require函数。
}
return e
}
var n = {}//熟悉的webpack缓存变量
, o = {
1: 0
}
, u = [];
function l(t) {//开始reiqure各个依赖
if (n[t])
return n[t].exports;
var r = n[t] = {
i: t,
l: !1,
exports: {}
};
return e[t].call(r.exports, r, r.exports, l),//正式执行代码!传入三个参数,如单文件分析的那样开始进行!
r.l = !0,
r.exports
}
l.m = e,
l.c = n,
l.d = function(e, t, r) {
l.o(e, t) || Object.defineProperty(e, t, {
enumerable: !0,
get: r
})
}
,
l.r = function(e) {
"undefined" != typeof Symbol && Symbol.toStringTag && Object.defineProperty(e, Symbol.toStringTag, {
value: "Module"
}),
Object.defineProperty(e, "__esModule", {
value: !0
})
}
,
l.t = function(e, t) {
if (1 & t && (e = l(e)),
8 & t)
return e;
if (4 & t && "object" == typeof e && e && e.__esModule)
return e;
var r = Object.create(null);
if (l.r(r),
Object.defineProperty(r, "default", {
enumerable: !0,
value: e
}),
2 & t && "string" != typeof e)
for (var n in e)
l.d(r, n, function(t) {
return e[t]
}
.bind(null, n));
return r
}
,
l.n = function(e) {
var t = e && e.__esModule ? function() {
return e.default
}
: function() {
return e
}
;
return l.d(t, "a", t),
t
}
,
l.o = function(e, t) {
return Object.prototype.hasOwnProperty.call(e, t)
}
,
l.p = "./";
var a = this["webpackJsonpantd-demo"] = this["webpackJsonpantd-demo"] || []
, f = a.push.bind(a);
a.push = t,//关键操作,push被改造了!
a = a.slice();
for (var i = 0; i < a.length; i++)
t(a[i]);
var p = f;
r()
}([])
其他文件:
//这里的push方法其实是被改造过的。
(this["webpackJsonpantd-demo"] = this["webpackJsonpantd-demo"] || []).push([[2], [function(e, t, n) {
"use strict";
e.exports = n(195)
}
, function(e, t, n) {
"use strict";
n.d(t, "a", (function() {
return a
}
));
....省略
前端调试思路
这里开始,先问自检了解了前面说的几个问题:
-
webpack_require_
这个函数你已经相当熟悉,无论简化成什么字母,大概一眼可以认出。 -
你已经知道了 webpack 分包后的代码是如何被响应执行的。
-
你会用 chrome 浏览器打断点(基础能力哦)。
这里开始我们陆续上图,首先当你认识到这是一个 webpack 最后打包上线的网站。就可以在控制台先尝试着去看看 webpack 开头的变量(控制台先输入webpack这类词就会有自动提示)。往往这类变量就是核心,整个webpack向外打包的内容都在这里可以找到。
可以看到,我们马上就定位到了,首先是两个数组。这里面毫无疑问就是所有的依赖函数,最后一个push就是核心的响应依赖函数。之前已经说过,这个函数的作用就是改写了数组本身的push方法,实现的响应。
跟进去看看,到了source面板:
好家伙,看到这么多东西?都是老朋友了(ノ"◑ ◑)ノ"(。•́︿•̀。)。想必看到这里,不难了吧?
很明显 l 函数就是关键加载函数 webpack_require_
的压缩版本。所有的模块必须经过它来处理。
相信看到这里,你也会有疑惑?打断点这么多函数都要经过这里很麻烦啊,而且条件断点也很烦,我想看不同的函数,还得反反复复。
我们先全局最外层定义一个全局变量(deno),然后到 l 函数下面,赋值给deno,这样的话,我们在全局就可以直接通过 deno 来使用 l 函数了。
这里提供几个办法:
1.使用 fiddler 代理响应这个文件,本地改写 l 函数。
2.打开断点,直接在控制台引用。
然后释放断点。
由于我这里的代码块打包后都是一些数字编号的(其他网站未必,也可能是字符串当key值)。我在控制台里,通过 deno(XXX) 就可以非常方便的去调试和启动某个代码块运行起来。相当于是看到了跟网站开发者当时在做项目的时候,类似的结构了,通过这种办法,我们也可以举一反三。去调试更多构建工具所带来的“麻烦代码”。
转载自:https://juejin.cn/post/6943153110609035277