手把手带你学webpack(5)-- 模块化原理

845 阅读9分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路

本篇文章对应源码:github.com/Plasticine-…

在你写项目的时候,你是否有好奇过为什么可以在webpack项目中同时使用importrequire导入其他的包?也就是说webpack是同时支持ES ModuleCommonJS这两种模块化规范的(其他的模块化规范由于已经很少使用,因此不在本篇文章讨论范围内)

那么webpack中是如何处理ES ModuleCommonJS模块同时使用的问题的呢?这就是这篇文章要探讨的问题


1. 环境搭建

首先我们需要了解一下ESM(ES Module)CJS(CommonJS Module)webpack中是如何被处理的,一个很自然的猜想:既然webpack能够同时支持两种模块化规范,那肯定是它自己内部实现了一个模块化的方案,并且能够把ESMCJS的模块化代码转成自己实现的模块化方案的代码

当然,这只是目前的猜想,那么webpack内部到底是不是这样做的呢?为了探究清楚这个问题,我们先来搭建一下项目的目录结构 image.png 项目的入口在src/index.js中,其代码如下

// ESM 方式加载 CJS 模块
import moduleEsCommon from './js/common-module';
moduleEsCommon.hello();

// ESM 方式加载 ESM 模块
import moduleEsEs from './js/es-module';
moduleEsEs.hello();

// CJS 方式加载 CJS 模块
const moduleCommonCommon = require('./js/common-module');
moduleCommonCommon.hello();

// CJS 方式加载 ESM 模块
const moduleCommonEs = require('./js/es-module');
moduleCommonEs.default.hello(); // CJS 方式导入 ESM 模块时要加上 default

我们需要探讨四种情况:

  1. CJS中导入CJS模块
  2. ESM中导入ESM模块
  3. CJS中导入ESM模块
  4. ESM中导入CJS模块

src/js目录下有两个js文件,一个是CJS模块,另一个是ESM模块,然后我们在main.js中导入它们,而其他没用到的模块则注释掉即可

// src/js/common-module.js
module.exports = {
  hello() {
    console.log(`I'm from common module.`);
  },
};
// src/js/es-module.js
export default {
  hello() {
    console.log(`I'm from es module.`);
  },
};

export const esmVar = 'hello esm';
export function esmFn() {
  console.log('hello esm fn');
}

2. 修改mode为development

默认情况下,webpack打包的modeproduction,这样一来打包后的代码会被压缩和丑化

  • 压缩:将所有空格换行去掉
  • 丑化:将所有变量名替换成简短的无意义的变量名

这样的话不方便我们观察打包后的结果,因此可以修改modedevelopment,尽量让index.js中的代码保持原样

除此之外,development模式下,我们的业务代码是会被放进eval中的,也不方便观察,这是因为development模式下默认的devtooleval,因此还需要将devtool改成source-map,关于webpackmodedevtool中的source-map后面还会出文章详细讲,这里只是为了本篇文章的需要先配置环境,了解即可

因此目前的webpack.config.js配置如下

const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanPlugin } = require('webpack');

/**
 * @type { import('webpack').Configuration }
 */
module.exports = {
  mode: 'development',
  devtool: 'source-map',
  plugins: [new HtmlWebpackPlugin(), new CleanPlugin()],
};

3. CJS方式加载CJS模块

首先将index.js中的其他模块注释掉,只保留CJS加载CJS模块的代码

// src/index.js

// ESM 方式加载 CJS 模块
// import moduleEsCommon from './js/common-module';
// moduleEsCommon.hello();

// ESM 方式加载 ESM 模块
// import moduleEsEs from './js/es-module';
// moduleEsEs.hello();

// CJS 方式加载 CJS 模块
const moduleCommonCommon = require('./js/common-module');
moduleCommonCommon.hello();

// CJS 方式加载 ESM 模块
// const moduleCommonEs = require('./js/es-module');
// moduleCommonEs.default.hello(); // CJS 方式导入 ESM 模块时要加上 default

