从零开始的Webpack原理剖析(一)——模块加载

505 阅读13分钟

前言

脚手架用多了,webpack的一些配置基本上忘得透透的,用了这么久都没有去研究过,到底是如何进行文件打包的,回想起刚刚自学前端的时候,都是手动创建xxx.html文件,xxx.js文件,xxx.css文件,模仿着某米手机官网的页面,搞出来了个静态页面,然而工作之后,一切全变了,直接就过度到了脚手架生成的单页面应用,看着满屏的.vue文件和import xxx各种语法,一脸懵,感觉之前学的都是假的一样,都是从来没见过的写法,之后的半年才逐渐适应了项目的写法与结构,但却感觉跨越了好多步,仿佛处于一个黑箱之外,很多原理性的东西并没有掌握,只知道怎样写,就能输出怎样的结果,中间做了什么事情,完全不清楚,相信很多初入门的前端同胞们,会有一样的体会,身处舒适区,完全不想离开,直至今年的互联网寒冬,各种公司都在裁员,猛然发现,自己对好多东西都是一知半解,而且也没有输出和记录,决定痛定思痛,从现在开始,跳出舒适区,记录下我的学习之路。那么第一步,打算先啃下打包工具这块硬骨头,我会以一个只会简单配置webpack,却完全不懂原理的小白视角,从头开始分析webpack,rollup,vite等打包工具的原理,记录下学习成长的每一步,也希望对刚入门和渴望提升的初中级前端程序员有所帮助~
阅读此文章的前提是用过webpack,能看懂一些最基本的配置,只需要知道import和require导入导出模块的写法(不需要具体了解其区别)。因为是个人的理解,所以有些观点可能不正确,还请各位大佬们及时指正。

模块的加载

webpack对CommonJs模块加载的处理:

首先我们需要思考一个最基础的问题,模块是怎样被加载的呢?只说没概念,还是要借助简单的实例来进行分析,我们创建几个文件,并在其中写下如下代码(关于Commonjs和ES module的具体区别,我们这里只需要简单的理解他们导入导出的方式写法不同,就好了,其他的区别以后随着webpack原理的学习,会再去细讲,不然上来就讲有啥区别,却没有看到实际的例子,和直接背答案没啥区别,并不能理解):

// index.js文件
const testName = require('./testName.js')
console.log(testName)
// testName.js文件
module.exports = 'testName: xiaoMing'
// index.html文件,引入index.js文件
......
<script src="./index.js"></script>
......

然后在浏览器中打开index.html文件,打开控制台,看下是什么结果呢?没错,如下图所示,因为浏览器不识别require方法,所以就会报错。
error

那么为什么同样的代码,我们在配置完webpack之后,就可以正常在浏览器中运行了呢?先说结论没错,正是webpack对require方法进行了重写,转化成了浏览器能识别的方法,从而能够正确的运行;为了证实我们的猜想,就按照下边的方法试验一下:

step 1: 
npm init -y生成package.json文件,并在scripts中配置"build": "webpack" 命令
step 2:
npm i webpack webpack-cli html-webpack-plugin -D 来安装基本的依赖(本文章中用的都是webpack5的版本)
step 3:
在package.json同级创建webpack.config.js,其中内容为:
const path = require("path")
const HtmlWebpackPlugin = require("html-webpack-plugin")
module.exports = { 
    mode: "development", // 模式写成development,方便查看打包结果
    devtool: "source-map",
    entry: "./index.js",
    output: { 
        path: path.resolve(__dirname, "dist"),
        filename: "main.js"
    },
    plugins: [
       new HtmlWebpackPlugin({ template: "./index.html", filename: "index.html"})
    ]
}
step 4:
使用 npm run build命令进行打包,查看打包后的结果dist/main.js
step 5:
在浏览器中打开dist/index.html文件,发现并没有报错,而且控制台也成功的打印出了testName: xiaoMing

这是为什么呢?webpack是如何做到处理用require来引入js文件的呢?经过对注释的删除和整理,可以得到如下的代码,我们可以看下webpack是如何处理require(CommonJs模块)的:

