webpack是怎么实现js模块化的?

878 阅读7分钟

前言

博主最近一直在学习算法相关的内容,所以挺长一段时间没有更新技术文章了,正好最近有个朋友问了我一个问题,webpack是怎么实现模块化的?我也就顺便把这块相关的内容写成一篇掘文,分享给那些对这块内容不太清楚的同学。

通过本文,你会搞清楚下面这些问题:

  • 1.webpack的模块化实现
  • 2.import会被webpack编译成什么?
  • 3.为什么你可以使用import引入commonjs规范的模块?为什么反向引用也可以?

前端模块化

对于前端的模块化,相信大家都很熟悉。在现在的前端开发中,因为三大前端框架以及webpack等一系列打包工具的普及,模块化的应用已经是家常便饭。我们不再需要像以前用对象来定义js模块,或者使用AMDCMDjs规范。现在在浏览器端,使用模块的方法就一个,import。随着时代发展,现在已经有很多浏览器原生支持了import语法,但是为了兼容性,我们还是需要通过webpack来处理import语法。

PS:前不久尤大的vite2.0已经正式发布了,构建速度真是快到飞起,相信这也是未来的主流打包构建方式。

import会被编译成什么

我们先来写个最简单的例子,来让webpack编译一下。本文的例子使用的webpack5编译,部分命名可能跟webpack4有些许差异,但是模块化的思想是一致的。

// index.js
import { read } from './a';
import run from './b';
read();
run();

// a.js
export const read = () => {
  console.log('阅读');
};

// b.js
export default run = () => {
  console.log('跑步');
};