之后的情况也是类似的处理,就不赘述了

然后运行webpack打包查看构建结果

// dist/main.js
(() => {
  // index.js 中用到的所有模块会被放到 __webpack_modules__ 中
  var __webpack_modules__ = {
    // 每个模块都不是直接拿到的 而是放到一个函数中 执行后会将模块的内容挂载到 module.exports 中
    './src/js/common-module.js': (module) => {
      module.exports = {
        hello() {
          console.log(`I'm from common module.`);
        },
      };
    },
  };

  // 用于缓存
  var __webpack_module_cache__ = {};

  // 替代原生的 CommonJS 的 require
  function __webpack_require__(moduleId) {
    // 到缓存中去取模块
    var cachedModule = __webpack_module_cache__[moduleId];
    if (cachedModule !== undefined) {
      // 缓存中有模块则直接返回
      return cachedModule.exports;
    }

    // 缓存中没有则添加到缓存中再返回
    var module = (__webpack_module_cache__[moduleId] = {
      // 双重赋值 module 和 缓存项 都指向 { exports: {} } 这一对象
      exports: {},
    });

    // 在 __webpack_modules__ 中找到对应的模块后执行它得到模块的内容
    __webpack_modules__[moduleId](module, module.exports, __webpack_require__);

    return module.exports;
  }

  // 替代原生的 CommonJS 的 module.exports
  var __webpack_exports__ = {};

  // 入口文件中的代码
  (() => {
    // ESM 方式加载 CJS 模块
    // import moduleEsCommon from './js/common-module';
    // moduleEsCommon.hello();

    // ESM 方式加载 ESM 模块
    // import moduleEsEs from './js/es-module';
    // moduleEsEs.hello();

    // CJS 方式加载 CJS 模块
    const moduleCommonCommon = __webpack_require__('./src/js/common-module.js');
    moduleCommonCommon.hello();

    // CJS 方式加载 ESM 模块
    // const moduleCommonEs = require('./js/es-module');
    // moduleCommonEs.default.hello(); // CJS 方式导入 ESM 模块时要加上 default
  })();
})();

打包的结果中是有webpack的注释的,为了方便观察,我将这些注释都删除了

所有代码都被放到一个立即执行函数中执行,形成一个单独的作用域,防止变量污染

打包的结果中主要有一下几个部分:

  1. __webpack_modules__:在入口文件中用到的所有模块都会被放到这个对象中,key是模块所在路径,value是一个函数,模块中导出的内容并不是直接作为对象的value,而是通过一个函数,执行该函数时会将导出的内容挂载到函数的参数module
  2. __webpack_module_cache__:用作缓存,缓存存放的是对模块导出对象的引用,因此导出对象修改时,缓存中的相应部分也会修改
  3. __webpack_require__:实现了类似CJSrequire的功能,能够加载一个模块中导出的内容,首先会查找缓存,缓存中有则直接返回,缓存中没有时,则先创建一个有一个exports属性的对象,然后根据moduleId__webpack_modules__中找到相应的模块挂载函数后,执行挂载函数将模块的内容挂载到exports属性中再返回
  4. __webpack_exports__:实现类似CJSmodule.exports的功能
  5. 最后是一个立即执行函数,里面就是入口文件中的代码了,只是把其中的require替换成了webpack实现的__webpack_require__

4. ESM方式加载ESM模块

