进阶篇:模块化编程——CJS、AMD、CMD、UMD、ESM
前端模块化,是将庞大复杂的业务代码,按照功能、职责拆分成一个个独立封闭、可复用的代码单元(模块),依靠统一规范完成模块导入导出、依赖关系管理的开发模式。 核心收益:
- 可维护性:模块职责单一,独立作用域,便于调试、迭代更新。
- 可复用性:编写完成的模块,可以跨业务、跨项目重复使用。
- 命名空间隔离:模块拥有私有作用域,从根源减少全局变量污染。
- 依赖可管理:显式声明模块之间的依赖关系,降低耦合。
模块化是软件工程中高内聚、低耦合原则在前端的具体实践。在主流规范中,通常一个文件对应一个模块,具备独立作用域,只对外暴露预先定义好的变量、函数。
早期模块化:IIFE 立即执行函数
在标准化的模块规范诞生之前,前端主要依靠立即执行函数表达式(IIFE,Immediately-Invoked Function Expression)实现基础模块化,利用函数闭包制造私有作用域。
var num0 = 2;
(function () {
var num1 = 3
var num2 = 5
var add = function () {
return num0 + num1 + num2
}
console.log(add()) // 10
})()
函数内部的 num1、num2 属于局部变量,不会泄露到全局;同时内部可以访问全局变量。闭包为模块提供私有状态,这是早期前端实现模块隔离最普遍的手段。
局限:仅实现作用域隔离,缺少标准化的导入导出、依赖解析机制,大型项目下手动管理依赖成本很高。
CommonJS(CJS)
CommonJS 是服务端 JavaScript 的模块规范,Node.js 原生实现这套规范。
核心思想:通过 module.exports、exports 向外导出接口;使用 require() 同步加载依赖模块。
核心特性:
- 同步加载:执行
require的时候立刻读取模块,阻塞后续代码执行,适合本地磁盘读取的服务器环境。 - 值拷贝:导出的是变量的值副本,模块内部后续修改不会影响外部引用。
- 模块缓存:模块第一次被加载执行后,执行结果会被缓存;后续再次
require直接读取缓存,不会重复执行模块代码。
var x = 5
var addX = function (value) {
return value + x
}
module.exports.x = x
module.exports.addX = addX
var a = require('./a.js')
console.log(a.x) // 5
console.log(a.addX(1)) // 6
exports 只是 module.exports 的引用(指针):
- 正确:
module.exports = { foo: 'bar' }、exports.foo = 'bar' - 错误:
exports = { foo: 'bar' },直接对 exports 重新赋值会切断引用,导出失效。
// 错误写法,会断开引用
exports = { foo: 'bar' }
// 正确写法
module.exports = { foo: 'bar' }
exports.foo = 'bar'
CommonJS 不适合原生浏览器环境:require() 为同步阻塞加载。
- 在 Node.js 中模块读取本地硬盘,速度很快;
- 浏览器端加载模块需要走网络请求,网速波动会造成页面假死、主线程阻塞。
AMD(异步模块定义)
AMD(Asynchronous Module Definition,异步模块定义)为浏览器异步加载场景而生,代表实现库:require.js、curl.js。
核心思想:动态创建 <script> 标签实现异步脚本加载,内置依赖管理,全部依赖加载完成后执行回调工厂函数。
核心特性:
- 依赖前置:定义模块时,数组参数预先声明全部依赖。
- 异步并行加载:多个依赖模块并行下载,不会阻塞浏览器主线程。
- 回调执行:全部依赖加载完毕,执行工厂回调函数。
define(['a'], function(myLib){
function foo(){
myLib.doSomething()
}
return {
foo : foo
}
})
// 第一个参数为依赖模块数组;第二个为加载完成后的回调
require(['a', 'b', 'c'], function (a, b, c){
// 模块加载完成后执行业务逻辑
});
CMD(通用模块定义)
CMD(Common Module Definition,公共模块定义)由阿里玉伯提出,SeaJS 是这套规范的代表实现。
核心思想:底层同样是动态创建 <script> 异步加载脚本;语法风格贴近 CommonJS,和 AMD 的最大差异在于模块工厂函数的执行时机。
核心特性:
- 依赖就近:依赖不写在 define 的头部数组,在哪里用到就在哪里写
require()。 - 下载提前、执行延迟:解析阶段通过正则扫描 factory 函数源码,提取全部
require依赖,提前完成网络下载,但不会立刻执行模块;只有代码执行到对应require()语句时,才真正执行模块工厂函数,拿到导出对象。 - 类 CommonJS 同步写法:代码书写上和 Node.js 的
require保持一致。
define(function(require, exports, module) {
var a = require('./a')
a.doSomething()
if (false) {
var b = require('./b')
b.doSomething()
}
})
UMD(通用模块定义)
UMD(Universal Module Definition,通用模块定义)不属于官方标准规范,是一套兼容多环境的代码编写模式。
核心目标:让同一份 JS 代码,既可以运行在 Node.js CommonJS 环境,也可以运行浏览器 AMD 环境,降级支持浏览器全局变量。
核心特性:
- 运行时环境嗅探:代码执行时检测当前宿主环境。
- 多规范兼容:一套源码适配 CommonJS、AMD。
- 降级兜底:前面环境都不匹配,则挂载到全局
window、global。
(function (root, factory) {
// AMD 浏览器环境(require.js)
if (typeof define === 'function' && define.amd) {
define(['jquery'], factory);
}
// CommonJS Node 环境
else if (typeof exports === 'object') {
module.exports = factory(require('jquery'));
}
// 普通浏览器,挂载全局变量
else {
root.MyLib = factory(root.jQuery);
}
})(this, function ($) {
// 模块业务代码
function hello() {
return 'Hello UMD';
}
return { hello };
});
ES6 Modules(ESM)
ES6 在语言标准层面内置模块系统,是浏览器与 Node.js 通用的官方标准模块方案,取代社区诞生的 CommonJS、AMD。
核心特性:
- 静态结构:
import、export必须写在顶层作用域;编译阶段就可以分析依赖,支持 Tree-Shaking。 - 实时引用绑定:导出的是变量的引用,不是值拷贝;模块内部修改,外部导入可以拿到最新值。
- 默认严格模式:ESM 模块自动开启
use strict。 - 完善循环引用处理:编译阶段构建完整依赖图,通过引用绑定处理循环依赖。
备注:CommonJS、AMD 属于运行时加载;ESM 属于编译时静态分析。
// profile.js
export var firstName = 'Michael'
export function multiply(x, y) {
return x * y
}
function func1() {}
function func2() {}
// 使用 as 重命名导出
export {
func1 as myFunc1,
func2 as myFunc2
}
import { firstName, multiply, myFunc1 } from './profile.js'
// 动态导入,返回 Promise,运行时按需加载
import('./profile.js').then(module => {
module.multiply(2, 3)
})
// await 动态导入
const module = await import('./profile.js')
其他常用语法:
// 整体模块加载
import * as Utils from './profile.js'
// 默认导出
export default {
firstName,
multiply
}
import Utils from './profile.js'
// 导出转发(复合写法)
export { foo, bar } from './my_module.js'
// 全部重导出
export * from './my_module.js'
// 获取模块元信息
console.log(import.meta.url)
Node.js 模块体系
Node.js 同时支持 CJS 和 ESM,通过文件名后缀与 package.json 的 type 字段区分:
.mjs:始终解析为 ESM;.cjs:始终解析为 CommonJS;.js文件:package.json设置"type": "module",按 ESM;缺省或"type": "commonjs",按 CommonJS(Node 默认)。
// package.json
{
"type": "module"
}
注意:ESM 顶层不可以直接调用 require(),可以通过 createRequire 工具函数创建 require 对象;CJS 没有命名导出,ESM import 加载 CJS 模块,只能获取默认导出;命名导入会得到 undefined。
import { createRequire } from 'module'
const require = createRequire(import.meta.url)
const lodash = require('lodash')
各模块规范对比
| 特性 | CommonJS | AMD | CMD | UMD | ES6 Modules |
|---|---|---|---|---|---|
| 加载方式 | 同步 | 异步 | 异步 | 运行时判断环境 | 文件下载异步,静态 import 执行同步;支持动态 import() |
| 适用环境 | Node.js | 浏览器 | 浏览器 | 通用兼容 | 浏览器、Node.js 通用官方标准 |
| 核心语法 | require、module.exports | define、require | define、require | 环境嗅探兼容 | import、export |
| 依赖处理 | 运行时解析 | 依赖前置,加载完立即执行 | 依赖就近,加载完延迟执行 | 多套兼容 | 编译时静态分析 |
| Tree-Shaking | 不支持 | 不支持 | 不支持 | 不支持 | 支持 |
| 标准化 | 社区规范 | 社区规范 | 社区规范 | 兼容模式 | ECMAScript 语言官方标准 |
| 导出形式 | 值拷贝 | 对象返回 | 对象返回 | 多模式适配 | 实时引用绑定 |
CJS 和 ESM 关键差异:
- CJS 导出是值拷贝;ESM 导出是实时引用绑定。
- CJS 运行时加载、解析;ESM 编译时静态构建依赖图。
- CJS
require()同步;ESM 的静态 import 执行同步,文件下载是异步,动态import()返回 Promise。 - CJS 顶层
this指向模块本身;ESM 顶层this为undefined。
模块循环依赖
循环依赖指两个或多个模块互相导入对方,形成闭环依赖链。
简单示例:a.js 导入 b.js,同时 b.js 又导入 a.js。
带来的现实问题:
- 初始化顺序歧义:闭环依赖,无法直观判断模块执行先后顺序。
- 未完全初始化访问:访问还没执行完毕的模块导出,拿到未赋值变量。
- 运行时报错:调用尚未挂载的函数,抛出
is not a function。 - 死循环风险:如果没有缓存保护,会无限递归加载模块(主流实现都有缓存避免死循环)。
- 调试困难:逻辑正常,但部分变量值为
undefined,堆栈难以定位根源。
最佳实践:尽量从设计层面规避循环依赖,拆分公共逻辑抽离到第三方公共模块,切断闭环;循环依赖运行时可以被兼容处理,但不推荐主动设计这类结构。
CJS 的循环依赖处理机制
CJS 的核心防护是模块缓存机制:
- 调用
require()进入模块,在模块代码执行之前,就创建空的module.exports = {}对象存入全局模块缓存。 - 后续再次
require该模块,直接返回缓存中的exports对象,不会重新执行模块源码,避免死循环。 - CJS 导出是值拷贝:只有执行到赋值语句,属性才挂载到
exports;循环引用时,拿到的是半成品对象。 - 模块后续对
exports的赋值,会同步修改缓存对象;但已经拷贝出去的变量不会自动更新。
// main.js
require('./a.js')
console.log('done')
// a.js
var moduleB = require('./b.js')
console.log('a 模块读取 moduleB.b =', moduleB.b)
try {
moduleB.funcB()
} catch (e) {
console.error('调用 funcB 异常:', e.message)
}
module.exports = {
a: 'a字符串',
funcA: function () {
console.log('执行 funcA')
}
}
// b.js
var moduleA = require('./a.js')
console.log('b 模块读取 moduleA.a =', moduleA.a)
try {
moduleA.funcA()
} catch (e) {
console.error('调用 funcA 异常:', e.message)
}
module.exports = {
b: 'b字符串',
funcB: function () {
console.log('执行 funcB')
}
}
执行步骤拆解:
- 执行
main.js,调用require('./a.js'),进入a.js;创建a.js的空exports={}放入缓存。 a.js第一行执行require('./b.js'),进入b.js;创建b.js的空exports={}放入缓存。b.js执行require('./a.js'),此时a.js已经存在缓存,直接返回a.js的半成品空 exports 对象,不会再次执行a.js。b.js打印moduleA.a→undefined;尝试调用moduleA.funcA(),对象上不存在该函数,抛出TypeError: moduleA.funcA is not a function。b.js继续向下执行,给自身module.exports挂载b、funcB;b.js执行完毕返回给a.js。- 回到
a.js,打印moduleB.b可以正常拿到b字符串;调用moduleB.funcB()正常执行。 a.js执行完毕,把{a, funcA}写入a.js缓存的 exports;main.js打印 done,程序结束。
总结:循环依赖不会死循环,依靠缓存保护;但互相引用的模块有可能拿到尚未赋值的半成品对象,出现 undefined、调用函数报错。
ESM 的循环依赖处理机制
ESM 分为三个阶段:解析、加载、执行
- 解析阶段:扫描全部
import、export,构建完整模块依赖图,建立变量引用绑定关系,此时不执行任何业务代码。 - 加载阶段:网络、文件系统加载全部依赖模块。
- 执行阶段:按照深度优先顺序执行模块代码。
关键特性:
- ESM 导出不是值拷贝,是实时引用绑定;外部导入拿到的是变量引用,模块内部赋值变化,外部可以感知。
- 遇到循环依赖,不会重复加载模块,直接复用已经解析完成的模块记录。
- 函数声明在 ESM 模块会被提升,绑定提前建立;
var变量声明提升,但赋值不会提升;let、const存在暂时性死区。
// main.js
import './c.js'
console.log('done')
// c.js
import { d, funcD } from './d.js'
console.log('c 读取 d =', d)
funcD()
export var c = 'c字符串'
export function funcC () {
console.log('执行 funcC')
}
// d.js
import { c, funcC } from './c.js'
console.log('d 读取 c =', c)
funcC()
export var d = 'd字符串'
export function funcD () {
console.log('执行 funcD')
}
执行步骤拆解:
main.js解析,发现导入c.js,加载c.js。c.js解析,发现导入d.js,加载d.js。d.js解析,发现导入c.js;c.js已经在依赖图中,不再重复加载,建立引用绑定。- 深度优先,先执行
d.js:funcC是函数声明,绑定已经提升完毕,可以直接调用;- 变量
c声明已提升,但尚未赋值,打印输出undefined; - 执行
funcC(),可以正常输出「执行 funcC」; - 给
d变量赋值为d字符串,funcD函数注册完成。
d.js执行完毕,回到c.js:d.js已经完整执行,读取d得到d字符串,调用funcD()正常执行;- 执行赋值
c = 'c字符串'。
c.js执行完毕,回到main.js,打印 done。
总结:循环依赖不会死循环;函数声明可以正常调用;变量如果还没有执行赋值,读取得到 undefined;一旦完成赋值,所有导入方可以读到最新值。
Webpack 对循环依赖的处理
Webpack 不会创造新模块规范,只是把 ESM、CJS 编译为浏览器可运行代码,内部实现 __webpack_require__。
var installedModules = {};
function __webpack_require__(moduleId) {
// 缓存已有 → 直接返回
if (installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
// 创建空模块:空 exports + 未加载完成标记
var module = installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {} // 空对象!循环依赖时就返回这个半成品
};
// 执行模块代码
modules[moduleId].call(
module.exports,
module,
module.exports,
__webpack_require__
);
// 标记加载完成
module.l = true;
// 返回导出对象
return module.exports;
}
编译 CJS:编译后保持值拷贝行为;循环依赖拿到空对象,访问未赋值属性得到 undefined,调用未挂载函数报错。
编译 ESM:编译时维护引用绑定;函数提前挂载可以调用;变量只声明不赋值,未赋值读取为 undefined,赋值后全部引用同步更新。

注意:Webpack 是编译模拟,底层是 __webpack_require__;忠于规范行为,但和 Node 原生 ESM 实现有细微差异。
小结
前端模块化发展经历 IIFE 闭包 → CJS、AMD、CMD → UMD,最终演进到 ESM。
2026 年现状:ESM 是官方标准,浏览器原生支持、Node.js 主推;CJS 存量项目数量庞大,生态成熟;AMD、CMD 已经基本淘汰,仅存在于历史旧项目中。