跳到主要内容
预计阅读 45 分钟

Javascript篇 | ESM与CJS

前言

“ES Module 和 CommonJS 有什么区别?“——这道题在面试中出现的频率越来越高,因为模块化已经是现代前端工程的基础设施。

但很多同学对模块化的理解停留在”import/export 就是 ESM,require 就是 CJS”的层面。面试官一追问就露馅了:

  • “ESM 和 CJS 对循环引用的处理有什么不同?”
  • “为什么 Tree Shaking 依赖 ESM?”
  • “ESM 的实时绑定和 CJS 的值拷贝是什么意思?”

模块化不只是语法层面的事,它涉及到加载机制、执行时机、变量绑定等底层设计。搞懂这些,不仅能应对面试,还能帮你理解打包工具(Webpack、Vite、Rollup)的核心原理。

诊断自测

在开始之前,试着回答以下问题:

1. 以下 CJS 代码输出什么?

// counter.js
let count = 0;
function increment() { count++; }
module.exports = { count, increment };

// main.js
const { count, increment } = require('./counter');
increment();
increment();
console.log(count);

2. 以下 ESM 代码输出什么?

// counter.mjs
export let count = 0;
export function increment() { count++; }

// main.mjs
import { count, increment } from './counter.mjs';
increment();
increment();
console.log(count);

3. 为什么 CommonJS 不能做 Tree Shaking?

点击查看答案

第1题: 输出 0。CJS 导出的是值的拷贝main.js 拿到的 count 是导出时刻的值(0)。之后无论 increment 怎么修改 counter.js 内部的 count,main.js 的 count 都不会变。

第2题: 输出 2。ESM 导出的是实时绑定(live binding)main.mjscount 始终指向 counter.mjs 中的那个 count 变量。increment() 修改了源头的 count,import 端自然也变了。

第3题: CJS 的 require 是动态的,可以出现在任何位置(if 里、函数里),而且导出的内容可以是运行时才确定的。打包工具在静态分析阶段无法确定哪些代码被用到了。ESM 的 import/export 是静态声明,必须在模块顶层,打包工具在编译时就能确定依赖关系并移除未使用的代码。

如果三道题都答对了,说明你对模块化理解很深!继续阅读可以补充更多细节。

IIFE:模块化的史前时代

在讲 CJS 和 ESM 之前,我们先聊聊 JavaScript 模块化的”远古历史”,这能帮你理解为什么后来要发明模块系统。

早期的问题:全局污染

// utils.js
var name = 'Utils';
function formatDate(date) { /* ... */ }
function parseUrl(url) { /* ... */ }

// app.js
var name = 'App'; // 💥 覆盖了 utils.js 的 name
formatDate(new Date()); // 能用,但不知道从哪来的

所有脚本共享全局作用域,变量名冲突、依赖关系不明确、加载顺序敏感——这些问题在大型项目中简直是灾难。

IIFE 模式

聪明的开发者用**立即执行函数表达式(IIFE)**来模拟模块:

var MyModule = (function() {
  // 私有变量
  var count = 0;
  var name = 'MyModule';

  // 私有函数
  function log(msg) {
    console.log(`[${name}] ${msg}`);
  }

  // 公共 API
  return {
    increment: function() {
      count++;
      log('count = ' + count);
    },
    getCount: function() {
      return count;
    }
  };
})();

MyModule.increment(); // [MyModule] count = 1
MyModule.getCount();  // 1
console.log(MyModule.count); // undefined(私有变量)

IIFE 利用函数作用域实现了封装,但仍然有问题:

  • 模块之间的依赖关系不明确
  • 没有标准化的加载机制
  • 大型项目中模块管理困难

后来又出现了 AMD(RequireJS)、UMD 等方案,但都不是语言标准。直到 CommonJS 和 ES Module 的出现,模块化才有了官方解决方案。

CommonJS(CJS)

基本语法

CommonJS 是 Node.js 采用的模块系统:

// 导出
// math.js
const PI = 3.14159;

function add(a, b) {
  return a + b;
}