(() => {
  'use strict';
  var __webpack_modules__ = ({
    './src/js/es-module.js': ((
      __unused_webpack_module,
      __webpack_exports__,
      __webpack_require__
    ) => {
      __webpack_require__.r(__webpack_exports__);
      __webpack_require__.d(__webpack_exports__, {
        // default 将会被放到 exports 对象中
        default: () => __WEBPACK_DEFAULT_EXPORT__,
        "esmFn": () => (/* binding */ esmFn),
        "esmVar": () => (/* binding */ esmVar)
      });

      // 模块中的内容会作为 default 函数的返回值返回 因此要使用模块的内容需要这样:
      // const foo = __webpack_require__('module-name');
      // foo.default.hello(); -- default 现在是 foo 的 getter 会被自动调用,因此不需要我们手动调用
      const __WEBPACK_DEFAULT_EXPORT__ = ({
        hello() {
          console.log(`I'm from es module.`);
        },
      });
      
      const esmVar = 'hello esm';
      function esmFn() {
        console.log('hello esm fn');
      }
    }),
  });
  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;
  }

  (() => {
    /**
     * 遍历 definition 对象的每一个属性 如果在 exports 中不存在该属性时则将其添加进去
     * @param {any} exports 存放模块导出内容的对象
     * @param {any} definition
     */
    __webpack_require__.d = (exports, definition) => {
      for (var key in definition) {
        // key 是属于 definition 且 不是 exports 的属性时
        if (
          __webpack_require__.o(definition, key) &&
          !__webpack_require__.o(exports, key)
        ) {
          // 将 key 添加到 exports 中
          Object.defineProperty(exports, key, {
            enumerable: true,
            // 设置 getter 代理 调用 definition 中对 key 的 getter 代理
            get: definition[key],
          });
        }
      }
    };
  })();

  (() => {
    /**
     * 判断 prop 是否是属于 obj 自身的属性而不是原型链上的属性
     * @param {any} obj 对象
     * @param {string | Symbol} prop 属性名
     */
    __webpack_require__.o = (obj, prop) =>
      Object.prototype.hasOwnProperty.call(obj, prop);
  })();

  (() => {
    // 给 __webpack_require__ 函数对象添加一个 r 方法
    /**
     * 给 exports 对象添加一个 __esModule 属性 并且在支持 Symbol 的环境下还会添加一个额外的属性
     * @param {any} exports 存放模块中导出内容的对象
     */
    __webpack_require__.r = (exports) => {
      // 判断是否支持 Symbol
      if (typeof Symbol !== 'undefined' && Symbol.toStringTag) {
        // 支持 Symbol 时给 exports 对象添加一个属性 [Symbol.toStringTag]: 'Module'
        Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
      }

      // 添加一个属性 __esModule: true
      Object.defineProperty(exports, '__esModule', { value: true });
    };
  })();

  var __webpack_exports__ = {};
  (() => {
    __webpack_require__.r(__webpack_exports__);
    var _js_es_module__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(
      './src/js/es-module.js'
    );
    // ESM 方式加载 CJS 模块
    // import moduleEsCommon from './js/common-module';
    // moduleEsCommon.hello();

    // ESM 方式加载 ESM 模块

    // 默认导出的内容被挂载到 default 属性中
    _js_es_module__WEBPACK_IMPORTED_MODULE_0__['default'].hello();
    console.log(_js_es_module__WEBPACK_IMPORTED_MODULE_0__.esmVar);
    (0,_js_es_module__WEBPACK_IMPORTED_MODULE_0__.esmFn)();

    // CJS 方式加载 CJS 模块
    // const moduleCommonCommon = require('./js/common-module');
    // moduleCommonCommon.hello();

    // CJS 方式加载 ESM 模块
    // const moduleCommonEs = require('./js/es-module');
    // moduleCommonEs.default.hello(); // CJS 方式导入 ESM 模块时要加上 default
  })();
})();

相比于CJS加载CJS模块,多出了以下几点:

  1. __webpack_require__函数对象添加了三个方法rdo,这三个方法的作用已在上面的代码中写明了注释
  2. 对于默认导出对象,会给exports添加一个default属性,并且通过Object.defineProperty的方式给default属性设置了getter,将模块中的默认导出的容挂载到default getter的返回值中,这样在使用的时候需要对__webpack_require__得到的模块再额外调用一个default属性后才能访问到模块中的内容
  3. 普通导出的内容也是有相应的getter函数,但是没有挂载到default属性中,因此还是可以直接调用

打包的结果中,对于esm中的esmFn的调用是这样调用的

