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.mjs 的 count 始终指向 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:核心差异对比
| 特性 | CommonJS | ES Module |
|---|---|---|
| 语法 | require / module.exports | import / export |
| 加载方式 | 同步 | 异步 |
| 绑定方式 | 值拷贝 | 实时绑定(只读) |
| 分析时机 | 运行时 | 编译时(静态) |
| 模块顶层 this | module.exports | undefined |
| 动态加载 | 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.done 是 undefined。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 的加载分为三个阶段:
- 解析(Parsing):静态分析所有 import/export,建立模块依赖图
- 实例化(Instantiation):为所有导出创建绑定(此时变量已存在但未初始化)
- 求值(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 的设计差异,能帮你更好地理解打包工具的工作原理。
核心要点
- IIFE 是模块化的早期方案,用闭包模拟封装
- CJS:同步加载、值拷贝、运行时分析、适合服务端
- ESM:异步加载、实时绑定、编译时分析、适合浏览器和服务端
- 值拷贝 vs 实时绑定:CJS 拿到的是快照,ESM 拿到的是活引用
- 循环引用:CJS 返回已执行部分的快照;ESM 通过绑定机制更好地支持
- Tree Shaking:依赖 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始终指向模块内部的那个数组。addItempush 后,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
详细分析:
main.cjs执行require('./a.cjs')- 开始执行
a.cjs:输出a 开始,设置exports.x = 'a1' a.cjs执行require('./b.cjs')- 开始执行
b.cjs:输出b 开始,设置exports.x = 'b1' b.cjs执行require('./a.cjs')— 循环引用!返回 a 当前已执行部分的 exports({ x: 'a1' })- 输出
在 b 中, a.x = a1 - 设置
exports.x = 'b2',输出b 结束 - 回到
a.cjs,require('./b.cjs')返回({ x: 'b2' }) - 输出
在 a 中, b.x = b2 - 设置
exports.x = 'a2',输出a 结束 - 回到
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 };
}
核心思想:
define注册模块的工厂函数require调用工厂函数并缓存结果- 工厂函数接收
require参数,用于加载依赖 - 缓存机制确保每个模块只执行一次
这就是一个最简版的 CommonJS 模块加载器,它揭示了 require 的核心原理:注册 → 执行 → 缓存。
自我检测
读完本章后,确认你能回答以下问题:
- 能说出 IIFE 模式的作用和局限
- 能说出 CJS 的四个核心特性(同步加载、值拷贝、动态加载、缓存机制)
- 能说出 ESM 的四个核心特性(静态分析、实时绑定、异步加载、严格模式)
- 能解释”值拷贝”和”实时绑定”的区别,并给出代码示例
- 能分析 CJS 循环引用时的执行顺序和导出值
- 能解释 Tree Shaking 为什么依赖 ESM 的静态特性
- 知道 CJS 和 ESM 在 Node.js 中的互操作方式
- 理解 ESM 的三阶段加载过程(解析、实例化、求值)
购买课程解锁全部内容
大厂前端面试通关:71 篇构建完整知识体系
¥89.90