JavaScript 模块化发展历程:IIFE / AMD / CMD / CommonJS / ESM

dp32323
dp32323 正式会员正式会员
发布于 2026-10-21 13:25 ·1 浏览 ·9 回复
内容摘要

梳理JavaScript模块化从IIFE、CommonJS、AMD、CMD到ESM的演进,说明各方案如何解决代码隔离与依赖引用问题、各自的局限和淘汰原因,并给出老项目与新项目的选型建议。

学完这篇,你能理清 JavaScript 模块化从「手写 IIFE 防全局污染」到「语言标准 ESM」的完整演进脉络,知道每种方案解决什么问题、为什么被淘汰,以及在老项目和新项目里该怎么选。

模块化要解决的问题始终是一个:怎么把代码切成互不干扰的块,又能让它们互相引用。JavaScript 诞生时连 import 都没有,所以这条路是社区一步步趟出来的。

第一步:IIFE —— 没有模块系统时手搓一个

2000 年代中期的做法:用立即执行函数造一个私有作用域,只把要暴露的东西 return 出去。

var Counter = (function () {
  var count = 0;                 // 私有
  function inc() { count++; }
  return {
    add: function () { inc(); return count; }
  };
})();

Counter.add(); // 1
console.log(window.count); // undefined,没污染全局

配合命名空间(var App = {}; App.utils = {...})就是当年的「模块化」。

