Skip to content

ES Module vs CommonJS

作者:青见春山

面试准备中关于模块化的知识点整理,主要是 ES Module 与 CommonJS 的对比。


语法差异

在 CommonJS 中,每个模块内部有一个 module 对象,其中 module.exports 是真正用于导出的对象。而 exports 只是 module.exports 的一个 初始引用(别名)

导出(Export)

JavaScript
// CommonJS
module.exports = { foo, bar };  //整体替换module.exports
// 或
exports.foo = foo; 本质上等于:  module.exports.foo = foo;

// ES Module
export { foo, bar };
export default foo;

// moduleA.js
exports.foo = 1;
exports.bar = 2;
// 导出结果: { foo: 1, bar: 2 }

// moduleB.js
exports.foo = 1;
module.exports = { bar: 2 };
// 导出结果: { bar: 2 } ← foo 丢失了!因为 module.exports 被整体替换

导入(Import)

JavaScript
// CommonJS — 运行时同步加载
const { foo, bar } = require('./module');
const myModule = require('./module');

// ES Module — 静态分析,编译时加载
import { foo, bar } from './module.js';
import myModule from './module.js';
import fs from 'fs';         // 命名导入
import('dynamic-module');     // 动态导入(异步)

"运行时同步加载" vs "静态分析,编译时加载"

1. CommonJS — 运行时同步加载

"运行时"指的是代码真正被执行的时候,"同步"指的是 require()阻塞后续代码执行,直到模块加载完毕。

JavaScript
// a.js
console.log('a.js 开始执行');

const b = require('./b');  // ← 到这里,暂停 a.js,去执行 b.js
                           //    b.js 全部执行完后,拿到导出值,再继续

console.log('a.js 继续执行');

// -----

// b.js
console.log('b.js 开始执行');
module.exports = { name: 'moduleB' };
console.log('b.js 执行完毕');

执行 node a.js,输出顺序:

Plain
a.js 开始执行
b.js 开始执行
b.js 执行完毕
a.js 继续执行

关键点require() 本身就是一个普通的函数调用,它在代码运行到那一行时才去读取文件、解析、执行模块。这就是"运行时加载"。

而且 require() 是一个普通函数,所以它可以放在任何地方:

JavaScript
// 可以放在 if 里
if (process.env.NODE_ENV === 'production') {
  const logger = require('./logger.prod');
}

// 可以放在函数里
function init() {
  const db = require('./database');
}

// 可以放在循环里(虽然不推荐)
for (let i = 0; i < 3; i++) {
  const mod = require('./module');
}

因为这一切都发生在运行时,JavaScript 引擎只需要从上到下执行代码就能搞定。


2. ES Module — 静态分析,编译时加载

"静态分析"指的是不运行代码,仅通过阅读代码结构(语法层面)就能分析出模块的依赖关系。"编译时"指的是这些分析发生在代码真正执行之前

ES Module 的执行分为三个阶段:

Plain
阶段1: 构建(Build)—— 找到所有 import/export,确定模块依赖图
阶段2: 实例化(Link)—— 创建模块实例,建立 import 和 export 的绑定关系
阶段3: 执行(Execute)—— 逐个执行模块代码,填充绑定的值

用代码来说明:

JavaScript
// main.js
import { add } from './math.js';
import { name } from './utils.js';

console.log(add(1, 2));
console.log(name);
JavaScript
// math.js
export function add(a, b) { return a + b; }
JavaScript
// utils.js
export const name = 'hello';

浏览器/Node.js 处理过程

Plain
阶段1(构建):
  扫描 main.js → 发现 import { add } from './math.js'
               → 发现 import { name } from './utils.js'
  扫描 math.js → 发现 export function add
  扫描 utils.js → 发现 export const name
  → 得到完整的依赖图(还没执行任何代码!)

阶段2(实例化):
  为每个模块创建模块记录(Module Record)
  在内存中分配空间,把 import 和 export "连接"起来
  → 此时 add 和 name 都是 undefined(还没执行)

阶段3(执行):
  执行 math.js → add 函数被定义
  执行 utils.js → name = 'hello'
  执行 main.js → 调用 add(1, 2),打印结果

这就是"编译时加载"的含义:依赖关系在代码执行之前就已经确定了。


3. 为什么"静态分析"很重要?

正是因为 ES Module 的依赖在编译时就已确定,才能实现以下关键特性:

① Tree Shaking(摇树优化)

JavaScript
// utils.js
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }

// main.js
import { add } from './utils.js';
console.log(add(1, 2));

打包工具(如 Webpack、Rollup)可以静态分析subtract 没有被导入使用,于是将其安全删除,减小包体积。

CommonJS 做不到这一点,因为 require() 是运行时的函数调用:

JavaScript
// 打包工具无法确定 require('./utils') 会用到什么
const utils = require('./utils');
utils.add(1, 2);  // 也许还调用了 utils.subtract?静态分析无法确定