(() => {
  var __webpack_modules__ = ({
    "./testName.js": ((module) => {
      module.exports = 'testName: xiaoMing';
    })
  });
  var __webpack_module_cache__ = {};

  function __webpack_require__(moduleId) {
    var cachedModule = __webpack_module_cache__[moduleId];
    if (cachedModule !== undefined) {
      return cachedModule.exports;
    }
    var module = __webpack_module_cache__[moduleId] = {
      exports: {}
    };
    __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
    return module.exports;
  }
  (() => {
    var testName = __webpack_require__("./testName.js");
    console.log(testName);
  })();
})()

看到这里可能就烦来了,名字这么长,看起来好麻烦啊,别急,让我们一块一块的来分析代码的意思,坚持过第一步,后边的都是小case:

// step 1:首先声明了一个modules模块对象,key是文件的路径,value是一个函数,执行后是文件中的代码
var __webpack_modules__ = ({
  "./testName.js": ((module) => {
    module.exports = 'testName: xiaoMing';
  })
})
// step2: 定义一个缓存对象,在下边的require方法中首先判断的时候会用到
var __webpack_module_cache__ = {};
// step3: 重新定义require方法,进行解析
function __webpack_require__(moduleId) {
  // 如果能在缓存对象中找到,那么就直接返回
  var cachedModule = __webpack_module_cache__[moduleId];
  if (cachedModule !== undefined) {
    return cachedModule.exports;
  }
  // 重点:定义一个module对象,里边包含exports对象(没有为啥,就是这么定义的,为了符合commonJS规范)
  var module = __webpack_module_cache__[moduleId] = {
    exports: {}
  };
  // 执行step1模块对象中,key为[moduleId]所对应的方法(相当于执行了代码)
  __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
  // 返回执行完的结果module.exports
  return module.exports;
}
// step4: 开始执行代码啦~
(() => {
  var testName = __webpack_require__("./testName.js");
  console.log(testName);
})();

综上,完整的执行流程为:当传入了'./testName.js'后,在缓存对象中没有找到对应的Id,需要在modules模块对象中找到对应的key;
找到后,执行了其对应的方法,然后给module.exports赋值'testName: xiaoMing';
最后require方法返回的module.exports给testName赋值,所以console.log打印出来的结果为'testName: xiaoMing'

好了,此时此刻,如何使用require加载(CommonJS模块)的原理,我们已经清楚了,那么还有其他3中情况还需要我们进行一一分析,即:ES module的加载,ES module中加载CommonJs模块,CommonJs模块中加载 ES module。

webpack对ES6 modules加载的处理:

先说结论:webpack将ES module转化为上述CommonJs模块所示的代码,并且进行特殊标记。什么意思呢?同样还是以代码为例,我们需要将之前的文件,用ES6标准的导入导出进行改写:

// index.js文件,删除原有代码,用下边代码覆盖
import testName, { age } from './testName.js'
console.log(testName, age)
// testName.js文件,删除原有代码,用下边代码覆盖
export default 'test_name_xiaoMing'
export const age = 'test_age_22'
// index.html文件,保持原样

同样,我们执行npm run build命令,在打包出来目录的dist/main.js中观察结果 打包出来一大坨代码,看的头痛,经过删除注释和代码格式化,我们得到了如下所示的代码:

(() => {
  "use strict";
  var __webpack_modules__ = {
    "./testName.js": (__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
      __webpack_require__.r(__webpack_exports__);
      __webpack_require__.d(__webpack_exports__, {
        "age": () => age,
        "default": () => __WEBPACK_DEFAULT_EXPORT__
      });
      const __WEBPACK_DEFAULT_EXPORT__ = 'test_name_xiaoMing';
      var age = 'test_age_22';
    }
  };
  var __webpack_module_cache__ = {};
  function __webpack_require__(moduleId) {
    var cachedModule = __webpack_module_cache__[moduleId];
    if (cachedModule !== undefined) {
      return cachedModule.exports;
    }
    var module = __webpack_module_cache__[moduleId] = {
      exports: {}
    };
    __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
    return module.exports;
  }
  (() => {
    __webpack_require__.d = (exports, definition) => {
      for (var key in definition) {
        if (__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
          Object.defineProperty(exports, key, {
            enumerable: true,
            get: definition[key]
          });
        }
      }
    };
  })();
  (() => {
    __webpack_require__.o = (obj, prop) => Object.prototype.hasOwnProperty.call(obj, prop);
  })();
  (() => {
    __webpack_require__.r = exports => {
      if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
        Object.defineProperty(exports, Symbol.toStringTag, {
          value: 'Module'
        });
      }
      Object.defineProperty(exports, '__esModule', {
        value: true
      });
    };
  })();
  var __webpack_exports__ = {};
  (() => {
    __webpack_require__.r(__webpack_exports__);
    var _testName_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./testName.js");
    console.log(_testName_js__WEBPACK_IMPORTED_MODULE_0__["default"], _testName_js__WEBPACK_IMPORTED_MODULE_0__.age);
  })();
})();

