历史背景
-
CommonJS (CJS):
- 诞生背景:诞生于 Node.js。早期 JavaScript 只能在浏览器里运行,没有模块系统。当 Node.js 把它带到服务器端时,必须有一套模块化规范来管理复杂的项目代码。于是,CommonJS 应运而生。
- 设计目标:为服务器端设计,特点是简单、同步。因为服务器上的文件都在本地硬盘,读取速度很快,所以“同步加载”模块是可以接受的。
-
ES Modules (ESM):
- 诞生背景:由 ECMAScript 官方推出的标准。JavaScript 社区意识到语言本身需要一个统一的、官方的模块化标准,既能用于浏览器,也能用于服务器。
- 设计目标:为所有环境(尤其是浏览器)设计,特点是静态、异步。因为浏览器需要通过网络加载模块,“异步加载”才不会阻塞页面渲染,这是至关重要的。
核心区别
| 特性 | CommonJS (CJS) | ES Modules (ESM) |
|---|---|---|
| 关键字 | require / module.exports |
import / export |
| 加载方式 | 同步加载 (Synchronous) | 异步加载 (Asynchronous) |
| 分析时机 | 运行时分析 (Runtime) | 静态分析 (Static) |
| 值的类型 | 值的复制 (Value Copy) | 动态引用 (Live Binding) |
| 主要环境 | Node.js (历史) | 浏览器和现代 Node.js |
区别:
1. 语法 (Keywords) – 最直观的区别
-
CJS:
// 导出 const path = require('path'); function myFunc() { /* ... */ } module.exports = { myFunc }; // 必须导出整个对象 // 导入 const { myFunc } = require('./myModule.js'); -
ESM:
// 导出 import path from 'path'; export function myFunc() { /* ... */ } // 可以直接导出 export const PI = 3.14; // 导入 import { myFunc } from './myModule.js';
2. 加载方式-最根本的区别
- CJS (同步): 当 Node.js 遇到
require('./myModule.js')时,它会停下当前代码的执行,立刻去硬盘读取、解析并执行myModule.js,然后把module.exports的结果返回,之后才继续执行下面的代码。这是一种阻塞式的加载。 - ESM (异步): 浏览器(或现代 Node.js)看到
import时,它会发出一个网络请求去获取这个模块。但它不会傻等,而是会继续解析和执行其他代码。模块加载回来后,再执行相关逻辑。这是一种非阻塞式的加载,对用户体验至关重要。
3. 分析时机
-
CJS (运行时):
require本质上是一个函数,你可以在代码的任何地方调用它,甚至可以把它放在if语句里,require的路径也可以是动态拼接的变量。这意味着只有代码执行到那一行,系统才知道需要加载什么模块。if (process.env.NODE_ENV === 'development') { const devTools = require('./dev-tools'); // 运行时才知道要不要加载 } -
ESM (静态):
import是一个关键字,不是函数。它必须写在文件的顶层。这使得打包工具(如 Webpack, Vite)可以在不执行任何代码的情况下,只通过阅读源码,就能分析出整个项目的依赖关系图。这个能力是实现摇树优化 (Tree Shaking) 的基础——即打包时可以安全地移除掉那些你import了但从未使用过的代码。
两者之间的异同点
对比一下“单例模式”和“模块级变量”在两个系统中的表现。
相同点:模块缓存机制 & 单例模式
这个核心机制在 CommonJS 和 ESM 中完全相同
无论哪种规范,JavaScript 引擎都会维护一个模块缓存。当一个模块第一次被 require 或 import 时,它会被执行一次,然后结果会被缓存起来。之后所有对该模块的 require 或 import 都会直接从缓存中读取,不会再次执行模块代码。
- 在 CJS 中: 你可以通过
require.cache这个对象来查看缓存。 - 在 ESM 中: 缓存机制是规范的一部分,但通常不直接暴露给开发者。
结论:通过在模块顶层创建一个对象来实现单例模式的原理,在 CommonJS 和 ESM 环境下都完全成立。因为它们都保证了模块只会被初始化一次。
关键差异点:值的复制 与 动态引用
这是两者最微妙、但也最重要的区别。
-
CJS 导出的是“值的复制”: 当
require一个模块时,你得到的是那个模块module.exports对象在那一刻的一个快照副本。如果被导入的模块内部之后改变了导出的值,导入方是不知道的。counter.cjs(CommonJS)let count = 0; function increment() { count++; console.log(`(Inside CJS) Count is now ${count}`); } module.exports = { count: count, // 导出的是此刻 count 的值,也就是 0 increment: increment, };main.cjsconst counter = require('./counter.cjs'); console.log(`(Outside CJS) Initial count: ${counter.count}`); // 输出 0 counter.increment(); // 输出 (Inside CJS) Count is now 1 console.log(`(Outside CJS) Count after increment: ${counter.count}`); // 还是输出 0!为什么?
因为
main.cjs得到的counter.count只是这个值的拷贝。counter.cjs内部的count变量变了,但main.cjs手里的那份拷贝不会更新。 -
ESM 导出的是“动态引用” (Live Binding): 当你
import一个成员时,你得到的是一个指向原始模块内部那个变量的“实时指针”或“只读视图”。如果被导入的模块内部改变了这个变量的值,导入方能立即看到这个变化。counter.mjs(ESM)export let count = 0; export function increment() { count++; console.log(`(Inside ESM) Count is now ${count}`); }main.mjsimport { count, increment } from './counter.mjs'; console.log(`(Outside ESM) Initial count: ${count}`); // 输出 0 increment(); // 输出 (Inside ESM) Count is now 1 console.log(`(Outside ESM) Count after increment: ${count}`); // 输出 1!为什么? 因为
main.mjs里的count不是一个值的拷贝,它就像一个直接连通到counter.mjs内部count变量的窗口,能实时反映其值的变化。
总结
| CommonJS (CJS) | ES Modules (ESM) | |
|---|---|---|
| 设计初衷 | 服务器端,文件 I/O 很快 | 浏览器端,网络 I/O 很慢 |
| 加载方式 | 同步、阻塞 | 异步、非阻塞 |
| 分析时机 | 运行时,动态 | 编译时,静态 |
| 优化 | 难以做 Tree Shaking | 天然支持 Tree Shaking |
| 单例模式 | 支持 (通过模块缓存) | 支持 (通过模块缓存) |
| 值传递 | 导出的是值的拷贝 | 导出的是动态引用 (Live Binding) |
前端开发中,接触到的几乎都是ESM,因为打包工具(Vite, Webpack)都是基于 ESM 的静态分析能力来做各种优化的。在写 Node.js 后端或者一些老的配置文件时,可能还会遇到 CommonJS,因为vite和webpack的开发服务器是使用了nodejs的不同框架,运用的通常是commonjs规范
评论(0)
暂无评论