② 编译时报错

JavaScript
// ❌ ES Module — 编译时就能发现错误
import { nonExistent } from './module.js'; // SyntaxError: 模块没有这个导出

// CommonJS — 运行时才能发现问题
const { nonExistent } = require('./module'); // nonExistent = undefined(静默失败)

③ 不能用条件语句包裹 import

JavaScript
// ❌ 报错!因为编译阶段就需要确定所有依赖
if (condition) {
  import { foo } from './module.js';  // SyntaxError!
}

// ✅ CommonJS 可以,因为 require 是运行时的普通函数
if (condition) {
  const { foo } = require('./module');  // 完全合法
}

4. 一句话总结

CommonJSES Module
类比即时翻译:读到 require() 那一行才去加载模块提前做好翻译:先把所有 import 看完,整理好依赖关系,再开始执行
分析时机运行时(执行代码时)编译时(执行代码前)
加载方式同步阻塞(一个一个来)提前构建依赖图,按依赖顺序执行

核心机制差异

3.1 加载方式

CommonJSES Module
加载时机运行时(Runtime)加载编译时(Parse/Link 阶段)静态分析
加载方式同步加载(require 是同步的)异步加载(浏览器中)/ 同步(Node.js 内置模块)
条件判断可以在 if 语句中使用 requireimport 必须在顶层,不能放在条件语句中
JavaScript
// CommonJS — 可以在条件中 require
if (process.env.NODE_ENV === 'production') {
  const logger = require('./logger.prod');
} else {
  const logger = require('./logger.dev');
}

// ES Module — import 必须在顶层
// ❌ 以下写法不合法
if (someCondition) {
  import { foo } from './module.js';
}

// ✅ 替代方案:动态 import()
if (someCondition) {
  const { foo } = await import('./module.js');
}

3.2 值的传递

这是两者最关键的区别之一

JavaScript
// ========== CommonJS:值的拷贝(Copy) ==========
// counter.js
let count = 0;
module.exports = { count, increment: () => count++ };

// main.js
const counter = require('./counter');
console.log(counter.count); // 0
counter.increment();
console.log(counter.count); // 0 ← 还是0!因为拷贝了原始值
JavaScript
// ========== ES Module:值的引用(Live Binding) ==========
// counter.js
export let count = 0;
export function increment() { count++; }

// main.js
import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 1 ← 值变了!因为是活绑定

关键总结:CommonJS 导出的是值的拷贝,导入后模块内部的变化不会反映到外部;ES Module 导出的是值的活引用(Live Binding),模块内部修改导出值后,外部能实时感知。

CommonJS 中让外部感知模块内变化

可以实现,但需要一些技巧。 CommonJS 本身是值拷贝,没有活绑定,但有几种变通方案:


方案一:用函数/闭包代理(最常用)

既然函数是引用类型,通过闭包可以访问内部变量,那就用函数来充当"中介":

JavaScript
// counter.js
let count = 0;

module.exports = {
  getCount: () => count,
  increment: () => count++,
  decrement: () => count--
};
JavaScript
// main.js
const counter = require('./counter');

counter.increment();
counter.increment();
console.log(counter.getCount()); // 2 ✅ 拿到了最新值
counter.increment();
console.log(counter.getCount()); // 3 ✅

每次调用 getCount() 时,函数内部通过闭包访问的是模块内部count,所以永远是最新值。

缺点:每次读取都要调用函数,不能直接用 .count 属性。


方案二:用对象包装基本类型

把基本类型包装成对象,因为对象的属性修改是对同一个引用的修改:

JavaScript
// counter.js
const state = { count: 0 };

module.exports = {
  state,
  increment: () => state.count++,
};
JavaScript
// main.js
const counter = require('./counter');

console.log(counter.state.count); // 0
counter.increment();
console.log(counter.state.count); // 1 ✅ 感知到了变化
counter.increment();
console.log(counter.state.count); // 2 ✅

原理state 是对象,require 拿到的是同一个对象引用。修改 state.count 等于修改同一个对象的属性,所有引用者都能看到变化。

缺点:需要额外包一层对象,代码不够直观。


总结

CommonJS 可以实现让外部感知变化,核心思路就是不要导出基本类型的值,而是导出能间接访问内部状态的函数或对象引用。但如果项目允许,直接用 ES Module 是最简单、最自然的选择。

3.3 this 的指向

JavaScript
// CommonJS — 顶层 this 是 module.exports
console.log(this === module.exports); // true

// ES Module — 顶层 this 是 undefined
console.log(this); // undefined

4. 模块缓存与循环引用

4.1 缓存机制

两者都有缓存机制,require / import 同一模块不会重复执行:

JavaScript
// CommonJS — 缓存的是 module.exports 的值的拷贝
const a = require('./module');
const b = require('./module');
console.log(a === b); // true(引用同一个缓存对象)

