CommonJS 与 ES Modules的区别

0 阅读7分钟

在前端工程化的演进长河中,模块化规范的变迁是理解 JavaScript 运行机制的关键一环。对于资深开发者而言,CommonJS(简称 CJS)与 ES Modules(简称 ESM)不仅仅是语法的区别,更代表了 JavaScript 在服务端与浏览器端不同运行环境下的架构哲学。

本文将从底层原理出发,剖析这两大规范的核心差异,并结合 Node.js 的最新特性,探讨工程化场景下的互操作性方案。

一、模块化的前世今生

在 ES6 之前,JavaScript 语言层面并没有内置的模块体系。这导致早期的大型项目开发极易陷入全局作用域污染、依赖关系混乱(Dependency Hell)的泥潭。为了解决这一痛点,社区涌现出了多种解决方案。

CommonJS 应运而生,它主要面向服务器端(Node.js)。由于服务器端的文件存储在本地硬盘,读取速度极快,因此 CommonJS 采用了同步加载的设计。这一规范迅速确立了 Node.js 生态的统治地位。

然而,随着前端应用的日益复杂,浏览器端急需一种标准化的模块体系。ES6(ECMAScript 2015)正式推出了 ES Modules。作为官方标准,ESM 旨在统一浏览器和服务器的模块规范,凭借其静态编译和异步加载的特性,逐渐成为现代前端构建工具(如 Vite, Webpack, Rollup)的首选。

二、两大规范的运行机制与特点

1. CommonJS (CJS)

定位:服务器端模块规范,Node.js 的默认模块系统。

核心特点

  • 运行时加载:模块在代码执行阶段才被加载。
  • 同步加载:代码按照编写顺序同步执行,阻塞后续代码直至模块加载完成。
  • 值的拷贝:导出的是值的副本(对于基本数据类型)。

代码示例

JavaScript

// 导出:module.exports
const obj = { a: 1 };
module.exports = obj;

// 引入:require
const obj = require('./test.js');

2. ES Modules (ESM)

定位:ECMAScript 官方标准,旨在实现浏览器与服务端的通用。

核心特点

  • 编译时输出接口:在代码解析阶段(编译时)即可确定依赖关系。
  • 异步加载:支持异步加载机制,适应网络请求环境。
  • 值的引用:导出的是值的动态映射(Live Binding)。

代码示例

JavaScript

// 导出:export
export const obj = { name: 'ESM' };
export default { name: 'Default' };

// 引入:import
import { obj } from './test.js';
import defaultObj from './test.js';

三、深度解析——核心差异

如果要深入理解两者的区别,必须从输出机制、加载时机和加载方式三个维度进行剖析。

1. 输出值的机制:值的拷贝 vs 值的引用

这是 CJS 与 ESM 最本质的区别,也是面试中最高频的考察点。

  • CommonJS:值的拷贝
    CJS 模块输出的是一个对象,该对象在脚本运行完后生成。一旦输出,模块内部的变化就无法影响到这个值(除非导出的是引用类型对象且修改了其属性,这里特指基本数据类型或引用的替换)。
  • ES Modules:值的引用
    ESM 模块通过 export 导出的是一个静态接口。import 导入的变量仅仅是一个指向被导出模块内部变量的“指针”。如果模块内部修改了该变量,外部导入的地方也会感知到变化。

代码演示:

场景:我们定义一个 age 变量和一个自增函数 addAge。

CommonJS 实现:

JavaScript

// lib.js
let age = 18;
module.exports = {
  age,
  addAge: function () {
    age++;
  },
};

// main.js
const { age, addAge } = require('./lib.js');
console.log(age); // 18
addAge();
console.log(age); // 18 (注意:这里依然是 18,因为导出的是 age 变量在导出时刻的拷贝)

ES Modules 实现:

JavaScript

// lib.mjs
export let age = 18;
export function addAge() {
  age++;
}

// main.mjs
import { age, addAge } from './lib.mjs';
console.log(age); // 18
addAge();
console.log(age); // 19 (注意:这里变成了 19,因为 import 获取的是实时的绑定)

技术延伸
由于 ESM 是实时引用,它能更好地处理循环依赖问题。在 ESM 中,只要引用存在,代码就能执行(尽管可能在暂时性死区 TDZ 中);而在 CJS 中,循环依赖可能导致导出一个不完整的对象(空对象),因为模块可能尚未执行完毕。此外,ESM 导入的变量是只读的(Read-only),尝试在 main.mjs 中直接执行 age = 20 会抛出 TypeError。