代码很简单,现在我们来看下,webpack编译出来的代码是什么样的。`(去掉了很多注释)

(() => {
  "use strict";
  var __webpack_modules__ = ({
    "./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 */   \"read\": () => (/* binding */ read)\n/* harmony export */ });\nconst read = () => {\r\n  console.log('阅读');\r\n};\n\n//# sourceURL=webpack://my-leetcode/./a.js?");
    }),
    "./b.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 */   \"default\": () => (__WEBPACK_DEFAULT_EXPORT__)\n/* harmony export */ });\n/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (run = () => {\r\n  console.log('跑步');\r\n});\n\n//# sourceURL=webpack://my-leetcode/./b.js?");
    }),
    "./index.js": ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
      eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _a__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./a */ \"./a.js\");\n/* harmony import */ var _b__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./b */ \"./b.js\");\n\r\n\r\n(0,_a__WEBPACK_IMPORTED_MODULE_0__.read)();\r\n(0,_b__WEBPACK_IMPORTED_MODULE_1__.default)();\n\n//# sourceURL=webpack://my-leetcode/./index.js?");
    })
  });
  
  var __webpack_module_cache__ = {};

  function __webpack_require__(moduleId) {
    if(__webpack_module_cache__[moduleId]) {
        return __webpack_module_cache__[moduleId].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 });
    };
  })();

  // 执行入口的index.js
  var __webpack_exports__ = __webpack_require__("./index.js");
 })();

首先编译出来的这个代码就是一个自执行函数,里面的内容可以分为三部分。

  • 1.modules对象
  • 2.__webpack_require__方法以及子方法的定义
  • 3.通过__webpack_require__方法运行入口的index.js文件

modules对象

这个对象里存放了所有你代码里写的作为一个个模块的js,它以js的文件路径作为key,值为一个可执行的函数。

__webpack_require__方法以及子方法的定义

__webpack_require__是一个关键的方法,负责实际的模块加载并执行这些模块内容,返回执行结果。它的子方法都是用来帮助模块的加载和执行。

运行index.js文件

通过__webpack_require__方法运行入口文件index.js

webpack模块化实现

我们现在从入口index.js开始,一步步跟随代码。

__webpack_require__("./index.js");

我们先来看看__webpack_require__方法

// 模块缓存
var __webpack_module_cache__ = {};

// 传入引用模块的路径
function __webpack_require__(moduleId) {
  // 如果引用的模块存在缓存,直接返回缓存内容
  if(__webpack_module_cache__[moduleId]) {
      return __webpack_module_cache__[moduleId].exports;
  }
  // 定义一个module对象,再给它初始化一个exports对象
  var module = __webpack_module_cache__[moduleId] = {
      exports: {}
  };
  // 运行__webpack_modules__里的相关模块,传入相关参数
  __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
  return module.exports;
}

__webpack_require__方法其实就是运行__webpack_modules__里的相关模块。我们现在来看看index.js模块的可执行函数。

"./index.js": ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
  eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _a__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./a */ \"./a.js\");\n/* harmony import */ var _b__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./b */ \"./b.js\");\n\r\n\r\n(0,_a__WEBPACK_IMPORTED_MODULE_0__.read)();\r\n(0,_b__WEBPACK_IMPORTED_MODULE_1__.default)();\n\n//# sourceURL=webpack://my-leetcode/./index.js?");
})
// 把eval里的代码提取出来,等价于
(__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
  __webpack_require__.r(__webpack_exports__);
  // 定义一个变量,通过__webpack_require__加载a.js文件
  var _a__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./a.js");
  // 定义一个变量,通过__webpack_require__加载b.js文件
  var _b__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__("./b.js");
  // 通过之前定义的变量,来运行相关的方法
  (0,_a__WEBPACK_IMPORTED_MODULE_0__.read)();
  (0,_b__WEBPACK_IMPORTED_MODULE_1__.default)();
}

里面的方法其实很简单,就是通过__webpack_require__加载a.jsb.js,通过返回值来运行a.jsb.js模块里的方法。

我们现在来看看,__webpack_require__是怎么加载a.jsb.js模块,并把它们内部的方法返回出来使用的。我们先从eval中提取出相关函数。

// a.js
(__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
   __webpack_require__.r(__webpack_exports__);
   __webpack_require__.d(__webpack_exports__, { "read": () => read });
   const read = () => { console.log('阅读'); };
}

// b.js
(__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
      __webpack_require__.r(__webpack_exports__);
      __webpack_require__.d(__webpack_exports__, { "default": () =>__WEBPACK_DEFAULT_EXPORT__ });
      const __WEBPACK_DEFAULT_EXPORT__ = (run = () => { console.log('跑步') });
    }

因为一个是read方法是export导出的,run方法是export default导出的,但是两者除了在命名上稍微有所区别,其他都一致。

首先,函数里,都存在我们写在模块里的业务代码,readrun。然后我们先重点来看下__webpack_require__.d方法。

__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] });
    }
  }
};
//这里的重点其实就是一句话,把key的内容,定义到exports的get方法中
Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });

关于Object.defineProperty的内容不在本文讨论范围内,如果你不清楚这个方法,请先去了解一下它的使用。

我们再把a.js__webpack_require__.d结合一下。

// a.js
(__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
   __webpack_require__.r(__webpack_exports__);
   // 这里的__webpack_exports__其实就是__webpack_require__里定义的module.exports。
   // 这里就是把read方法定义到module.exports.read上
   Object.defineProperty(__webpack_exports__, "read", { enumerable: true, get: read });
   const read = () => { console.log('阅读'); };
}

这样定义之后

// index.js
// 这里__webpack_require__返回出来的module.exports.read上就定义了一个read方法
var _a__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./a.js");
// 后面自然就可以使用a.js里定义的read方法了。b.js也是相同的道理
_a__WEBPACK_IMPORTED_MODULE_0__.read()

其实就是相当于,webpack将每一个模块暴露出来的方法,都定义在了各自的module.exports对象上,然后返回出来,给其他的模块使用。通过这种方法,webpack就实现了js的模块化。

这不但跟Commonjs的导出方法命名一样,实现上也是类似。Commonjs中,每个js文件一创建,也会生成一个 var exports = module.exports = {}, 开发者定义的方法,都会定义到exports或者module.exports

import懒加载实现

懒加载是前端非常常用的一种性能优化手段,使用上也很简单,只要import('xxx.js')就行,现在我们来看下webpack是怎么实现懒加载的。我们稍微改下之前的代码,然后再重新编译一下。

// index.js
import('./a.js').then(res => {
  res.read();
})

// a.js
export const read = () => {
  console.log('阅读');
};

编译之后,我们会发现除了主的js文件之外,还会生成一个懒加载的时候需要加载的js文件。 主文件步骤跟之前一致,还是通过__webpack_require__加载index.js文件。

这里的代码量比较大,详细的流程,我也不在这里贴代码了,总的来说,当用户触发其加载的动作时,会通过__webpack_require__.l方法动态的在head标签中创建一个script标签,然后加载模块,通过script标签的onloadonerror事件监听模块加载状态,如果完成,自动执行其中的代码。

commonjs的文件加载

接下来,我们看下commonjs规范的文件会被webpack编译成什么样,改造一下代码

// index.js
const a = require('./a');
const run = require('./b');
a.read();
run();
// a.js
exports.read = () => {
  console.log('阅读');
};
// b.js
module.exports = run = () => {
  console.log('跑步');
};

别的代码都一致,主要就来看下__webpack_modules__对象中各个模块的key对应的函数

// index.js
(__unused_webpack_module, __unused_webpack_exports, __webpack_require__) => {
  const a = __webpack_require__("./a.js");
  a.read();
  const run = __webpack_require__("./b.js");
  run();
}
// a.js
(__unused_webpack_module, exports) => {
  exports.read = () => { console.log('阅读') };
}
// b.js
(module) => {
  module.exports = run = () => { console.log('跑步'); };
},

编译之后的index.js文件跟原来的文件,只是把require换成了__webpack_require__,其他没有变化。而a.jsb.js跟原来的代码是一模一样的。但是这里的exportsmodule__webpack_require__调用时候传入的。相当于,a.jsb.js都直接在__webpack_require__module.exports上定义了相关的方法。那index.js自然也就可以调用到这些方法了。

这也说明了,为什么可以使用import引入commonjs规范的模块,反向引用也可以。

总结

webpack的模块化主要是通过__webpack_require__方法,将各个模块里定义的方法,esm定义的方法使用Object.definePropertycommonjs定义的方法直接定义,最终都会统一加到自己定义的module.exports对象上,然后返回出来,给其他的模块引用。

import进来的文件经过webpack打包以后会存放在一个对象里,key为模块路径,value为模块的可执行函数。import懒加载会单独打成一个包,在需要加载的时候,动态进行加载。

因为webpack会把import的方法都会转换成__webpack_require__方法,使用类似commonjs规范的方式,获取其他模块里的方法。所以可以使用import引入commonjs规范的模块, 反向引用也可以。

感谢

本文如果对你有所帮助,请帮忙点个赞,感谢。