error
相信不少同学看到上边的代码已经准备关闭页面,或点击小星星,去收藏夹里吃灰吧,其实很正常,我们看源码的时候都会产生这种想法,介于这种心理,我再进行几层优化,把这些代码删减成我们看的舒服的代码,首先,把自执行函数全给拿掉,我们只看其中的逻辑,一堆括号太碍眼了,最后,我们简化变量名,看起来很复杂,一大部分是因为变量名起的实在是太长了,又都是大写,可读性边的很差,所以我们做些简写名称替换:__webpack_modules__ -> modules;__webpack_require__ -> require;__webpack_exports__ -> exports;__unused_webpack_module -> module;__WEBPACK_DEFAULT_EXPORT__ -> _DEFAULT_EXPORT__;__WEBPACK_IMPORTED_MODULE_ -> '',之后再调整下代码顺序,删除一些无关紧要的代码逻辑,方便阅读; 经过简化,最终结果如下,是不是变得舒服多了呢?我们同样,来一步一步分析做了那些事情:(首先请自行了解Object.definproperty和Symbol.toStringTag的作用)

(() => {
  "use strict";
  // step1:和之前的CommonJS模块转化相同,定义模块对象,然后定义一个require方法,均是由其演变过来的
  var modules = {
    "./testName.js": (module, exports, require) => {
      // 标识打包前的模块是ES module(testName.js)
      require.r(exports);
      // 给exports赋值
      require.d(exports, {
        "age": () => age,
        "default": () => _DEFAULT_EXPORT__
      });
      const _DEFAULT_EXPORT__ = 'test_name_xiaoMing';
      var age = 'test_age_22';
    }
  };
  var cache = {};
  function require(moduleId) {
    var cachedModule = cache[moduleId];
    if (cachedModule !== undefined) {
      return cachedModule.exports;
    }
    var module = cache[moduleId] = {
      exports: {}
    };
    modules[moduleId](module, module.exports, require);
    return module.exports;
  }
  var exports = {};
  /* step2:给require定义了一个r方法,那么这个r方法的作用,就是为了给ES module做一个标识,因为无论 
  CommonJs模块还是ES module,最后打包出来的结果都是要借助webpack重写的require方法的,那如何区分呢?
  那就是给ES module一个标识,通过这个标识来区分*/
  require.r = exports => {
    // 写法其实就是相当于 exports[Symbol.toStringTag] = 'Module'
    Object.defineProperty(exports, Symbol.toStringTag, {
      value: 'Module'
    });
    // 写法其实相当于 exports.__esModule = true
    Object.defineProperty(exports, '__esModule', {
      value: true
    });
  };
  /* step3:给require定义了一个d方法,这个d方法的作用就是给exports对象进行赋值,遍历definition对象
  将其中的value作为exports对象中的getter */
  require.d = (exports, definition) => {
    for (var key in definition) {
      Object.defineProperty(exports, key, {
        enumerable: true,
        get: definition[key]
      });
    }
  };
  /* step4:开始执行代码,传入了文件路径,经过require方法处理后,
  取default则可获得export default默认导出的结果,取age则可获取export const age导出的值*/
  (() => {
    // 只要打包前的模块(index.js)是一个ES module,就要调用require.r方法进行标识
    require.r(exports);
    var _testName_js_0__ = require("./testName.js");
    console.log(_testName_js_0__["default"], _testName_js_0__.age);
  })();
})();