2. 加载时机:运行时 vs 编译时

  • CommonJS (运行时)
    require 本质上是一个函数。你可以将它放在 if 语句中,或者根据变量动态生成路径。只有当代码执行到这一行时,Node.js 才会去加载模块。

    JavaScript

    if (condition) {
      const lib = require('./lib.js'); // 条件加载
    }
    
  • ES Modules (编译时)
    import 语句(静态导入)必须位于模块顶层,不能嵌套在代码块中。JavaScript 引擎在编译阶段(解析 AST 时)就能确定模块的依赖关系。
    工程化价值:这使得 Tree Shaking(摇树优化)  成为可能。构建工具可以在打包时静态分析出哪些 export 没有被使用,从而安全地删除这些死代码,减小包体积。

3. 加载方式:同步 vs 异步

  • CommonJS (同步)
    主要用于服务器端。文件都在本地磁盘,读取时间通常在毫秒级,同步加载不会造成明显的性能瓶颈。
  • ES Modules (异步)
    设计之初就考虑了浏览器环境。在浏览器中,模块需要通过网络请求加载,网络延迟不可控。如果采用同步加载,会阻塞主线程,导致页面“假死”无法交互。因此,ESM 规范规定模块解析阶段是异步的。

四、工程化实践与互操作性

在 Node.js 环境逐步过渡到 ESM 的过程中,两者共存的情况十分常见。

1. 文件后缀与配置

在 Node.js 中,为了区分模块类型:

  • CommonJS:通常使用 .cjs 后缀,或者在 package.json 中未设置 type 字段(默认为 CJS)。
  • ES Modules:强制使用 .mjs 后缀,或者在 package.json 中设置 "type": "module"。

2. 相互引用(Interoperability)

这是开发中最容易踩坑的地方。

场景 A:CommonJS 引用 ES Modules

由于 CJS 是同步的 require,而 ESM 是异步加载的,因此原生 CJS 无法直接 require ESM 文件

  • 常规方案:使用异步的动态导入 import() 配合 IIFE。

    JavaScript

    // index.cjs
    (async () => {
      const { default: foo } = await import('./foo.mjs');
    })();
    
  • 新特性(Node.js v22+ / Experimental)
    Node.js 在 2024 年推出了 --experimental-require-module 标志。开启后,支持同步 require 加载 ESM(前提是该 ESM 模块内部没有顶级 await)。

    Bash

    node --experimental-require-module index.cjs
    

场景 B:ES Modules 引用 CommonJS

ESM 的兼容性较好,可以导入 CJS 模块。

  • 机制:Node.js 会将 CJS 的 module.exports 整体作为一个默认导出(Default Export)处理。

  • 注意事项不支持具名导入(Named Imports)的直接解构。虽然部分构建工具(如 Webpack)支持混用,但在原生 Node.js 环境下,以下写法通常会报错或表现不符合预期:

    JavaScript

    // 错误示范 (原生 Node.js)
    import { someMethod } from './lib.cjs'; // 可能会失败,因为 CJS 只有 default 导出
    

    正确写法

    JavaScript

    import lib from './lib.cjs';
    const { someMethod } = lib;
    

五、面试场景复盘

面试官提问:“请聊聊 CommonJS 和 ESM 的区别。”

高分回答策略

1. 一句话定性(宏观视角)
“CommonJS 是 Node.js 社区提出的服务器端运行时模块规范,主要特点是同步加载值的拷贝;而 ES Modules 是 ECMAScript 的官方标准,实现了浏览器和服务端的统一,主要特点是编译时静态分析异步加载值的引用。”

2. 核心差异展开(技术深度)
“两者最本质的区别在于输出值的机制
CommonJS 输出的是值的拷贝。一旦模块输出,内部变量的变化不会影响导出值,类似于基本类型的赋值。
ES Modules 输出的是值的引用(Live Binding) 。导入的变量实际上是指向模块内部内存地址的指针,模块内部变化会实时反映到外部,这使得 ESM 能更好地处理循环依赖问题。”

3. 工程化价值(架构视角)
“在工程实践中,ESM 的静态编译特性非常关键。因为它允许构建工具在代码运行前分析依赖关系,从而实现 Tree Shaking,去除无用代码,优化包体积。这是 CommonJS 这种动态加载规范无法做到的。”

4. 兼容性补充(实战经验)
“在 Node.js 环境中,两者互操作需要注意。ESM 可以较容易地导入 CJS(作为默认导出),但 CJS 导入 ESM 通常需要异步的 import()。不过,Node.js 最近引入了 --experimental-require-module 标志,正尝试打破这一同步加载的壁垒。”