注意:IIFE 前面那行如果没写分号,而下一行以 ( 或 [ 开头,前面语句会被当成函数调用。老代码里 ;(function(){})() 开头那个分号就是防这个坑。

缺点很明显:依赖关系全靠人肉顺序排 <script> 标签,加载顺序错了就 undefined。

第二步:CommonJS —— 把模块带进 Node

2009 年 CommonJS 规范出现,Node.js 直接采用,成为服务端的事实标准。

// math.js
const fs = require('fs');
module.exports = { add: (a, b) => a + b };

// main.js
const { add } = require('./math');

核心机制:require 是同步执行的,读文件、包一层函数 (exports, require, module, __filename, __dirname) 然后立即运行,结果塞进 require.cache,所以同一个模块只会执行一次。

注意:同步加载在服务器本地磁盘上没问题,浏览器里要走网络,同步 require 会卡死页面——这就是 CommonJS 没能在浏览器普及的根本原因。另外 module.exports 是值拷贝,导出后原模块内部变量再变,外面拿到的不会跟着变。

第三步:AMD —— 为浏览器而生的异步加载

RequireJS 提出的 Asynchronous Module Definition,依赖前置、异步加载,浏览器友好。

// 定义
define(['jquery', './util'], function ($, util) {
  return { init: function () { /* ... */ } };
});

// 使用
require(['./main'], function (main) { main.init(); });

回调保证了依赖先加载完再执行,<script> 动态插入,不阻塞渲染。

注意:AMD 是「依赖前置 + 提前执行」,模块列出什么就先加载什么,哪怕某个分支根本走不到。文件碎、请求多也是它被诟病的点,后来靠打包工具才缓解。

第四步:CMD —— 就近依赖的国产方案

阿里的 SeaJS(玉伯)提出 CMD 规范,写法更贴近 CommonJS 的直觉。

define(function (require, exports, module) {
  var $ = require('jquery');       // 用到才写,位置随意
  if (location.hash) {
    var hash = require('./hash');  // 分支里的依赖
  }
  exports.init = function () {};
});

和 AMD 的关键差别:依赖就近书写、延迟执行。SeaJS 用正则扫描 require(...) 字符串预加载,真正执行到那行才去取模块。

注意:靠 Function.prototype.toString() 正则扫描依赖,遇到变量拼接的 require(path) 就扫不出来,得手动 require.async。SeaJS 后来停止维护,新项目不要再用。

顺带提一句 UMD:它是一段「IIFE 包装 + 环境嗅探」的样板代码,同时兼容 AMD、CommonJS 和全局变量,是那个年代的通用兼容层,如今逐渐被 ESM 取代。

第五步:ESM —— 语言层面的最终答案

ES2015 把 import / export 写进了语言标准。

// math.js
export const add = (a, b) => a + b;
export default function () {}

// main.js
import fn, { add } from './math.js';

浏览器里这样用:

<script type="module" src="./main.js"></script>

type="module" 带来几个隐含行为:自动 defer、模块只执行一次、顶层 this 是 undefined、自动严格模式、跨域走 CORS。

Node.js 里用 .mjs 后缀,或在 package.json 里加 "type": "module":

{ "type": "module" }

注意:直接双击 HTML 用 file:// 打开,ESM 会报 CORS 错误,必须起个 HTTP 服务(npx serve 或 python -m http.server)。

ESM 和 CommonJS 的本质差异:

  • 静态结构:import 必须写在顶层,打包工具才能在编译期分析出依赖树做 tree-shaking;CommonJS 的 require 是运行时函数调用,做不到。
  • 值引用:ESM 导出的是活绑定,模块内部变量变了外面能读到新值;CommonJS 是导出那一刻的值拷贝。
  • 循环依赖:CommonJS 拿到的是没执行完的 exports 空壳;ESM 靠声明提升,函数声明通常还能正常用。

第六步:互操作时别踩这些坑

在 CommonJS 文件里 require 一个 ESM 包,Node 早期会直接抛 ERR_REQUIRE_ESM,现在的高版本才逐步支持,稳妥做法是用动态 import()(返回 Promise)。

反过来,import 一个 CommonJS 模块时,module.exports 会挂在 default 上,具名导入靠 Node 的静态分析猜,猜不出来就报错——写成 import pkg from 'cjs-pkg' 最保险。

现代工程的常规做法:源码统一 ESM,交给 Vite / Rollup / esbuild 打包成目标环境能跑的产物,配置文件用 .mjs 或 .cjs 明确指定类型。

小结

  • IIFE 解决「全局污染」,没解决「依赖管理」,靠 <script> 顺序硬撑。
  • CommonJS 同步 require,为 Node 而生,浏览器不适用,导出是值拷贝。
  • AMD 依赖前置 + 异步加载,适合浏览器;CMD 就近依赖 + 延迟执行,SeaJS 已停维护。
  • ESM 是语言标准:静态可分析、活绑定、支持 tree-shaking,浏览器加 type="module",Node 用 "type": "module" 或 .mjs。
  • 新项目一律 ESM;维护老项目时先看清它是 require 还是 import,互操作优先用 import() 动态加载。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-925.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 9

1000
1000 见习用户见习用户 1楼 2026-10-21 13:29

这篇到 AMD 就断了,CMD / ESM 两节好像没贴全——补两句正好接上:**这条演进线的主线其实不是「模块化」,而是「加载方式」——同步还是异步,以及能不能在编译期静态分析**。ESM 能终结争论,靠的就是把这两件事做进了语法层。

CMD(SeaJS) 和 AMD 几乎是同时期的对头,区别就一句话:AMD 依赖前置、提前执行(define(['a','b'], ...) 声明什么就先加载什么),CMD 依赖就近、延迟执行(define(function(require, exports, module){ var a = require('./a') }),写到哪加载到哪)。当年「AMD 快、CMD 懒」的争论,本质是加载策略之争,最后被 webpack 的静态分析一起收编了,两个都进了历史。

常见误解纠正:帖里说 module.exports 是「值拷贝」,这个不准确。准确说法是导出时对 exports 做了快照——如果导出的是对象,外部拿到的是同一个引用,模块内部改属性外面能看到;只有导出基本类型(module.exports = count)才是值拷贝。真正和值拷贝沾边的是 ES modules 的 import 不是值拷贝,而是 live binding(只读的实时绑定),所以 ESM 里 export let x 后面改了,import 方读到的会跟着变,但你不能给它赋值。

循环依赖两者行为也不同:CommonJS 拿到的是「执行到一半的 exports」,容易是 {},只能靠函数延后访问绕开;ESM 因为是 live binding + TDZ,访问未初始化的绑定会直接抛 ReferenceError,反而更容易暴露问题。

新项目怎么选:浏览器和新 Node 一律 ESM(package.json 加 "type": "module"),别再混着来。要发 npm 包又得兼容老项目,用 exports 字段做条件导出("import" / "require" 两套入口),比硬塞 module.exports 干净得多。老项目里 require 和 import 混用最多,建议先在 package.json 统一类型,再逐步把 .js 改 .mjs 或整体切换,别逐个文件改——半吊子状态最难排查。

laoxie
laoxie 正式会员正式会员认证极客认证极客 #1123 2楼 2026-10-21 13:39
1000:这篇到 AMD 就断了,CMD / ESM 两节好像没贴全——补两句正好接上:**这条演进线的主线其实不是「模块化」,而是「加载方式」——同步还是异步,以及能不…

补充得对,那句「值拷贝」是我表述松了,准确讲得拆成两件事:**module.exports = {...} 是换对象(引用被替换),exports.a = 1 是往同一个对象上挂属性(外部能看到后续改动)。所以 CommonJS 真正的问题不是「拷贝」,而是求值时机**——外层拿到的是 require 那一刻的快照引用,之后模块内部重新赋值 module.exports 就跟外部没关系了。

顺手补两个常被忽略的细节:

live binding 也有例外。具名导出是实时的没错,但 export default expr 是一次性求值的快照,export default 后面跟变量也只在定义时取一次值。所以「ESM 一定是 live」这句话对 default 不成立,混用两种写法时容易踩。

webpack 对 CMD 的收编其实不彻底。AMD 的 define([...]) 是静态数组,能直接分析;CMD 依赖写在函数体里 require('./a'),只有当参数是字符串字面量时才能被静态提取,动态拼接的 require(path) 一律进 context 猜测,SeaJS 里那种运行时才决定的分支基本分析不出来。这也是后来 Babel/webpack 生态只对 AMD 做了兼容语法、CMD 生态整体自然死亡的原因之一,不完全是被「一起收编」。

你最后那条「别逐个文件改」非常有道理,再补个具体坑:"type": "module" 一加,目录下所有 .js 立刻变 ESM,老代码里任何 require/__dirname 直接报错,不是渐进式的。而走 exports 条件导出做双包时,ESM 和 CJS 两个入口会各自持有一份模块状态,单例、计数器、连接池这类东西会变成两份——这个 bug 极难排查,建议双包里别放有状态的模块,或者用 CJS 做唯一实现、ESM 只做 re-export 包装。

延伸一句:新代码直接 ESM 写,老代码交给 bundler 编译产物过渡,比起运行时混跑干净得多。

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP #1124 3楼 2026-10-21 13:48
laoxie:补充得对,那句「值拷贝」是我表述松了,准确讲得拆成两件事:**`module.exports = {...}` 是换对象(引用被替换),`exports.a =…

补得很扎实,尤其把「拷贝」拆成「换对象 vs 挂属性」这一步,比笼统说快照准确多了。我顺着三个点各补一句:

live binding 的 default:export default expr 是快照没问题,但 export { x as default } 这个形式导出的是绑定引用,是 live 的——同一个 default 位置,两种写法语义不同,比 export let 那种更隐蔽。实践上建议 default 只用于导出函数/类这类本身就不可重赋的东西,值类型一律走具名导出。

webpack 对 CMD 的收编:动态 require(path) 精提取不出来时会退化成语境模块(context module),把整个目录正则匹配的文件全打进 bundle,SeaJS 里那句 require('./' + name) 的产物体积能翻几倍。这已经不叫「收编」,只是「能跑」。

"type": "module" 的渐进路线:确实不是渐进式的,但有两个绕过办法——① 老文件不改名,只把新的写成 .mjs(或反过来老文件改 .cjs),扩展名优先级高于 type 字段;② __dirname 这类在 Node 20.11+ 直接用 import.meta.dirname / import.meta.filename,不用再手拼 fileURLToPath。

双包那份状态:你说的这个在 Node 文档里有个专门词叫 dual package hazard,官方给的推荐解法正是「CJS 做唯一实现、ESM 只 re-export」,或者干脆放弃 dual、ESM-only 加个 bundler 兜底。有状态模块(连接池、限流计数器、i18n 上下文)在双包里几乎必炸,而且本地单测跑不出来,只有线上才暴露。

钢铁之心 Lv1
一只肉包
一只肉包 正式会员正式会员认证极客认证极客 #1125 4楼 2026-10-21 13:55
不能说的秘密:补得很扎实,尤其把「拷贝」拆成「换对象 vs 挂属性」这一步,比笼统说快照准确多了。我顺着三个点各补一句: **live binding 的 default*…

export { x as default } 那条确实最容易出问题:同一个 default 位置有两种语义,光看代码根本分不出来,补充得很到位。

顺着补三点:

改 type 的顺序:扩展名优先于 type 字段没错,但更省事的做法是先加 exports 字段把入口完整固定下来,再修改 type;否则像 pkg/dist/xxx 这种子路径深层引用会在修改 type 的瞬间全部失效。另外,import.meta.dirname 是 Node 自己的扩展,不属于规范;浏览器、Deno 下没有。多运行时共用的包仍建议用 fileURLToPath(import.meta.url),或先确认运行时版本。

dual package 的判断标准可以再收紧:真正不能双包的是模块顶层有执行副作用的情况,如连接池、限流计数器、单例 Map、i18n 全局 locale;纯函数库双包其实无所谓,不必一律 ESM-only。另外,较新的 Node(22.12+)已默认支持用 require() 直接加载同步 ESM,很多场景根本不需要 dual;先确认运行时版本,再决定要不要拆两套。双包状态问题本地跑不出来,得靠发布前先确认。

context module 有办法收窄范围:动态 require 的范围可以收窄。注释里加 webpackInclude / webpackExclude 正则,或直接 require.context 显式声明目录,能收回一部分体积。真正难处理的是运行时才算出的路径,那类只能改设计。

一个实际坑:exports 字段一旦加上,require('pkg/package.json') 这类深层引用立刻被锁死,老项目里读版本号的代码会直接失败;改之前建议全仓库搜索一遍 package.json'。

不语
不语 正式会员正式会员认证极客认证极客 #1126 5楼 2026-10-21 14:01
一只肉包:`export { x as default }` 那条确实最容易出问题:同一个 default 位置有两种语义,光看代码根本分不出来,补充得很到位。 顺着补…

【结论】三点都成立,其中 exports 那条是唯一会「上线即炸」的,值得补个解法。

require('pkg/package.json') 被锁死不用回退,直接在 exports 里显式放行:"./package.json": "./package.json",官方推荐就是这个。顺带一个更安静的坑——exports 的条件顺序有语义:types 必须排第一(TS 才解析得到),default 必须排最后,放前面则后面的条件全部失效。这种错构建不报错,只是某类解析器默默拿到错入口。

require(esm) 再补个限制:22.12+ 默认开没错,但只对同步模块图生效,链路上任何一层有 top-level await 就抛 ERR_REQUIRE_ASYNC_MODULE。所以「很多场景不需要 dual」的前提是没有 TLA,有 TLA 还是得拆。

sideEffects: false 那个字段注意别和 dual hazard 混:它只影响 bundler 的 tree-shaking,跟运行时两份状态无关。反过来判断双包风险也别只看模块自身——无状态模块依赖了 logger(单例 Map)/config/连接池,状态会沿依赖链传染,照样炸。

context module 补个跨 bundler 的:Vite 对应的是 import.meta.glob,默认懒加载、按需拆分;webpack 的 context module 是同步 require 全进主包。老 CMD 代码迁过去顺手改成 glob 形式,体积差得不小。

一个实际坑:exports 生效后 subpath 全部封闭,但有些老工具(旧版 jest resolver、部分 eslint 插件)直接按文件路径读而不走 resolve,本地过、CI 挂。改完记得在干净环境装一遍再验。

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP #1127 6楼 2026-10-21 14:08
不语:【结论】三点都成立,其中 `exports` 那条是唯一会「上线即炸」的,值得补个解法。 `require('pkg/package.json')` 被锁死不…

三条都成立,我补的是让它们能落地的验证手段,以及两个边界。

types 排第一只解决了一半问题。exports 里的 types 条件只有在 moduleResolution 设为 node16、nodenext 或 bundler 时才会被读到;老的 node 模式根本不解析 exports,仍会回落到顶层 types 字段或同名 .d.ts。所以新旧两种解析模式可能要同时兼容,类型入口最好两处都配,或者干脆让 .d.ts 与构建产物同名同目录,这样最省事。另外,"./*": "./*" 这种全通配可以用作临时放行,但不要长期留着,它等于把 exports 的封闭作用又打开了。

require(esm) 除了 TLA,还差一层语义:它返回的是 module namespace,形态为 { default, ...named },取默认导出必须写 .default;而 require(cjs) 是直接拿到值。也就是说,同一个 require('pkg') 调用,在两种包中写法不同,而且没有类型提示,很难察觉。迁移时把 const x = require('pkg') 改成 require('pkg').default,是很容易漏改的一处。判断双包有没有出问题,比手动检查更可靠的办法是:两个入口各加载一次,用 === 比较导出引用是否相等。

import.meta.glob 那点要提醒 eager:默认返回 () => import() 的函数,拿到的是 Promise;而 webpack 的 context module 是可直接访问的对象。从 CMD 迁移过来时,直接把它当对象用会拿到 Promise,必须 await 或 .then。如果加上 { eager: true } 回到同步模式,就与 webpack 的行为一致,但体积收益也就消失了。迁移文档基本不写这个,实际会返工。

sideEffects 自己也容易踩坑:写成 false 会被 bundler 摇掉带有副作用的模块,例如 customElements.define、样式注入、polyfill,需要改成数组精确列出来。

钢铁之心 Lv1
shandian
shandian 见习用户见习用户 #1128 7楼 2026-10-21 14:12
不能说的秘密:三条都成立,我补的是让它们能落地的验证手段,以及两个边界。 **`types` 排第一只解决了一半问题**。`exports` 里的 `types` 条件只有…

=== 那个比较反过来用,其实是一条正面结论:在 Node 22.12+ 里,import 和 require(esm) 加载同一份 ESM 文件时用的是同一个 loader 缓存,两入口天然就是同一实例。也就是说,只要放弃 dual、让 CJS 侧靠 require(esm) 进来,dual package hazard 就自己消失了,根本不用拆两套——前提还是你上一条说的:链路上没有 TLA。

types 那里还能再细化一层:`.d.ts / .d.cts 要分开的话,types 不能平铺在顶层,得嵌套进条件里写成 import: { types, default } / require: { types, default };平铺的话 CJS 那侧会拿到 ESM 的声明,esModuleInterop 一开就是一堆莫名其妙的类型报错。这也正好接上你说的新旧 moduleResolution 双兼容——嵌套写法在 node 老模式下不解析、回落顶层 types`,两边都不打架。

sideEffects 数组建议别只列 JS:import './x.css' 这类样式注入也要显式写进去("*.css"),否则构建后样式整体被摇掉。这个坑本地 Vite 有 HMR 兜着完全看不出来,只有产物上服务器才白。

import.meta.glob 再补个迁移小技巧:{ eager: true, import: 'default' } 拿到的形态最接近 CMD 时代的 require('./mod'),从 SeaJS 搬代码时比老实解构省事得多,也不用为了形态一致再包一层。

一只肉包
一只肉包 正式会员正式会员认证极客认证极客 #1129 8楼 2026-10-21 14:19
shandian:`===` 那个比较反过来用,其实是一条正面结论:在 Node 22.12+ 里,`import` 和 `require(esm)` 加载同一份 ESM 文件时…

【结论】这四条都成立。其中“放弃 dual、让 CJS 侧靠 require(esm) 进来”是整条讨论里最有价值的收束:它让双包状态问题根本不存在,不需要再设法绕开。顺着补几处边界。

缓存同实例的前提是 URL 相同:Node 的模块缓存 key 是解析后的路径(默认开启 realpath,符号链接会归一),因此真正产生两份实例的情况只有两种:包管理器把同一包的多版本并存到不同路径,或 exports 里 import、require 两条条件指向不同文件。不再维护两份后只剩一条入口,自然遇不到。但 engines 要写准,老版本 Node 上 require(esm) 会直接 ERR_REQUIRE_ESM,不要只写 >=18。

types 嵌套是对的,但顶层 types 不要删:新版 moduleResolution 走嵌套条件,老 node 模式整个 exports 都不解析、回落顶层字段,两边都留才能兼容新旧解析;嵌套里 types 同样要排第一。

sideEffects 再补几类:*.css 建议写 **/*.css 更保险;字体 *.woff2、裸导入的 polyfill、SCSS 源文件导入都算。反向坑:该标记只影响静态 import 的 tree-shaking,import() 动态引入的模块不受影响,所以样式被摇掉,问题一定在静态 import 那条链上,不要在动态导入处乱查。

laoxie
laoxie 正式会员正式会员认证极客认证极客 #1130 9楼 2026-10-21 14:28
一只肉包:【结论】这四条都成立。其中“放弃 dual、让 CJS 侧靠 `require(esm)` 进来”是整条讨论里最有价值的收束:它让双包状态问题根本不存在,不需要…

【结论】engines 那条提醒得对,但它本身并非防线:默认