function multiply(a, b) {
  return a * b;
}

module.exports = { PI, add, multiply };
// 或者逐个导出
// exports.PI = PI;
// exports.add = add;
// 导入
// app.js
const math = require('./math');
console.log(math.PI);       // 3.14159
console.log(math.add(1, 2)); // 3

// 解构导入
const { add, multiply } = require('./math');

核心特性

1. 同步加载

require 是同步的,调用时会立即加载并执行模块文件:

console.log('before');
const fs = require('fs');  // 同步加载,阻塞后续代码
console.log('after');

// 输出:before → after(fs 加载完成后才输出 after)

这在服务端(Node.js)没问题,因为文件在本地磁盘上,读取很快。但在浏览器中就不行了——从网络加载模块如果用同步方式,页面会”卡死”。

2. 值拷贝

CJS 导出的是值的拷贝,而不是引用:

// counter.js
let count = 0;

function increment() {
  count++;
  console.log('内部 count:', count);
}

module.exports = { count, increment };
// main.js
const mod = require('./counter');

mod.increment(); // 内部 count: 1
mod.increment(); // 内部 count: 2
console.log(mod.count); // 0 ← 还是 0!

module.exports = { count, increment } 相当于 module.exports = { count: 0, increment: [Function] }。导出的 count 是数字 0 的拷贝,后续内部 count 的变化不会影响已导出的值。

但如果导出的是对象呢?

// state.js
const state = { count: 0 };
function increment() { state.count++; }
module.exports = { state, increment };

// main.js
const { state, increment } = require('./state');
increment();
console.log(state.count); // 1 ← 变了!

因为对象是引用类型,拷贝的是引用。所以修改对象属性能被看到,但如果你重新赋值整个变量就不行了。

3. 动态加载

require 可以出现在任何位置,包括条件判断和函数内部:

// 条件加载
if (process.env.NODE_ENV === 'development') {
  const devTools = require('./devTools');
  devTools.init();
}

// 动态路径
const moduleName = getModuleName();
const mod = require(`./${moduleName}`);

// 循环中加载
for (const name of plugins) {
  const plugin = require(`./plugins/${name}`);
  plugin.install();
}

4. 缓存机制

模块在第一次 require 时执行,之后会被缓存。后续的 require 直接返回缓存结果:

// module.js
console.log('模块被执行了');
module.exports = { value: 42 };

// main.js
const a = require('./module'); // 打印 '模块被执行了'
const b = require('./module'); // 不打印,直接返回缓存
console.log(a === b);         // true

ES Module(ESM)

基本语法

ES Module 是 ECMAScript 标准定义的模块系统:

// 命名导出
// math.mjs
export const PI = 3.14159;
export function add(a, b) { return a + b; }
export function multiply(a, b) { return a * b; }

// 默认导出
// logger.mjs
export default class Logger {
  log(msg) { console.log(msg); }
}
// 命名导入
import { PI, add } from './math.mjs';

// 默认导入
import Logger from './logger.mjs';

// 全部导入
import * as math from './math.mjs';
console.log(math.PI);

// 重命名
import { add as sum } from './math.mjs';

// 同时导入默认和命名
import Logger, { PI } from './combined.mjs';

核心特性

1. 静态分析

ESM 的 import/export 必须在模块顶层,不能出现在条件语句或函数内:

// ✅ 正确:顶层声明
import { add } from './math.mjs';

// ❌ 错误:不能在条件中 import
if (condition) {
  import { add } from './math.mjs'; // SyntaxError
}

// ❌ 错误:不能在函数中 import
function loadModule() {
  import { add } from './math.mjs'; // SyntaxError
}

这个限制是有意为之的——静态声明让打包工具在编译时(不需要执行代码)就能分析出完整的依赖关系。

如果需要动态加载,可以用 import() 函数(注意它返回的是 Promise):

const module = await import(`./locale/${lang}.mjs`);

2. 实时绑定(Live Binding)

ESM 导出的不是值的拷贝,而是对原始变量的实时绑定(只读引用):

