深入剖析 CommonJS 与 ES Module 模块导出区别

163 阅读3分钟

深入剖析 CommonJS 与 ES Module 模块导出区别

在 JavaScript 的世界里,准确理解 CommonJS 和 ES Module 的模块导出特性,对于开发者而言至关重要。近期,相关讨论引发了深入思考,让我们重新审视二者在值传递上的本质差异。

一直以来,流行着这样一种说法:CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。然而,早期用于证明 CommonJS 模块导出值特性的例程,大多存在瑕疵。以常见的示例来看:

在 b.js 中:

let count = 1;
module.exports = {
  count,
  add() {
    count++;
  },
  get() {
    return count;
  }
};

a.js 引入并使用:

const { count, add, get } = require('./b');
console.log(count);    // 1
add();
console.log(count);    // 1
console.log(get());    // 2

表面上,有人认为此例能说明 CommonJS 特性,但仔细分析,由于 count 变量是基本类型,按值传递,module.exports 中的 count 属性只是获取了初始值的拷贝,后续 count 变量变化与导出的 count 属性毫无关联,所以这个例程并不能严谨地证明 CommonJS 模块导出是值的拷贝。

为求严谨,我们不妨从不同视角来论证。先看 Webpack 构建 CommonJS 模块的情况。假设有如下模块:

a.js:

const b = require('./b');
console.log(b.count);

b.js:

module.exports = {
  count: 1,
};

Webpack 输出的 bundle(省去注释和部分无关代码)如下:

(function(modules) {
  // webpackBootstrap
  //...
  // webpack实现的require函数
  function __webpack_require__(moduleId) {
    if(installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }
    // 模块缓存id、加载状态和导出值
    var module = installedModules[moduleId] = {
      i: moduleId,
      l: false,
      exports: {}    // 关键点:模块导出预置了一个空对象
    };
    // 模块代码执行
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    module.l = true;
    return module.exports;
  }
  //...
  return __webpack_require__(__webpack_require__.s = 0);
})([
  // a.js
  (function(module, exports, __webpack_require__) {
    const b = __webpack_require__(1);
    console.log(b.count);
  }),
  // b.js
  (function(module, exports) {
    module.exports = {
      count: 1,
    };
  })
])

从编译后的 bundle 清晰可见,CommonJS 模块导出在 Webpack 这里,实际是对 installedModules[moduleId].exports 属性的赋值操作。当在预置的 installedModules[moduleId].exports 空对象上新增一个基本类型的 count 属性,如同基本类型的拷贝;若 installedModules[moduleId].exports 被赋值一个新的包含 count 属性的对象,就相当于对象浅拷贝。这无疑证明了 CommonJS 模块导出的是值的拷贝。

再从 Node 底层源码角度出发,在 Node 中,当执行 require 操作引入模块时,模块代码被包裹在一个函数中执行,其 exports 对象负责收集对外暴露的值。此过程与 Webpack 处理相似,一旦模块内的变量要作为导出值,不管是基本类型赋值给 exports 的某个属性,还是复杂对象整体赋值给 module.exports,均为单向传递,原始变量后续变化不会反馈到已导出的 “副本” 上。

反观 ES Module,以如下示例说明:

b.mjs:

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

a.mjs:

import { count, add, get } from './b.mjs';
console.log(count);    // 1
add();
console.log(count);    // 2
console.log(get());    // 2

这里使用 export let 这种声明式导出,模块内变量与导出值紧密绑定,共享内存空间,只要其中一处修改,便能实时反映。这不仅在语法上直观呈现,在引擎底层实现时,基于静态模块结构,模块间的引用关系在解析阶段就已确定,有力保障了引用的一致性与实时性。

综上所述,通过源码分析以及构建工具如 Webpack 的视角,我们已然确凿地证明:CommonJS 模块导出是值的拷贝,ES Module 是值的引用。这一认知为我们深入理解 JavaScript 模块机制奠定了坚实基础,助力开发者在面对复杂项目的模块组织、跨模块优化等场景时,做出更明智的决策。若后续在源码剖析中有全新发现,或是期望进一步探讨它们在更多复杂场景下的应用,不妨继续深入挖掘,持续拓展知识边界。