【前端模块化】webpack是如何处理模块的

1,226 阅读6分钟

webpack 的模块系统

webpack 模块系统是自己实现的,模块经过打包后将导出的变量放在 exports 中给外部访问
webpack 模块定义是比较贴近于 commonJS 规范的,原因大概能猜到,commonJs 规范并不是采用关键字的语法,可以通过函数实现,这样有利于 webpack 做模块缓存,并且兼容两种规范(esModule 和 commonJs)时可以基本不对 commonJs 模块做处理
这篇文章主要讨论 webpack 对不同规范模块的处理,以下代码示例均采用 webpack 5 打包,相关代码在 这里 可以看到

webpack runtime 关键代码

变量 __webpack_modules__

模块经过 webpack 的打包后,变成一个键值对,挂载在 __webpack_modules__ 对象上,下面是 commonJs 和 esModule 模块打包后的代码。

var __webpack_modules__ = ({

/***/ "./src/esm-and-cjs/cjs.js":
/*!********************************!*\
  !*** ./src/esm-and-cjs/cjs.js ***!
  \********************************/
/***/ ((module) => {
        // 模块内容
        const name = 'cjs';
        module.exports = {
          log() {
            console.log(name);
          }
        }
/***/ }),

/***/ "./src/esm/esm.js":
/*!************************!*\
  !*** ./src/esm/esm.js ***!
  \************************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

__webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */   "log": () => (/* binding */ log),
/* harmony export */   "default": () => (__WEBPACK_DEFAULT_EXPORT__)
/* harmony export */ });
function log() {
  console.log(1);
}
/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = ({
  log() {
    console.log(2);
  }
});

/***/ })

})

打包之后的模块变成了一个有副作用的函数,对传进来的参数加上模块的导出变量。

函数对象 __webpack_require__

__webpack_require__ 是模块的引入方法 ,当引入一个模块时,首先会通过缓存去拿,缓存没有再通过模块 id 作为索引,拿到__webpack_modules__ 的模块函数去执行,并且设置一遍缓存,返回该模块的 exports 变量。
__webpack_require__ 不止是一个方法,还包括了其他变量,下文会有所介绍。

/******/ // The module cache
/******/ var __webpack_module_cache__ = {};
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/   // Check if module is in cache
/******/   if(__webpack_module_cache__[moduleId]) {
/******/    return __webpack_module_cache__[moduleId].exports;
/******/   }
/******/   // Create a new module (and put it into the cache)
/******/   var module = __webpack_module_cache__[moduleId] = {
/******/    // no module.id needed
/******/    // no module.loaded needed
/******/    exports: {}
/******/   };
/******/  
/******/   // Execute the module function
/******/   __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
/******/  
/******/   // Return the exports of the module
/******/   return module.exports;
/******/ }

函数 __webpack_require__.d

__webpack_require__.d 是对模块导出变量 exports 设置代理的函数。例如 export { a: 1, b: 2 } 中的 { a: 1, b: 2 },都会经过这个函数,将导出的对象都搬到 exports 中去,原理是 Object.defineProperty

/******/  // define getter functions for harmony 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__.r

__webpack_require__.r 是 esModule 模块用的包装函数,标记这个模块是 esModule 语法的,commonJs 模块不会调用这个方法

/******/  /* webpack/runtime/make namespace object */
/******/  // 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 });
/******/  };

函数 __webpack_require__.n

__webpack_require__.n 是默认导入模块时调用的函数,如果引入的模块是 esModule 就返回 default,不是就直接返回模块

// getDefaultExport function for compatibility with non-harmony modules
/******/   __webpack_require__.n = (module) => {
/******/    var getter = module && module.__esModule ?
/******/     () => (module['default']) :
/******/     () => (module);
/******/    __webpack_require__.d(getter, { a: getter });
/******/    return getter;
/******/   };

另外,下面的情况会报错

  • 使用 require 引入只有默认导出的 esModule 模块

因为这种情况 webpack 是不会调用 __webpack_require__.n 的,但其实导出的变量实际上在 export.default 上,没有调用 __webpack_require__.n 会导致拿不到变量

webpack 对 esModule 和 commonJs 模块的处理

对于 commonJs 规范的,webpack 基本不做处理,直接将代码搬运过来

// commonJs 规范的模块
const name = 'cjs';
module.exports = {
  log() {
    console.log(name);
  }
}

// 编译后的代码
var __webpack_modules__ = ({

/***/ "./src/esm-and-cjs/cjs.js":
/*!********************************!*\
  !*** ./src/esm-and-cjs/cjs.js ***!
  \********************************/
/***/ ((module) => {
        const name = 'cjs';
        module.exports = {
          log() {
            console.log(name);
          }
        }
/***/ })

})

对于 esModule 规范的,通过 __webpack_require__.r 打上标记,设置 exports._esModule = true,__webpack_require__.d 将模块的导出的变量做一层代理,将 export 导出的变量搬到 exports 中去
值得注意的是,这里使用了 Object.defineProperty(exports, key, { enumerable: true, get: definition[key] }) 方法做了代理,所有 exports 中变量的读写都代理到 definition 上去,通过这种方法对齐 esModule 强绑定的标准

// esModule 模块
export const name = 'esm';
export function log() {
  console.log(name);
}


