webpack 打包后代码及调试全解析

4,455

前言

平时出于习惯,看到网站就喜欢去打开调试别人的,学习下别人是怎么去写代码的。但是现代的打包工具往往都把代码压缩和优化过了。所以如何调试以 webpack 为代表的这类网站,去看看别人家代码是怎么个逻辑写出来的?以及探索整个网站的逻辑流程,就是我们今天文章讨论的话题。

这篇文章会比较长,我会做很多前置铺垫(我认为这是重要的)。如果你基础非常棒,急需知道关键调试方法,请直接跳到文末《前端调试思路》即可。

webpack 是一个用于现代 JavaScript 应用程序的_静态模块打包工具_。当 webpack 处理应用程序时,它会在内部构建一个 依赖图(dependency graph),此依赖图对应映射到项目所需的每个模块,并生成一个或多个 bundle

这是官方对于 webpack 的一个起步介绍。在这里,我们得出了几个信息:

  1. 本质上就是一个打包代码的工具,没啥魔法。
  2. 依赖图谱是关键,记录着所有模块的联系。
  3. 它会生成一个或者多个代码“包裹”。

从单入口开始分析

打包分了很多模式。每种模式打包出来不太一样。但大概相同,主要先来说下单入口不拆包情况!

以下是使用的工具版本号:

  • 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"]();
})();

代码还是比较简单的,我们先来分析一下:

  1. 一个自执行匿名函数在最外面运行,启动了整个代码块。
  2. 定义了一个 __webpack_modules__ 变量,里面储存了一个关键对象,key 值是文件相对路径,内容是一个匿名函数,然后用 eval 调用了我们写的代码。
  3. 直接调用了我们储存的入口文件名( 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

其他语法实现拦截:

  1. 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 );//修改成功
  1. Class
class ValidatorClass {
get [Symbol.toStringTag]() {
    return "Validator";
}
}

Object.prototype.toString.call(new ValidatorClass()); // "[object Validator]"

关于对象的操作修改

修改总共有三种方式,详细查阅MDN:

  1. Object.defineProperty[ES5]
  2. Object.defineProperties[ES5]
  3. Reflect.defineProperty[ES6]
  4. Proxy[ES6]

它们之间的区别主要是以下:

  1. proxy返回一个新的对象,第一个参数是需要代理的对象,第二个参数是一个对象,里面配置拦截操作(非常丰富)。
  2. Object.defineProperty修改原本对象,第二个参数是一个对象,里面配置拦截操作。使用Object.defineProperty()定义的属性,默认是不可以被修改,不可以被枚举,不可以被删除的。
  3. Reflect.defineProperty第一个参数是修改对象,第二个参数是需要修改的key,第三个参数是配置拦截操作。区别于ObjectdefineProperty返回一个对象,或者如果属性没有被成功定义,抛出一个TypeError。 相比之下,Reflect.defineProperty方法只返回一个 Boolean,来说明该属性是否被成功定义。
  4. 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会这样处理:

  1. 将所有的依赖放到一个全局对象的数组里(类似于webpackXXX命名),拆出来的依赖包代码都是这样,不断的push到全局对象的数组里。
  2. 一个类似于前面分析的单文件入口主要代码一样,有一个主要的调用启动,把它们都加进来,然后开始分析代码情况。
  3. 由于全局对象 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
    }
    ));
    ....省略

前端调试思路

这里开始,先问自检了解了前面说的几个问题:

  1. webpack_require_ 这个函数你已经相当熟悉,无论简化成什么字母,大概一眼可以认出。

  2. 你已经知道了 webpack 分包后的代码是如何被响应执行的。

  3. 你会用 chrome 浏览器打断点(基础能力哦)。

这里开始我们陆续上图,首先当你认识到这是一个 webpack 最后打包上线的网站。就可以在控制台先尝试着去看看 webpack 开头的变量(控制台先输入webpack这类词就会有自动提示)。往往这类变量就是核心,整个webpack向外打包的内容都在这里可以找到。

image-20210324171824122.png

可以看到,我们马上就定位到了,首先是两个数组。这里面毫无疑问就是所有的依赖函数,最后一个push就是核心的响应依赖函数。之前已经说过,这个函数的作用就是改写了数组本身的push方法,实现的响应。

image-20210324171930605.png

跟进去看看,到了source面板:

image-20210324172018100.png

好家伙,看到这么多东西?都是老朋友了(ノ"◑ ◑)ノ"(。•́︿•̀。)。想必看到这里,不难了吧?

很明显 l 函数就是关键加载函数 webpack_require_的压缩版本。所有的模块必须经过它来处理。

相信看到这里,你也会有疑惑?打断点这么多函数都要经过这里很麻烦啊,而且条件断点也很烦,我想看不同的函数,还得反反复复。

我们先全局最外层定义一个全局变量(deno),然后到 l 函数下面,赋值给deno,这样的话,我们在全局就可以直接通过 deno 来使用 l 函数了。

image-20210324172458854.png

这里提供几个办法:

1.使用 fiddler 代理响应这个文件,本地改写 l 函数。

2.打开断点,直接在控制台引用。

image-20210324172555274.png 然后释放断点。

image-20210324172708996.png

由于我这里的代码块打包后都是一些数字编号的(其他网站未必,也可能是字符串当key值)。我在控制台里,通过 deno(XXX) 就可以非常方便的去调试和启动某个代码块运行起来。相当于是看到了跟网站开发者当时在做项目的时候,类似的结构了,通过这种办法,我们也可以举一反三。去调试更多构建工具所带来的“麻烦代码”。