面试官:你能说说 CommonJS 和 ES Module 的区别吗?
我:......(脑子里只剩下
require和import)
说实话,这个问题你一定见过 ,而且99% 的前端都背过标准答案 。
但真要往深了问一句:
- 为什么 ESM 可以 Tree Shaking?CommonJS 不行
- 为什么 ESM 的 import 是"只读的"?
很多人,当场就开始"CPU 过载"。
于是我决定直接把底层逻辑捋清楚,以下就是我对 CommonJS 和 ES Module 一次系统性深挖的记录。
一、什么是 CommonJS?它解决了什么问题?
1. CommonJS 的诞生背景
在早期 JavaScript 只有浏览器环境时,是没有模块系统的:
- 全局变量污染
- 文件之间依赖混乱
- 无法复用代码
于是 Node.js 社区 提出了一套解决方案:CommonJS(CMJ) 。
👉 注意 :CommonJS 是社区标准,不是官方语言层面的规范。
CommonJS 的核心特征
- ✅ 社区标准
- ✅ 使用函数实现(
require) - ✅ 仅 Node 环境支持
- ✅ 动态依赖,同步执行
2. CommonJS 为什么叫"动态依赖"?
来看一段最典型的代码:
js
const moduleName = './a.js';
const a = require(moduleName);
这里的依赖路径,是不是运行时才能确定?这就是动态依赖;
CommonJS 的依赖关系,必须等代码执行时才能知道。
3. require 到底做了什么?(核心原理)
你在 Node 中写的:
js
const a = require('./a.js');
但如果我追问一句:require 加载的模块代码,是"直接执行"的吗? 模块里的 this、exports、module.exports 到底从哪来的?
答案其实藏在 Node.js 对模块的一层"函数包装"里:
js
function require(path) {
const cache = {}
// 1. 如果模块已经加载过,直接返回缓存
if (cache[path]) {
return cache[path].exports;
}
// 2. 创建模块对象
const module = {
id:path
exports: {}
};
// 3. 执行模块代码(用函数包一层)
function _run(exports, require, module, __filename, __dirname) {
// 模块源码在这里执行
}
_run.call(
module.exports,
module.exports,
require,
module,
__filename,
__dirname
);
// 4. 缓存并返回结果
cache[modulePath] = module;
return module.exports;
}
假设你有一个文件 a.js,那么文件中的内容会放到上面的_run函数中执行
我们拆开来看:
| 名称 | 实际指向 |
|---|---|
this |
module.exports |
exports |
module.exports |
module.exports |
module.exports |
在模块初始化阶段,这三个引用的是同一个对象。
所以以下判断永远成立:
js
console.log(arguments); // [exports, require ,module, __filename, __dirname]
console.log(this); // {}
console.log(this === exports); // true
console.log(exports === module.exports); // true
重点来了:
require是一个普通函数module.exports是一个普通对象- 模块执行是同步的
- 导出的值是一次性的值拷贝
二、ES Module:语言层面的模块系统
如果说 CommonJS 是"工具方案",那么 ES Module(ESM)就是 JavaScript 官方给出的答案。
ES Module 的核心关键
- ✅ 官方标准(ECMAScript)
- ✅ 使用新语法(
import / export) - ✅ 所有环境支持(浏览器 / Node / Deno)
- ✅ 同时支持静态依赖 & 动态依赖
- ✅ 符号绑定
1. 什么是「静态依赖」?
js
import { a } from './a.js';
这行代码有两个关键点:
import只能写在顶层- 依赖路径在代码运行前就确定
👉 这意味着什么?
- 构建工具在编译阶段就能分析依赖
- 支持Tree Shaking
- 可以做代码分割、预加载
这也是为什么 ESM 更适合前端工程化。
2. ESM 也支持动态依赖,但它是异步的
js
import('./a.js').then(module => {
console.log(module.a);
});
和 CommonJS 最大的不同点:
| 模块系统 | 动态依赖 |
|---|---|
| CommonJS | 同步 |
| ES Module | 异步 |
3. 符号绑定:ESM 最容易被忽略
这是 ESM 和 CommonJS 的本质区别。
看一段代码
js
// a.js
export var a = 1;
export function changeA() {
a = 2;
}
js
// index.js
import { a, changeA } from './a.js';
console.log(a); // 1
changeA();
console.log(a); // 2
这里为什么 a 会跟着变化?
真相就是 import 不是赋值,而是"引用同一个符号"
在 ESM 中: 导入的不是值,而是对导出符号的实时绑定
可以理解为:
a在模块内部是一个变量- 所有 import 的地方,都指向同一个 a
- 修改它,所有地方同步变化
这就是「符号绑定(Live Binding)」。
对比 CommonJS(非常关键)
js
// a.js
var n = 1;
function changeN() {
n = 2;
}
module.exports = {
n,
changeN
}
// b.js
const { n, changeN } = require('./a.js');
console.log(n); // 1
changeN();
console.log(n); // 1
这里的 n:
- 是一次值拷贝
- 后续模块内部怎么改,外面都不会同步
4. 再看下 下面几个问题
(1) export 和 export default 的区别
export:具名导出,可多个export default:默认导出,只能一个- 默认导出本质是
{ default: xxx }
(2) 下面代码导出了什么?
js
exports.a = 'a';
module.exports.b = 'b';
this.c = 'c';
module.exports = {
d: 'd'
};
结果:
js
{ d: 'd' }
(3)下面代码导出了什么?
js
exports.a = 1;
exports = { b: 2 };
结果:
js
{ a: 1 }
原因是:
exports只是module.exports的一个引用- 当你重新给
exports赋值时,只是断开了引用关系module.exports并没有变 - 等价于 let exports = module.exports; exports = {}; 只是改了局部变量