// counter.mjs
export let count = 0;
export function increment() { count++; }
// main.mjs
import { count, increment } from './counter.mjs';

console.log(count); // 0
increment();
console.log(count); // 1 ← 能看到变化!
increment();
console.log(count); // 2

// 但不能在导入端修改
count = 10; // TypeError: Assignment to constant variable

import { count } 拿到的不是 count 的值,而是一个”活的连接”,它始终指向 counter.mjs 中的那个 count 变量。但这个连接是只读的,不能在导入端直接修改。

3. 异步加载

浏览器中 ESM 默认是异步加载的:

<!-- 普通 script:同步加载,阻塞渲染 -->
<script src="app.js"></script>

<!-- ESM:异步加载,类似 defer -->
<script type="module" src="app.mjs"></script>

<script type="module"> 默认具有 defer 行为——异步下载,在 DOM 解析完成后按顺序执行。

4. 严格模式

ESM 模块内部自动启用严格模式,不需要写 'use strict'

// 在 ESM 中
this; // undefined(不是 window)
delete Object.prototype; // TypeError(严格模式下不允许)

ESM vs CJS:核心差异对比

特性CommonJSES Module
语法require / module.exportsimport / export
加载方式同步异步
绑定方式值拷贝实时绑定(只读)
分析时机运行时编译时(静态)
模块顶层 thismodule.exportsundefined
动态加载require(expr)import(expr) 返回 Promise
Tree Shaking
循环引用部分支持(拿到已执行部分的快照)完全支持(实时绑定)
使用环境Node.js浏览器 + Node.js

值拷贝 vs 实时绑定:完整对比

// ===== CJS 版本 =====
// lib.cjs
let value = 'initial';
function change() { value = 'changed'; }
module.exports = { value, change };

// main.cjs
const lib = require('./lib.cjs');
console.log(lib.value); // 'initial'
lib.change();
console.log(lib.value); // 'initial' ← 值拷贝,不变

// ===== ESM 版本 =====
// lib.mjs
export let value = 'initial';
export function change() { value = 'changed'; }

// main.mjs
import { value, change } from './lib.mjs';
console.log(value); // 'initial'
change();
console.log(value); // 'changed' ← 实时绑定,同步更新

循环引用的处理差异

循环引用(A 依赖 B,B 又依赖 A)是模块系统必须面对的问题。

CJS 的处理

CJS 对循环引用的处理比较”粗暴”——返回已执行部分的导出对象快照:

// a.cjs
console.log('a.cjs 开始执行');
const b = require('./b.cjs');
console.log('在 a.cjs 中, b.done =', b.done);
exports.done = true;
console.log('a.cjs 执行完毕');

// b.cjs
console.log('b.cjs 开始执行');
const a = require('./a.cjs');
console.log('在 b.cjs 中, a.done =', a.done);
exports.done = true;
console.log('b.cjs 执行完毕');

// main.cjs
const a = require('./a.cjs');
const b = require('./b.cjs');

执行 main.cjs,输出:

a.cjs 开始执行
b.cjs 开始执行
在 b.cjs 中, a.done = undefined    ← a 还没执行到 exports.done = true
b.cjs 执行完毕
在 a.cjs 中, b.done = true
a.cjs 执行完毕

关键:当 b.cjs require('./a.cjs') 时,a.cjs 还在执行中(还没执行到 exports.done = true),所以 b.cjs 拿到的 a.doneundefined。CJS 不会死循环,但你拿到的可能是”不完整”的模块。

ESM 的处理

ESM 利用实时绑定机制,对循环引用的支持更好:

// a.mjs
import { bDone } from './b.mjs';
console.log('在 a.mjs 中, bDone =', bDone);
export const aDone = true;

// b.mjs
import { aDone } from './a.mjs';
console.log('在 b.mjs 中, aDone =', aDone);
export const bDone = true;