// 编译后的代码 
var __webpack_modules__ = ({

/***/ "./src/esm-and-cjs/esm.js":
/*!********************************!*\
  !*** ./src/esm-and-cjs/esm.js ***!
  \********************************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */   "name": () => (/* binding */ name),
/* harmony export */   "log": () => (/* binding */ log)
/* harmony export */ });
const name = 'esm';
function log() {
  console.log(name);
}
/***/ })
})

webpack 对 esModule 的导出差异的处理

esModule 导出有两种,一种是直接 export,另一种是默认导出 export default,乍看之下只是语法上的区别,但其实不然

export default 和 export 的差异

export default 和 export 导出的表现不一样,这种差异如下
示例一:esm-without-default.js 使用 export 导出

export let counter = 1;
export function add() {
  counter++;
  return counter;
}

示例二:esm-with-default.js 使用 export default 导出

let counter = 1;
function add() {
  counter++;
  return counter;
}
export default {
  counter,
  add
}

接着我们调用两个打印并调用两个示例中的 add 方法

import esmWithoutDefault from './esm-without-default.js';
import esmWithDefault from './esm-with-default.js';

console.log('esmWithDefault 的行为:');
console.log(esmWithDefault.counter);      // 1
console.log(esmWithDefault.add());        // 2
console.log(esmWithDefault.counter);      // 1

console.log('esmWithoutDefault 的行为:');
console.log(esmWithoutDefault.counter);   // 1
console.log(esmWithoutDefault.add());     // 2
console.log(esmWithoutDefault.counter);   // 1

可以看出 export default 导出的变量并不是强绑定,即类似函数传参的形式

那 webpack 是如何处理这种差异呢?我们看看下面的代码

webpack 对 esModule 普通导出的处理

示例一:esm-without-default.js 编译后的代码

/***/ "./src/compare-export-default/esm-without-default.js":
/*!***********************************************************!*\
  !*** ./src/compare-export-default/esm-without-default.js ***!
  \***********************************************************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

__webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */   "counter": () => (/* binding */ counter),
/* harmony export */   "add": () => (/* binding */ add)
/* harmony export */ });
let counter = 1;
function add() {
  counter++;
  return counter;
}

/***/ })

对于 export 的变量,是直接挂载在 exports 中的,调用 add 方法,改变了局部变量 counter,读取时通过代理找到了该局部变量,所以是强绑定

webpack 对 esModule 默认导出的处理

示例二:esm-with-default.js 编译后的代码

/***/ "./src/compare-export-default/esm-with-default.js":
/*!********************************************************!*\
  !*** ./src/compare-export-default/esm-with-default.js ***!
  \********************************************************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

__webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */   "default": () => (__WEBPACK_DEFAULT_EXPORT__)
/* harmony export */ });
let counter = 1;
function add() {
  counter++;
  return counter;
}
/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = ({
  counter,
  add
});

/***/ })

可以看到,对于 export default,将导出的变量放在局部变量 __WEBPACK_DEFAULT_EXPORT__ 中,并代理了 exports.default 这个中间变量,从代码上很明显看出,调用 add 方法并不是直接改变 __WEBPACK_DEFAULT_EXPORT__.counter,而是改变了局部变量 counter,由于是简单类型,这两者并不是指向同一内存区域

webpack 对 esModule 默认导入的处理

esModule 引入模块有两种形式

下面的情况,引入时 webpack 读取的是 exports 变量

import { log } from 'a';
log();
// 或者
import * as a from 'a';
a.log();

默认导入,webpack 通过上文提到的 __webpack_require__.n 读取 exports.default 变量

import a from 'a';
a.log();

当一个模块同时有默认导出和普通导出时,webpack 的处理原则是,默认导入的读取默认导出的变量(exports.default),普通导出的读取普通导出的变量(exports),如下

// esm.js 模块
export function log() {
  console.log(1);
}
export default {
  log() {
    console.log(2)
  }
}

// index.js 引入 esm.js
import * as esm from './esm';
import esmDefaultImport from './esm'

esm.log();  // 1
esmDefaultImport.log(); // 2

另外,如果默认导入一个没有默认导出的模块,会报错

拓展

为什么有时候 import xxx from 'xxx' 会报错

这个报错只会出现在以下情况:

  1. 使用默认导入的方式去引入没有默认导出的 esModule 模块

解决方法:import * as xxx from 'xxx'

export default 的问题

export default 在过去其实是有坑的,因为可能会跟对象解构的语法冲突

// esm.js
export default {
  log() {
    console.log(2);
  }
}

// index.js 引入时解构
import { log } from './esm';
log();

这种情况可以有两种理解

  1. 单纯只是引入普通导入,不会解构
  2. 在引入的时候结构了默认引入,即
import esm from './esm'
const { log } = esm;
log();

按照 esModule 的标准,结果应该是 1,调用 log 会报错,但是用 babel5 却能正确输出
不过,在实际测试中,webpack 5中这样使用也会报错
鉴于历史,我们还是要注意一下规范编码

总结

了解 webpack 的模块系统,对我们平时的编码习惯也有所启发,使用 esModule 时,也要注意以下几点

  • 不要在一个模块同时使用普通导出和默认导出
  • 默认导出和默认导入,普通导入和普通导入,两者要一一对应
  • 如果一个文件有多个导出时,使用普通导出,如函数库
  • 如果一个文件只有单个导出时,可以使用默认导出,如 class、组件、配置项
  • esModule 默认导出不是强绑定,这个和 commonJs 是一样的,普通导出则是强绑定

参考