综上,完整的执行流程其实和转换CommonJS模块相同,就是增加了给module.exports对象打上ES Module的标识
和赋值操作,只要打包前的模块是ES module,那么就先执行一次require.r方法进行标识;核心就是赋值,导出。

有些细心的小伙伴可能会有疑问,在step1中,为啥一定要使用Object.defineProperty的get访问器属性,来给exports对象赋值呢,直接写成exports.age = xxx不是一样的么?这里其实涉及到了一个Commonjs模块和ES module的重大区别,:ES module导出的是一个引用,在内部修改后,任何地方都可以获取到它模块内最新的值,但CommonJs导出的仅仅是一个值,内部修改了这个值,就不会再同步到外部。我们单独拿出这段代码来讲解一下:

/* 使用Object.defineProperty来进行赋值,取值时要经过get访问器的拦截,那么如果改变了age的值,再去访
问exports.age的话,是能够访问到最新的exports.age的值,也就是test_age_33*/
var modules = {
  "./testName.js": (module, exports, require) => {
    require.r(exports);
    // 给exports赋值
    require.d(exports, {
      "age": () => age,
      "default": () => _DEFAULT_EXPORT__
    });
    const _DEFAULT_EXPORT__ = 'test_name_xiaoMing';
    var age = 'test_age_22';
    // 新增定时器代码便于理解
    setTimeout(() => {age = 'test_age_33'}, 1000)
  }
};
/* 直接使用exports.age = xxx来进行赋值,那么如果改变了age的值,再去访问exports.age的话,age虽然变了,
但是却和exports.age是没关系的,所以此时获取到的exports.age的值,依旧是原来的'test_age_22',这里理
解了,就能理解上边所说的Commonjs和ES module之一的区别了*/
var modules = {
  "./testName.js": (module, exports, require) => {
    require.r(exports);
    // 给exports赋值
    //require.d(exports, {
      //"age": () => age,
      //"default": () => _DEFAULT_EXPORT__
    //});
    // 直接进行赋值
    var age = 'test_age_22';
    exports.age = age
    // const _DEFAULT_EXPORT__ = 'test_name_xiaoMing';
    // 新增定时器代码便于理解
    setTimeout(() => {age = 'test_age_33'}, 1000)
  }
};

还没理解?ok,那我们用更简单的例子来说明一下:

// 提问,这段代码的输出结果是什么呢?相信只要你明白js中值和引用的区别,马上就能回答出来
let age = 1
let obj = { age: age }
console.log(obj.age) // 1
age = 222
console.log(obj.age) // 1
/* 变化一种形式,那么结果输出的是什么呢?没错,当你访问obj.age的时候,访问的其实是age这个变量的值,所
以,当age变量的值发生改变了,就一直能获取到最新的值 */
let age = 1
let obj = {}
Object.defineProperty(obj, 'age', {
    enumerable: true,
    get: () => age
})
console.log(obj.age) // 1
age = 222
console.log(obj.age) // 222

有时候简简单单的原理单独拿出来,大部分人都能看懂,但是当把这些运用到真实的场景中,很多人看的就一脸懵,所以,还是需要理论+实践,多去看优秀的源码和项目,来对我们学习的原理,进行融会贯通。

CommonJS加载ES module:

// index.js文件,删除原有代码,用下边代码覆盖
const testName = require('./testName.js')
console.log(testName.default, testName.age)
// testName.js文件,保持原样
// index.html文件,保持原样

我们执行npm run build命令,在打包出来目录的dist/main.js中观察结果 同样,经过注释的删减,变量名称的优化,可以得到和上边几乎一样的结果,之后最后开始执行的地方,有些许区别,可以自行分析一下,就当是复习

