好的,我们从你指定的 第 35 题:模块化原理(CommonJS vs ESModule) 开始。
我会给你 详细版 + 面试官思维 + 易错点 + 速记卡片。
✅ 第 35 题:CommonJS 与 ES Module 的区别?原理是什么?为什么 Node 现已逐步推荐 ESM?
一、CommonJS 与 ESModule 的核心区别(速记)
| 特性 | CommonJS(CJS) | ES Module(ESM) |
|---|---|---|
| 语法 | require() / module.exports |
import / export |
| 加载方式 | 同步加载 | 异步加载(编译期加载) |
| 执行时机 | 运行时加载 | 编译时加载(静态分析) |
| 导出值是否可变 | 导出的值是拷贝(值拷贝) | 导出的是引用(live binding)动态更新 |
| 能否 tree shaking | ❌ 不支持 | ✅ 支持(静态分析决定) |
| 是否支持顶层 await | ❌ 不支持 | ✅ 支持 |
| 是否允许循环依赖 | 可以但复杂 | 可以而且天然处理更好 |
| Node 支持情况 | 默认支持 | Node 16+ 完整稳定支持 |
二、深入原理:为什么两者的行为不同?
1)加载机制不同(同步 vs 异步)
🔸 CommonJS:运行时加载(sync)
-
require()相当于:- 读取文件内容
- 执行整个文件(形成 module 对象)
- 返回
module.exports
它必须等文件执行完才能继续,所以是 同步阻塞。
适合本地文件模块,不适合浏览器(因为同步阻塞网络)
🔸 ESModule:编译时加载(async)
- 在代码运行前,JS 引擎会对
import做 静态分析 - 不会执行代码,只会记录模块依赖图
- 之后按依赖图异步加载执行
静态分析意味着引擎能提前知道你用了哪些东西(支持 tree shaking)。
2)导出值是"值拷贝" vs "引用绑定"
🔸 CommonJS:值拷贝缓存
ini
exports.count = 1;
setTimeout(() => {
exports.count++;
}, 1000);
另外一个文件:
javascript
const { count } = require('./a');
console.log(count); // 永远是 1,不会变成 2
因为 CommonJS 返回的是 第一次 require 时的 module.exports 快照。
🔸 ESModule:引用绑定(live binding)
ini
export let count = 1;
setTimeout(() => count++, 1000);
导入者:
javascript
import { count } from "./a.js";
console.log(count); // 1
setTimeout(() => console.log(count), 1500);
// 输出 2(实时更新)
ESM 导入的是变量引用,本质是 getter。
3)Cirular dependency(循环依赖)处理完全不同
🔸 CommonJS:执行整个文件 → 才有 module.exports
循环依赖时会出现"部分加载",很容易出现 undefined。
🔸 ESModule:因为静态分析
ESM 能在执行前构建依赖图,所以即使循环依赖也能正常引用。
浏览器和 Node 都按标准执行,不容易出问题。
4)Tree Shaking 能否生效
ESM 能,CJS 不行。
这是因为:
- CJS 运行时决定导出内容(动态特性) → 无法静态分析
- ESM 导出结构固定 → 能在构建期删除没用的代码
所以现代前端构建工具都会要求你尽量使用 ESModule。
三、Node 为什么推荐 ESModule?(面试官爱问)
原因有五:
1)浏览器与 Node 的统一标准
以前 Node 推 CJS,浏览器没有模块化 → 必须用 webpack 等打包
如今浏览器支持 ESM → Node 也对齐标准。
2)静态分析能力更强 → 更好优化
比如:
- tree shaking
- Scope Hoisting
- 更快的加载速度
- 更强可预测性
3)天然支持顶层 await
csharp
const data = await fetch(...)
CJS 完全无法做到。
4)更好的循环依赖处理
5)更安全、更可控的模块加载系统
Node 的 CJS 模块解析路径复杂、易冲突
ESM 则完全按 URL 和标准路径执行。
四、前端最爱考的面试题:为什么这段代码打印不一样?
例 1:CommonJS
ini
// a.js
let counter = 1;
setTimeout(() => counter++, 1000);
module.exports = { counter };
// b.js
const a = require('./a');
console.log(a.counter);
setTimeout(() => console.log(a.counter), 1500);
输出:
1
1 ← 不会变
例 2:ESM
javascript
// a.js
export let counter = 1;
setTimeout(() => counter++, 1000);
// b.js
import { counter } from './a.js';
console.log(counter);
setTimeout(() => console.log(counter), 1500);
输出:
1
2 ← 会变
五、速记卡片(给你每天复习用)
🟦 🌟 模块化:CJS vs ESM 速记卡片
CommonJS(Node 老体系)
require()同步加载- 运行时决定依赖
- 导出是"值拷贝",使用缓存
- 不支持顶层 await
- 无法使用 tree shaking
- 循环依赖易出问题
ES Module(现代体系)
import异步、编译时分析- 导出是"引用绑定"
- 支持顶层 await
- 可 tree shaking
- 循环依赖天然支持
- 浏览器原生支持,Node 推荐
如果你愿意,我可以继续「第 36 题」
👉 Tree Shaking 原理与常见失效原因(非常高频)
要继续吗?