ESM 的加载分为三个阶段:

  1. 解析(Parsing):静态分析所有 import/export,建立模块依赖图
  2. 实例化(Instantiation):为所有导出创建绑定(此时变量已存在但未初始化)
  3. 求值(Evaluation):按依赖顺序执行模块代码

在循环引用时,因为绑定在实例化阶段就已建立,所以即使模块还没执行完,导入端拿到的绑定也是”活的”——等到被引用的模块执行完毕后,绑定自然就有值了。

但要注意,如果在模块执行时立即访问还未初始化的绑定,仍然会报错:

// a.mjs
import { b } from './b.mjs';
console.log(b); // 如果此时 b.mjs 还没执行到 export,会报 ReferenceError
export const a = 'a';

// b.mjs
import { a } from './a.mjs';
export const b = 'b';

💡 最佳实践:避免循环引用。如果无法避免,把导入的值放在函数调用中使用(延迟访问),而不是在模块顶层直接使用。

Tree Shaking 为什么依赖 ESM

什么是 Tree Shaking?

Tree Shaking 是打包工具(Webpack、Rollup、Vite)的一种优化手段:移除未使用的代码(dead code elimination)。

// utils.mjs
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }
export function multiply(a, b) { return a * b; }
export function divide(a, b) { return a / b; }

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

// 打包后,subtract、multiply、divide 会被移除

为什么 CJS 不能做 Tree Shaking?

原因一:动态 require 无法静态分析

// CJS —— 打包工具无法确定到底会加载哪个模块
const mod = require(`./${dynamicName}`);

// CJS —— 打包工具无法确定这个 if 是否会执行
if (someCondition) {
  const extra = require('./extra');
}

原因二:导出是一个对象,属性可以动态添加

// CJS —— module.exports 是普通对象,可以动态操作
const obj = {};
if (feature1) obj.a = function() {};
if (feature2) obj.b = function() {};
module.exports = obj;
// 打包工具无法知道最终导出了什么

原因三:require 的结果可以被任意操作

const utils = require('./utils');
const fn = utils[dynamicKey]; // 运行时才知道访问哪个属性
fn();

为什么 ESM 可以做 Tree Shaking?

原因一:静态声明,编译时可分析

// ESM 的 import/export 必须在顶层,路径必须是字符串字面量
import { add } from './utils.mjs';
// 打包工具在编译时就知道:只用到了 add

原因二:导出是静态的具名绑定

// ESM 的 export 是确定的
export function add() {}
export function subtract() {}
// 打包工具能精确知道模块导出了哪些东西

原因三:导入是只读的,不可动态操作

import * as utils from './utils.mjs';
utils.add(1, 2);   // 能确定用到了 add
// utils[key]();    // 这种情况打包工具会保守处理,保留所有导出

💡 简单记忆:Tree Shaking 需要在”编译时”知道”哪些代码被用到了”。CJS 的动态特性让这成为不可能,ESM 的静态特性让这成为可能。

实际效果

// lodash(CJS)—— 即使只用了一个函数,也会打包整个库
const _ = require('lodash');
_.get(obj, 'a.b.c');
// 打包后大小:~70KB

// lodash-es(ESM)—— 只打包用到的函数
import { get } from 'lodash-es';
get(obj, 'a.b.c');
// 打包后大小:~2KB(只有 get 及其依赖)

常见误区

误区1:ESM 完全取代了 CJS

虽然 ESM 是未来趋势,但 CJS 仍然在 Node.js 生态中大量使用。很多 npm 包只提供 CJS 版本。Node.js 对两种格式都支持,并且提供了互操作机制。

// Node.js 中 ESM 可以导入 CJS
import cjsModule from './lib.cjs'; // 默认导出

// Node.js 中 CJS 导入 ESM 需要用 import()
const esmModule = await import('./lib.mjs');

误区2:require 可以导入 ESM 模块

在 Node.js 中,CJS 的 require() 不能直接导入 ESM 模块,必须用 import()

// ❌ 错误
const mod = require('./esm-module.mjs'); // ERR_REQUIRE_ESM

// ✅ 正确(但需要在 async 函数中)
const mod = await import('./esm-module.mjs');