4.2 循环引用

JavaScript
// CommonJS — 循环引用时,只能拿到已经执行完毕的部分
// a.js
exports.done = false;
const b = require('./b');
console.log('a.js 中 b.done =', b.done); // false
exports.done = true;
console.log('a.js 完成');

// b.js
exports.done = false;
const a = require('./a');
console.log('b.js 中 a.done =', a.done); // false (只拿到 a.js 已导出的部分)
exports.done = true;
console.log('b.js 完成');
JavaScript
// ES Module — 通过 Live Binding,循环引用可以拿到最终值
// a.js
import { bar } from './b.js';
export const foo = bar; // 此时 bar 还是初始值
setTimeout(() => console.log('a.js 中 bar =', bar)); // 最终值

// b.js
export let bar = 'initial';
import { foo } from './a.js';
console.log('b.js 中 foo =', foo); // undefined(TDZ 或初始值)
bar = 'updated';


a.js 开始 → 遇到 import 要去 b.js → b.js 执行:
  bar 赋值为 initial → 要导入 a.js 的 foo,但 a.js 还没给 foo 赋值 → foo 是 undefined
  → 打印 undefined → 把 bar 改成 updated → b.js 结束
→ 回到 a.js → bar 已经是 updated 了 → foo = updated → 结束
→ 定时器触发 → bar 还是 updated → 打印 updated

5. Node.js 中的兼容性

.mjs 和 .cjs 就是 强制声明模块类型的后缀 。 .mjs = 无论怎样都当 ESM, .cjs = 无论怎样都当 CJS。它们存在的意义是: 让你在同一个项目里可以同时使用两种模块系统,而不会产生歧义 。

Node.js 同时支持两种模块系统,通过文件扩展名package.json** 中的 **type** 字段**来区分:

JSON
// package.json
{
  "type": "module"   // 使用 ES Module
  // 或 "commonjs"(默认值)
}
文件扩展名type 未设置或 "commonjs"type: "module"
.jsCommonJSES Module
.mjsES ModuleES Module
.cjsCommonJSCommonJS

文件扩展名的选择

你想写 CommonJS?

├── 项目默认是 CommonJS? → 用 .js 或 .cjs 都行

└── 项目默认是 ESM? → 必须用 .cjs(否则 .js 会被当 ESM)

你想写 ES Module?

├── 项目默认是 ESM? → 用 .js 或 .mjs 都行

└── 项目默认是 CommonJS? → 必须用 .mjs(否则 .js 会被当 CJS)

CJS 中使用 ESM 特性

JavaScript
// CommonJS 中可以使用动态 import()(返回 Promise)
async function load() {
  const { default: lodash } = await import('lodash-es');
}

ESM 中使用 CJS 模块

JavaScript
// ES Module 中可以用 default import 导入 CJS 模块
import lodash from 'lodash';  // ✅
// 但不支持命名导入(除非 Node.js 能静态分析)
import { debounce } from 'lodash';  // ❌ 可能失败

6. 浏览器支持

CommonJSES Module
浏览器原生支持❌ 不支持✅ 原生支持
使用方式需要打包工具(Webpack、Rollup 等)转换<script type="module">
HTTP 请求不适用每个 import 发起独立 HTTP 请求
HTML
<!-- ES Module 在浏览器中 -->
<script type="module">
  import { foo } from './module.js';
  console.log(foo);
</script>

<!-- CommonJS 需要打包后才能在浏览器运行 -->
<script src="bundle.js"></script>

7. 顶级特性对比总结

特性CommonJSES Module
语法require() / module.exportsimport / export
加载方式运行时、同步编译时、静态分析
值的传递值的拷贝(Copy)活绑定(Live Binding)
this** 值**module.exportsundefined
条件导入✅ 支持❌ 不支持(需用 import()
懒加载天然支持(require 按需加载)需用动态 import()
Tree Shaking❌ 不支持✅ 支持(静态分析可消除死代码)
浏览器原生
异步加载❌(同步阻塞)✅(天然异步)
JSON 导入require('./data.json')assert { type: 'json' } 或 fetch
循环引用可能拿到不完整的导出Live Binding 确保拿到最终值

8. 何时选择哪种?

  • CommonJS:Node.js 服务端项目、需要兼容旧版 Node.js、需要同步 require 和条件加载的场景

  • ES Module:现代前端项目(配合打包工具享受 Tree Shaking)、浏览器原生模块加载、新 Node.js 项目(官方推荐方向)

  • 混合使用:ESM 作为主模块格式,通过 import() 动态加载 CJS 模块

趋势:ES Module 是 JavaScript 模块化的未来标准方向,Node.js 官方也推荐新项目使用 ESM。但 CommonJS 在生态系统中仍然广泛存在,短期内两者会持续共存。