(() => {
  var modules = {
    "./testName.js": (module, exports, require) => {
      "use strict";
      require.r(exports);
      require.d(exports, {
        "age": () => age,
        "default": () => _DEFAULT_EXPORT__
      });
      const _DEFAULT_EXPORT__ = 'test_name_xiaoMing';
      var age = 'test_age_22';
    }
  };
  var cache = {};
  function require(moduleId) {
    var cachedModule = cache[moduleId];
    if (cachedModule !== undefined) {
      return cachedModule.exports;
    }
    var module = cache[moduleId] = {
      exports: {}
    };
    modules[moduleId](module, module.exports, require);
    return module.exports;
  }
  require.d = (exports, definition) => {
    for (var key in definition) {
      Object.defineProperty(exports, key, {
        enumerable: true,
        get: definition[key]
      });   
    }
  };
  require.r = exports => {
    Object.defineProperty(exports, Symbol.toStringTag, {
      value: 'Module'
    });
    Object.defineProperty(exports, '__esModule', {
      value: true
    });
  };
  (() => {
    var testName = require("./testName.js");
    // 只有这里是不同的,如果想要拿到export default默认导出的值,那么需要手动去取值testName['default']
    console.log(testName["default"], testName.age);
  })();
})();

ES module加载CommonJS

我们继续对文件中代码进行如下修改:

// index.js文件,删除原有代码,用下边代码覆盖
import testName, { age } from './testName.js'
console.log(testName, age)
// testName.js文件,删除原有代码,用下边代码覆盖
module.exports = {
  age: 'age_11',
  testName: 'test_xiaoMing'
}
// index.html文件,保持原样

我们执行npm run build命令,在打包出来目录的dist/main.js中观察结果。有了之前的学习和分析,再来看最后一种情况,便会发现好理解了很多,还是删除了无用的注释,无关紧要的逻辑和简化了变量名称,纵观全部代码,发现只是增加了一个require.n方法,开始执行的时候也略有不同:

(() => {
  var modules = {
    "./testName.js": module => {
      module.exports = {
        age: 'age_11',
        testName: 'test_xiaoMing'
      };
    }
  };
  var cache = {};
  function require(moduleId) {
    var cachedModule = cache[moduleId];
    if (cachedModule !== undefined) {
      return cachedModule.exports;
    }
    var module = cache[moduleId] = {
      exports: {}
    };
    modules[moduleId](module, module.exports, require);
    return module.exports;
  }
  require.d = (exports, definition) => {
    for (var key in definition) {
      Object.defineProperty(exports, key, {
        enumerable: true,
        get: definition[key]
      });
    }
  };
  require.r = exports => {
    Object.defineProperty(exports, Symbol.toStringTag, {
      value: 'Module'
    });
    Object.defineProperty(exports, '__esModule', {
      value: true
    });
  };
  // require.n方法其实就是,根据模块是否有__esModule标识,来获取默认导出的对象
  require.n = module => {
    var getter = module && module.__esModule ? () => module['default'] : () => module;
    // 就是给当前的getter添加了个a属性,指向自己,作用其实就是在获取默认导出对象的时候,不需要加()执
    // 行只需要getter.a便可以获取到默认导出对象
    require.d(getter, {
      a: getter
    });
    return getter;
  };
  var exports = {};
  (() => {
    "use strict";
    // 开始执行,只要打包前的模块是ES module(index.js),那么就要调用require.r方法进行标识
    require.r(exports);
    // 获取导出对象
    var _testName_js_0__ = require("./testName.js");
    // 获取用require.n方法处理完,导出对象的默认对象
    var _testName_js_0___default = require.n(_testName_js_0__);
    console.log(_testName_js_0___default(), _testName_js_0__.age);
  })();
})();

综上,整体的打包流程几乎相同,因为index.jsES module,所以在开始执行时,要先调用require.r方法进行
标识,之后调用require方法,传入.testName.js来获取导出对象module.exports,然后利用require.n方法
对导出对象进行判断,从而再获取默认导出对象getter

总结

通过简单的几个例子,和webpack打包的结果,我们大致上知道,webpack是如何处理CommonJs与ES module之间相互引用的原理了,迈出第一步虽然艰难,但是更艰难的是坚持走下去,花了2个晚上,终于把第一篇文章完成了,希望能对大家有所帮助,我也会抽空继续更新,文章中有哪些不对的地方,或理解不同的地方,欢迎大家一起来讨论~