误区3:export default 和 module.exports 完全等价

// ESM
export default { add, subtract };

// CJS
module.exports = { add, subtract };

虽然效果类似,但机制不同。export default 导出的是一个名为 default 的绑定,而 module.exports 是给整个模块对象赋值。

当 ESM 导入 CJS 模块时,CJS 的 module.exports 会被当作 ESM 的 default 导出:

// lib.cjs
module.exports = { add, subtract };

// main.mjs
import lib from './lib.cjs';         // ✅ 整个 module.exports 作为 default
import { add } from './lib.cjs';     // ⚠️ Node.js 支持,但不是所有打包工具都支持

误区4:import * 和 require 整体导入效果一样

// CJS: 拿到的是一个可变的对象
const utils = require('./utils');
utils.newProp = 'hello'; // ✅ 可以修改

// ESM: 拿到的是一个不可变的 Module Namespace Object
import * as utils from './utils.mjs';
utils.newProp = 'hello'; // ❌ TypeError: Cannot add property

ESM 的 import * 返回的是一个冻结的命名空间对象,你不能给它添加或修改属性。

小结

模块化是 JavaScript 生态从混沌走向秩序的关键一步。理解 CJS 和 ESM 的设计差异,能帮你更好地理解打包工具的工作原理。

核心要点

  1. IIFE 是模块化的早期方案,用闭包模拟封装
  2. CJS:同步加载、值拷贝、运行时分析、适合服务端
  3. ESM:异步加载、实时绑定、编译时分析、适合浏览器和服务端
  4. 值拷贝 vs 实时绑定:CJS 拿到的是快照,ESM 拿到的是活引用
  5. 循环引用:CJS 返回已执行部分的快照;ESM 通过绑定机制更好地支持
  6. Tree Shaking:依赖 ESM 的静态特性,CJS 的动态特性无法实现

本章思维导图

ESM 与 CJS
  • 历史背景
    • 全局污染问题
    • IIFE 模式
    • AMD / UMD
    • 走向标准化
  • CommonJS
    • 语法:require / module.exports
    • 同步加载
    • 值拷贝
    • 运行时分析(动态)
    • 缓存机制
  • ES Module
    • 语法:import / export
    • 异步加载
    • 实时绑定(只读)
    • 编译时分析(静态)
    • 自动严格模式
    • 三阶段加载:解析→实例化→求值
  • 核心差异
    • 值拷贝 vs 实时绑定
    • 同步 vs 异步
    • 动态 vs 静态
    • 循环引用处理
  • Tree Shaking
    • 依赖 ESM 的静态特性
    • CJS 的动态性不支持
    • 实际效果:lodash vs lodash-es

练习挑战

挑战一:基础(⭐)

以下代码分别在 CJS 和 ESM 中的输出是什么?

// data module
let items = ['a', 'b'];

function addItem(item) {
  items.push(item);
}

function getItems() {
  return items;
}

// CJS: module.exports = { items, addItem, getItems };
// ESM: export { items, addItem, getItems };
// main module
// CJS: const { items, addItem, getItems } = require('./data');
// ESM: import { items, addItem, getItems } from './data';

addItem('c');
console.log(items);      // A
console.log(getItems());  // B
答案与解析

CJS:

  • A: ['a', 'b']items 是数组(引用类型),但 module.exports = { items, ... } 时拷贝的是数组引用。addItem 通过闭包访问的是模块内部的 items,和导出的 items 指向同一个数组。所以… 等等,这里需要更仔细的分析。
    • 实际上,module.exports = { items, addItem } 中的 items 就是对数组的引用。addItem 中的 items.push() 修改的是同一个数组。所以 A 输出 ['a', 'b', 'c']
  • B: ['a', 'b', 'c']

ESM:

  • A: ['a', 'b', 'c'] — ESM 实时绑定,items 始终指向模块内部的那个数组。addItem push 后,items 自然能看到变化。
  • B: ['a', 'b', 'c']

