CommonJS vs ES Modules:从运行时加载到编译时静态分析的演进之路
大家好,欢迎来到今天的讲座。今天我们不聊框架、不聊工具链,也不讲什么“最佳实践”这种听起来很虚的概念——我们来深入探讨一个看似基础却极其重要的话题:CommonJS 与 ES Modules 的本质区别。
你可能已经在项目中用过 require 或者 import,但你是否真正理解它们背后的设计哲学?为什么 Node.js 最初选择 CommonJS,后来又逐步拥抱 ES Modules?为什么现代前端构建工具(如 Webpack、Vite)对这两种模块系统的处理方式完全不同?
这篇文章将带你一步步揭开这些谜团,从语法差异到执行机制,再到实际开发中的影响。我会尽量避免使用术语堆砌,而是通过代码示例和逻辑推导,让你真正明白“require 是运行时加载,import 是编译时静态分析”这句话到底意味着什么。
一、什么是模块系统?
在 JavaScript 发展早期,它只是一个浏览器脚本语言,没有内置的模块机制。随着应用复杂度上升,开发者需要一种方式来组织代码:把功能拆分成独立文件,按需引入,避免全局污染。
于是出现了两种主流方案:
| 特性 | CommonJS (Node.js) | ES Modules (ES6+) |
|---|---|---|
| 出现时间 | 2009年左右 | 2015年(ES6) |
| 主要用途 | Node.js 服务端 | 浏览器 + Node.js |
| 加载方式 | 运行时动态加载 | 编译时静态分析 |
| 导出语法 | module.exports / exports |
export / export default |
| 导入语法 | require() |
import |
这两个系统虽然都能实现模块化,但底层逻辑完全不同。下面我们逐个剖析。
二、CommonJS:运行时加载的经典代表
1. 基础语法示例
假设我们有两个文件:
math.js
function add(a, b) {
return a + b;
}
function multiply(a, b) {
return a * b;
}
module.exports = {
add,
multiply
};
main.js
const { add, multiply } = require('./math');
console.log(add(2, 3)); // 5
console.log(multiply(4, 5)); // 20
2. 核心特点:运行时加载(Runtime Loading)
当你执行 require('./math') 时,Node.js 并不会立刻读取并解析该文件内容。相反,它会:
- 缓存已加载模块(避免重复加载)
- 同步读取文件内容
- 执行模块代码(即执行 math.js 中的所有语句)
- 返回
module.exports对象
这个过程发生在程序运行期间,也就是说:
✅ require() 是一个函数调用
✅ 它可以出现在任何地方(条件判断、循环内部等)
✅ 模块路径可以在运行时计算出来
示例:动态加载模块
// 动态加载不同模块
const moduleName = process.argv[2] || 'utils';
const module = require(`./${moduleName}`); // 路径由变量决定!
console.log(module.someFunction());
这正是 CommonJS 的灵活性所在:你可以根据环境、用户输入或配置动态决定加载哪个模块。
但这带来了问题——无法提前优化。
三、ES Modules:编译时静态分析的新标准
1. 基础语法示例
math.js
export function add(a, b) {
return a + b;
}
export function multiply(a, b) {
return a * b;
}
main.js
import { add, multiply } from './math.js';
console.log(add(2, 3)); // 5
console.log(multiply(4, 5)); // 20
注意:这里必须写 .js 扩展名(除非你在 package.json 中设置 "type": "module")。
2. 核心特点:编译时静态分析(Static Analysis at Build Time)
ES Modules 的导入是静态的,这意味着:
✅ import 必须出现在顶层作用域(不能在 if、for 中)
✅ 导入路径必须是字符串字面量(不能是变量)
✅ 所有依赖关系在代码解析阶段就能确定
示例:不允许动态导入(这是关键!)
// ❌ 错误!不能这样做
const path = './utils.js';
import utils from path; // SyntaxError: Cannot use import statement outside a module
这是因为浏览器和 Node.js 在加载前会先做一次“静态扫描”,找出所有 import 和 export,然后一次性加载全部依赖。
🧠 这就是为什么 Webpack/Vite 等打包工具能进行 Tree Shaking(摇树优化)的原因:它们知道哪些模块被用了,哪些没用,从而移除无用代码。
四、核心差异总结(对比表格)
| 方面 | CommonJS | ES Modules |
|---|---|---|
| 加载时机 | 运行时(动态) | 编译时(静态) |
| 导入语法 | require() 函数调用 |
import 语句(声明式) |
| 导出语法 | module.exports / exports |
export / export default |
| 是否支持动态路径 | ✅ 支持(如 require(path)) |
❌ 不支持(必须是字符串常量) |
| 是否支持条件加载 | ✅ 支持(if/else 中使用) | ❌ 不支持(只能顶层) |
| 打包优化能力 | ⚠️ 较弱(难以静态分析) | ✅ 强(可做 tree-shaking) |
| Node.js 兼容性 | 默认支持(非模块模式) | 需启用 "type": "module" |
| 浏览器原生支持 | ❌ 不支持(需打包) | ✅ 原生支持() |
五、实际影响:性能、调试与构建工具
1. 性能差异:懒加载 vs 预加载
CommonJS 的运行时加载意味着:
- 只有真正执行到
require()时才会去读文件 - 如果某个模块从未被使用,它永远不会加载(节省资源)
ES Modules 则不同:
- 所有导入都会在启动时被扫描并加载(即使你不使用)
- 优点是可以提前发现错误(比如拼写错误),缺点是可能多加载一些不需要的模块
示例:CommonJS 的懒加载优势
// utils.js
console.log('Loading utils...'); // 这行只会在 require 时打印
// main.js
if (someCondition) {
const utils = require('./utils'); // 只有满足条件才加载
}
而 ES Modules:
// main.js
if (someCondition) {
import utils from './utils'; // ❌ 报错!import 必须在顶层
}
👉 所以 CommonJS 更适合“按需加载”的场景,比如插件系统、路由懒加载等。
2. 构建工具如何处理?
Webpack、Rollup、Vite 等工具都支持两者,但处理策略完全不同:
| 工具 | CommonJS 处理方式 | ES Modules 处理方式 |
|---|---|---|
| Webpack | 使用 require.context() 或动态 import |
自动识别 import/export,进行 tree-shaking |
| Vite | 支持热更新(HMR)但需额外配置 | 原生支持,无需转换即可热更新 |
| Rollup | 可以打包成 UMD 或 IIFE | 更高效,更适合库开发 |
💡 小贴士:如果你正在写一个公共库(Library),推荐使用 ES Modules,因为它的静态特性让构建工具更容易优化,减少最终包体积。
六、兼容性问题与解决方案
1. Node.js 的双模式共存
Node.js 同时支持两种模块系统,但默认是 CommonJS(非模块模式)。你要使用 ES Modules,必须:
- 文件扩展名为
.mjs - 或者在
package.json中添加"type": "module"
{
"type": "module"
}
这样以后所有 .js 文件都会按 ES Modules 解析。
⚠️ 注意:一旦设置了 "type": "module",你就不能再用 require(),否则会报错!
示例:混合使用(谨慎操作)
// node_modules/my-lib/index.js
module.exports = { hello: () => console.log("Hi!") };
// app.js (如果 type=module)
import myLib from './my-lib'; // ❌ 错误!不能混用
解决办法:要么统一用 ES Modules,要么统一用 CommonJS。
2. 如何在 ES Modules 中模拟 require?
你可以用 import() 动态导入,它是异步的,类似于 require() 的动态行为:
async function loadModule() {
const { default: utils } = await import('./utils.js');
return utils;
}
这就是所谓的“动态 import”,它是 ES Modules 的补充机制,用于替代 CommonJS 的动态加载需求。
七、实战建议:什么时候选哪种?
| 场景 | 推荐模块系统 | 理由 |
|---|---|---|
| Node.js 后端服务 | CommonJS(旧项目)或 ES Modules(新项目) | CommonJS 更成熟;ES Modules 更现代,利于打包优化 |
| 前端项目(React/Vue/Angular) | ES Modules | 浏览器原生支持,构建工具友好,利于 tree-shaking |
| 库开发(npm 包) | ES Modules | 更容易被其他项目引用,且构建工具可优化 |
| 插件系统 / 动态加载 | CommonJS | 支持运行时动态 require,灵活性更高 |
| 教学入门 | CommonJS | 更直观,适合初学者理解模块概念 |
八、结语:理解本质,才能写出更好的代码
今天我们一起回顾了 CommonJS 和 ES Modules 的根本区别:
- CommonJS 是运行时加载:灵活、动态、适合服务器端、插件系统。
- ES Modules 是编译时静态分析:可预测、易优化、适合前端、库开发。
记住一句话:“require 是运行时加载,import 是编译时静态分析”——这不是一句口号,而是两种设计哲学的根本差异。
未来几年,随着 Node.js 完全转向 ES Modules(v14+ 已默认支持),以及浏览器生态越来越强大,你会看到越来越多项目采用 ES Modules。但不要盲目跟风,理解它们各自的适用场景,才是写出高质量 JavaScript 的第一步。
希望今天的分享对你有所帮助。如果你还有疑问,欢迎留言讨论!