进阶篇:模块化编程——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
})()

函数内部的 num1num2 属于局部变量,不会泄露到全局;同时内部可以访问全局变量。闭包为模块提供私有状态,这是早期前端实现模块隔离最普遍的手段。

局限:仅实现作用域隔离,缺少标准化的导入导出、依赖解析机制,大型项目下手动管理依赖成本很高。

CommonJS(CJS)

CommonJS 是服务端 JavaScript 的模块规范,Node.js 原生实现这套规范。

核心思想:通过 module.exportsexports 向外导出接口;使用 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.jscurl.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。
  • 降级兜底:前面环境都不匹配,则挂载到全局 windowglobal
(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。

核心特性

  • 静态结构importexport 必须写在顶层作用域;编译阶段就可以分析依赖,支持 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.jsontype 字段区分:

  • .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 顶层 thisundefined

模块循环依赖

循环依赖指两个或多个模块互相导入对方,形成闭环依赖链。

简单示例: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')
    }
}

执行步骤拆解

  1. 执行 main.js,调用 require('./a.js'),进入 a.js;创建 a.js 的空 exports={} 放入缓存。
  2. a.js 第一行执行 require('./b.js'),进入 b.js;创建 b.js 的空 exports={} 放入缓存。
  3. b.js 执行 require('./a.js'),此时 a.js 已经存在缓存,直接返回 a.js半成品空 exports 对象,不会再次执行 a.js
  4. b.js 打印 moduleA.aundefined;尝试调用 moduleA.funcA(),对象上不存在该函数,抛出 TypeError: moduleA.funcA is not a function
  5. b.js 继续向下执行,给自身 module.exports 挂载 bfuncBb.js 执行完毕返回给 a.js
  6. 回到 a.js,打印 moduleB.b 可以正常拿到 b字符串;调用 moduleB.funcB() 正常执行。
  7. a.js 执行完毕,把 {a, funcA} 写入 a.js 缓存的 exports;main.js 打印 done,程序结束。

总结:循环依赖不会死循环,依靠缓存保护;但互相引用的模块有可能拿到尚未赋值的半成品对象,出现 undefined、调用函数报错。

ESM 的循环依赖处理机制

ESM 分为三个阶段:解析、加载、执行

  • 解析阶段:扫描全部 import、export,构建完整模块依赖图,建立变量引用绑定关系,此时不执行任何业务代码。
  • 加载阶段:网络、文件系统加载全部依赖模块。
  • 执行阶段:按照深度优先顺序执行模块代码。

关键特性

  • ESM 导出不是值拷贝,是实时引用绑定;外部导入拿到的是变量引用,模块内部赋值变化,外部可以感知。
  • 遇到循环依赖,不会重复加载模块,直接复用已经解析完成的模块记录。
  • 函数声明在 ESM 模块会被提升,绑定提前建立;var 变量声明提升,但赋值不会提升;letconst 存在暂时性死区。
// 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')
}

执行步骤拆解

  1. main.js 解析,发现导入 c.js,加载 c.js
  2. c.js 解析,发现导入 d.js,加载 d.js
  3. d.js 解析,发现导入 c.jsc.js 已经在依赖图中,不再重复加载,建立引用绑定。
  4. 深度优先,先执行 d.js
    • funcC 是函数声明,绑定已经提升完毕,可以直接调用;
    • 变量 c 声明已提升,但尚未赋值,打印输出 undefined
    • 执行 funcC(),可以正常输出「执行 funcC」;
    • d 变量赋值为 d字符串funcD 函数注册完成。
  5. d.js 执行完毕,回到 c.js
    • d.js 已经完整执行,读取 d 得到 d字符串,调用 funcD() 正常执行;
    • 执行赋值 c = 'c字符串'
  6. 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,调用未挂载函数报错。

微信图片_20231112214641 编译 ESM:编译时维护引用绑定;函数提前挂载可以调用;变量只声明不赋值,未赋值读取为 undefined,赋值后全部引用同步更新。

微信图片_20231112214652

注意:Webpack 是编译模拟,底层是 __webpack_require__;忠于规范行为,但和 Node 原生 ESM 实现有细微差异。

小结

前端模块化发展经历 IIFE 闭包 → CJS、AMD、CMD → UMD,最终演进到 ESM。

2026 年现状:ESM 是官方标准,浏览器原生支持、Node.js 主推;CJS 存量项目数量庞大,生态成熟;AMD、CMD 已经基本淘汰,仅存在于历史旧项目中。

© lizhao all right reserved,powered by Gitbook文件修订时间: 2026-08-22 13:47:31

results matching ""

    No results matching ""