这道题比较特殊:因为数组是引用类型,CJS 的”值拷贝”拷贝的是引用本身,所以修改数组内容两种方式都能看到。真正的差异在基本类型上(如前言诊断自测中的 count 例子)。

挑战二:进阶(⭐⭐)

分析以下循环引用场景:

// a.cjs
console.log('a 开始');
exports.x = 'a1';
const b = require('./b.cjs');
console.log('在 a 中, b.x =', b.x);
exports.x = 'a2';
console.log('a 结束');

// b.cjs
console.log('b 开始');
exports.x = 'b1';
const a = require('./a.cjs');
console.log('在 b 中, a.x =', a.x);
exports.x = 'b2';
console.log('b 结束');

// main.cjs
const a = require('./a.cjs');
console.log('最终 a.x =', a.x);

写出完整的输出顺序。

答案与解析
a 开始
b 开始
在 b 中, a.x = a1
b 结束
在 a 中, b.x = b2
a 结束
最终 a.x = a2

详细分析:

  1. main.cjs 执行 require('./a.cjs')
  2. 开始执行 a.cjs:输出 a 开始,设置 exports.x = 'a1'
  3. a.cjs 执行 require('./b.cjs')
  4. 开始执行 b.cjs:输出 b 开始,设置 exports.x = 'b1'
  5. b.cjs 执行 require('./a.cjs') — 循环引用!返回 a 当前已执行部分的 exports({ x: 'a1' }
  6. 输出 在 b 中, a.x = a1
  7. 设置 exports.x = 'b2',输出 b 结束
  8. 回到 a.cjsrequire('./b.cjs') 返回({ x: 'b2' }
  9. 输出 在 a 中, b.x = b2
  10. 设置 exports.x = 'a2',输出 a 结束
  11. 回到 main.cjs,输出 最终 a.x = a2

挑战三:综合(⭐⭐⭐)

实现一个简化版的模块加载器,支持 define(定义模块)和 require(加载模块):

const loader = createModuleLoader();

loader.define('math', () => {
  return {
    add: (a, b) => a + b,
    multiply: (a, b) => a * b,
  };
});

loader.define('app', (require) => {
  const math = require('math');
  return {
    calculate: () => math.add(1, math.multiply(2, 3)),
  };
});

const app = loader.require('app');
console.log(app.calculate()); // 7
答案与解析
function createModuleLoader() {
  const modules = {};  // 模块定义
  const cache = {};    // 模块缓存

  function define(name, factory) {
    modules[name] = factory;
  }

  function require(name) {
    // 缓存检查
    if (cache[name]) {
      return cache[name];
    }

    const factory = modules[name];
    if (!factory) {
      throw new Error(`Module '${name}' not found`);
    }

    // 执行工厂函数,传入 require 以支持依赖加载
    const exports = factory(require);

    // 缓存
    cache[name] = exports;

    return exports;
  }

  return { define, require };
}

核心思想:

  1. define 注册模块的工厂函数
  2. require 调用工厂函数并缓存结果
  3. 工厂函数接收 require 参数,用于加载依赖
  4. 缓存机制确保每个模块只执行一次

这就是一个最简版的 CommonJS 模块加载器,它揭示了 require 的核心原理:注册 → 执行 → 缓存。

自我检测

读完本章后,确认你能回答以下问题:

  • 能说出 IIFE 模式的作用和局限
  • 能说出 CJS 的四个核心特性(同步加载、值拷贝、动态加载、缓存机制)
  • 能说出 ESM 的四个核心特性(静态分析、实时绑定、异步加载、严格模式)
  • 能解释”值拷贝”和”实时绑定”的区别,并给出代码示例
  • 能分析 CJS 循环引用时的执行顺序和导出值
  • 能解释 Tree Shaking 为什么依赖 ESM 的静态特性
  • 知道 CJS 和 ESM 在 Node.js 中的互操作方式
  • 理解 ESM 的三阶段加载过程(解析、实例化、求值)

购买课程解锁全部内容

大厂前端面试通关:71 篇构建完整知识体系

¥89.90