JavaScript 模块化深度解析:CJS 与 ESM
在现代 JavaScript 开发中,模块化是组织代码、避免命名空间污染和提高可复用性的核心手段。目前主流的两种规范是 CommonJS (CJS) 和 ES Modules (ESM)。
1. CommonJS (CJS)
CJS 是 Node.js 默认使用的模块系统(早期版本唯一支持)。
核心语法
javascript
// 导出 lib.js
const name = 'CommonJS';
module.exports = { name };
// 导入 main.js
const lib = require('./lib.js');
console.log(lib.name);特点
- 运行时加载:模块在代码运行时同步加载。
- 值拷贝:导出的值是原始值的拷贝。一旦导出,模块内部的变化不会影响已经导出的值。
- 同步执行:由于主要用于服务端,读取磁盘速度较快,同步加载不会阻塞进程。
2. ES Modules (ESM)
ESM 是 ECMAScript 官方提出的标准,也是浏览器原生支持的唯一模块规范。
核心语法
javascript
// 导出 lib.js
export const name = 'ESModule';
export default function sayHi() {}
// 导入 main.js
import { name } from './lib.js';
import sayHi from './lib.js';特点
- 静态编译:引擎在代码执行前(链接阶段)就确定了模块间的依赖关系,允许进行 Tree-shaking 优化。
- 实时绑定 (Live Binding):导出的是值的引用。如果模块内部修改了导出的变量,外部引用也会随之改变。
- 异步加载:特别适合浏览器环境,不会阻塞页面渲染。
3. AMD (Asynchronous Module Definition)
AMD 是专门为浏览器端设计的模块规范,其核心特点是异步加载,模块的加载不阻塞页面的渲染。
核心语法
AMD 使用 define 函数定义模块,使用 require 函数加载模块。
javascript
// 定义模块 (math.js)
define(['dependency'], function(dependency) {
const add = (a, b) => a + b;
return { add };
});
// 加载模块 (main.js)
require(['math'], function(math) {
console.log(math.add(1, 2));
});特点
- 依赖前置:在定义模块时就需要声明所有依赖,并在回调函数中作为参数传入。
- 异步加载:通过动态创建
<script>标签实现异步加载,适合网络环境不稳定的浏览器端。 - 代表库:RequireJS 是 AMD 最具代表性的实现。
4. UMD (Universal Module Definition)
UMD 并不是一种新的模块规范,而是一种兼容性模式。它的目标是让同一个模块文件既能在浏览器端(通过 <script> 或 AMD 加载)运行,又能在 Node.js (CommonJS) 环境运行。
实现原理
UMD 通常通过一个自执行函数(IIFE)实现。它会检测当前环境:
- 如果存在
define且define.amd为真,则使用 AMD 方式。 - 如果存在
module且module.exports为真,则使用 CommonJS 方式。 - 如果都不存在,则将模块挂载到全局变量(如
window或global)上。
典型代码模板
javascript
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD 环境
define(['dependency'], factory);
} else if (typeof module === 'object' && module.exports) {
// CommonJS 环境
module.exports = factory(require('dependency'));
} else {
// 浏览器全局变量环境
root.myModule = factory(root.dependency);
}
}(this, function (dependency) {
// 模块核心逻辑
return { name: 'UMD' };
}));4. CJS 与 ESM 的核心区别
| 特性 | CommonJS (CJS) | ES Modules (ESM) |
|---|---|---|
| 关键字 | require, module.exports | import, export |
| 加载时机 | 运行时 (Runtime) | 编译时 (Compile-time) |
| 导出内容 | 值的拷贝 (Value Copy) | 值的引用 (Live Binding) |
| 加载方式 | 同步 | 异步 |
| 循环引用 | 输出部分完成的对象,易产生 undefined | 建立连接,可能触发暂时性死区 (TDZ) 报错 |
| Top-level await | 不支持 | 支持 |
this 指向 | 指向当前模块对象 | 指向 undefined |
| 严格模式 | 默认非严格(除非指定) | 强制处于严格模式 |
4. 多种导入导出方式
4.1 命名导出与导入 (Named)
可以导出多个变量,导入时需要名称匹配。
javascript
// 导出
export const a = 1;
export const b = 2;
// 导入
import { a, b } from './module.js';4.2 默认导出与导入 (Default)
每个模块只能有一个默认导出,导入时可以自定义名称。
javascript
// 导出
export default function() {}
// 导入
import myFunc from './module.js';4.3 整体导入 (Namespace Import)
将模块的所有导出整合到一个对象中。
javascript
import * as Utils from './utils.js';
Utils.method1();4.4 动态导入 (Dynamic Import)
允许按需加载模块,返回一个 Promise,适用于代码分割(Code Splitting)。
javascript
import('./heavy-module.js').then((m) => {
m.doSomething();
});5. 关于循环引用的差异(进阶)
这是面试中常考的一个细节:
- CJS:当遇到循环引用时,由于是按序执行,它会返回当前已经执行完的那部分
exports对象。如果访问尚未导出的属性,会得到undefined。 - ESM:它先建立模块间的引用桥梁,但不求值。在执行阶段,如果你试图在变量初始化前访问它(比如
const声明的变量),会因为 TDZ (暂时性死区) 抛出ReferenceError。
TIP
最佳实践:
- 在现代前端项目中(Vite, Webpack 5+),优先使用 ESM。
- 避免在模块顶层直接调用可能引发循环引用的复杂逻辑。
- 利用动态导入来解决某些极端的递归引用问题。