(0,_js_es_module__WEBPACK_IMPORTED_MODULE_0__.esmFn)()

这种方式等价于直接调用esmFn函数


5. CJS方式加载ESM模块

(() => {
  var __webpack_modules__ = {
    './src/js/es-module.js': (
      __unused_webpack_module,
      __webpack_exports__,
      __webpack_require__
    ) => {
      'use strict';
      __webpack_require__.r(__webpack_exports__);
      __webpack_require__.d(__webpack_exports__, {
        default: () => __WEBPACK_DEFAULT_EXPORT__,
      });
      const __WEBPACK_DEFAULT_EXPORT__ = {
        hello() {
          console.log(`I'm from es module.`);
        },
      };
    },
  };
  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);
  })();

  (() => {
    // define __esModule on exports
    __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__ = {};
  (() => {
    // ESM 方式加载 CJS 模块
    // import moduleEsCommon from './js/common-module';
    // moduleEsCommon.hello();

    // ESM 方式加载 ESM 模块
    // import moduleEsEs from './js/es-module';
    // moduleEsEs.hello();

    // CJS 方式加载 CJS 模块
    // const moduleCommonCommon = require('./js/common-module');
    // moduleCommonCommon.hello();

    // CJS 方式加载 ESM 模块
    const moduleCommonEs = __webpack_require__('./src/js/es-module.js');
    moduleCommonEs.default.hello(); // CJS 方式导入 ESM 模块时要加上 default
  })();
})();

可以看到,结果和ESM方式加载ESM模块是一样的,但是由于ESM中的默认导出的内容是被挂载到default中的,因此CJS的方式加载到的ESM模块要想使用默认导出的内容时还是需要先调用default属性才能访问ESM中默认导出的内容


6. ESM方式加载CJS模块

(() => {
  var __webpack_modules__ = {
    './src/js/common-module.js': (module) => {
      module.exports = {
        hello() {
          console.log(`I'm from common module.`);
        },
      };
    },
  };
  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;
  }

  // 多了一个判断是 module 是否是 esm 的特殊处理
  // esm 模块的默认导出的内容会放到 default 属性中 为了在外部都能够直接统一调用模块中的内容而不需要加上
  // default 属性去调用 因此需要进行一下判断再去处理
  (() => {
    __webpack_require__.n = (module) => {
      var getter =
        module && module.__esModule ? () => module['default'] : () => module;
      __webpack_require__.d(getter, { a: getter });
      return getter;
    };
  })();

  (() => {
    __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__ = {};
  (() => {
    'use strict';
    __webpack_require__.r(__webpack_exports__);
    var _js_common_module__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(
      './src/js/common-module.js'
    );

    // 调用 __webpack_require__.n() 使得无论是 cjs 还是 esm 中的默认导出内容都能够直接被调用
    // 而不需要添加 default 属性去调用
    var _js_common_module__WEBPACK_IMPORTED_MODULE_0___default =
      __webpack_require__.n(_js_common_module__WEBPACK_IMPORTED_MODULE_0__);
    // ESM 方式加载 CJS 模块

    _js_common_module__WEBPACK_IMPORTED_MODULE_0___default().hello();

    // ESM 方式加载 ESM 模块
    // import moduleEsEs from './js/es-module';
    // moduleEsEs.hello();

    // CJS 方式加载 CJS 模块
    // const moduleCommonCommon = require('./js/common-module');
    // moduleCommonCommon.hello();

    // CJS 方式加载 ESM 模块
    // const moduleCommonEs = require('./js/es-module');
    // moduleCommonEs.default.hello(); // CJS 方式导入 ESM 模块时要加上 default
  })();
})();

相比于前面的方式中,在__webpack_require__函数对象上新增了一个n方法,用于特殊处理esm模块的默认导出内容,使得在外部调用的时候可以不需要带上.default去调用

这也就能够理解为什么ESM方式使用ESM模块的默认导出内容时可以不需要先调用default属性,而CJS方式使用ESM模块的默认导出内容时却需要了