JavaScript 模块化演进史
2026-01-28 12:00
模块化不是“语法糖”,而是工程规模增长后对可维护性、依赖管理与交付方式的必然回答。理解模块化的演进,本质是在理解:代码如何被组织、如何被加载、如何被链接、如何被打包与发布。
JavaScript 作为一种脚本语言,从诞生之初就面临一个棘手问题:如何组织和管理代码?早期 JS 代码往往塞在一个文件里,全局变量满天飞,命名冲突频发,维护起来像噩梦。随着 Web 应用从简单页面转向复杂系统,模块化成了刚需。
你可能经常看到这些词:CommonJS、AMD、CMD、UMD、ESM、tree-shaking、bundle、import maps、package.json 的 exports……它们看似分散,其实在回答同一组问题:
- 我怎么把代码拆成多个文件并复用?
- 依赖怎么声明?谁依赖谁?
- 模块什么时候加载?同步还是异步?
- 运行时怎么找到模块?靠文件系统、URL,还是打包产物?
- 生产环境怎么交付:很多小文件,还是一个大包?
为了讲清楚,我们按时间线走一遍模块化的演进史吧。
为什么 JS 需要模块化?
想象一下 2000 年代的 JS 项目:所有代码写在 <script> 标签里或一个大文件,依赖顺序手动控制,变量全局共享。结果?
HTML<script src="/a.js"></script> <script src="/b.js"></script> <script> window.add = (a, b) => a + b; </script>
- 命名冲突:不同文件都想要
utils、config、add - 依赖顺序:
b.js依赖a.js,就必须保证先引入a.js - 难以测试:全局状态很难隔离
- 难以复用:把一段逻辑拿到另一个项目,要连同“全局约定”一起搬
- 团队协作苦:大文件容易 merge 冲突,难并行开发。
所以模块化的核心目标就是:
- 解耦:把代码拆分成多个文件,每个文件只负责一部分功能。
- 复用:像搭积木一样导入模块。
- 依赖管理:明确模块之间的依赖关系,避免“全局污染”。
- 按需加载:提升性能,尤其浏览器端。
从 2009 年起,社区开始标准化模块化,演进出多种规范。下面我们逐一拆解。
早期自救:命名空间与 IIFE(立即执行函数)
命名空间(Namespace)
把所有东西塞进一个对象里,避免污染全局:
JSwindow.App = window.App || {}; window.App.math = { add(a, b) { return a + b; }, };
好处:冲突减少了。坏处:依赖仍靠“约定”,模块边界依旧模糊。
IIFE:造一个私有作用域
用函数作用域封装内部实现,只暴露少量 API:
JSwindow.App = window.App || {}; window.App.store = (function () { let count = 0; function inc() { count += 1; return count; } return { inc }; })();
这一招很重要:它把“私有实现”与“公共 API”分开了。它几乎就是后续模块系统的雏形。但依赖管理仍然是手工的:如果 store 依赖 math.js,你还是得自己保证加载顺序。当前依赖关系并未成为“语言或运行时的一等公民”,而只是隐藏在代码和 script 顺序中的隐式约定。
在很长一段时间里,依赖顺序并不是被“解决”,而只是通过 script 顺序和构建期约定被“维持”。
CommonJS: Node.js 的“老大哥”
2009 年,Ryan Dahl 发明 Node.js 时,JS 还没有官方模块系统。Node 社区推出 CommonJS(简称 CJS)规范,迅速成为服务器端事实标准。npm 生态的爆炸式增长,全靠它支撑。它的核心用法是:
- 导出:用
module.exports或exports.xxx。 - 导入:用
require('path')。 - 支持默认导出:
module.exports = add;→const add = require('./math'); - 支持动态路径:
require(./${fileName});
JS// math.js(导出) const add = (a, b) => a + b; module.exports = { add }; // 或 exports.add = add; // main.js(导入) const { add } = require('./math'); // 相对路径或 npm 包名 console.log(add(2, 3)); // 5
导入流程
CommonJS 的 require 不是简单复制代码,而是有完整的加载流程。这设计源于 Node.js 的文件系统操作,确保高效和可靠。
- 解析路径:require 先解析模块路径(相对/绝对/npm 包)。Node 用 module.paths 数组查找(从当前目录向上到 node_modules)。
- 加载文件:找到文件后,Node 用 fs 模块读取文件内容。
- 执行代码:读取到代码后,Node 用 vm 模块在沙箱环境执行。
- 缓存模块:执行完成后,Node 把模块对象缓存起来,后续 require 直接返回缓存。 验证缓存:
JS// counter.js let count = 0; module.exports = { increment: () => ++count, getCount: () => count }; // main.js const mod1 = require('./counter'); const mod2 = require('./counter'); // 第二次 require,读缓存 mod1.increment(); console.log(mod1.getCount()); // 1 console.log(mod2.getCount()); // 1(共享同一对象)
对象共享问题与解决方案
由于导出的是 module.exports 的引用(指向堆中对象),多次 require 得到同一对象。修改一方会影响所有导入处——这叫“共享状态副作用”。
JS// shared.js module.exports = { value: 0 }; // main1.js const shared = require('./shared'); shared.value = 42; // main2.js const shared = require('./shared'); console.log(shared.value); // 42(被 main1 修改)
**为什么会有这个问题?**因为 CommonJS 返回的是 module.exports 的引用,多次 require 得到的是同一个对象实例,因此修改会在所有引用方之间共享。 解决方案:避免直接修改导出对象,而是导出“不可变值”(如函数、对象冻结等)。
为什么 CommonJS 要这样实现?
CommonJS 的设计背景是 Node.js 服务端:
- 同步加载:Node 是服务器环境,文件 IO 快,阻塞无大碍。简化 API,无需回调/Promise。
- 运行时加载:允许动态(如环境变量决定导入),灵活性高。
- 长生命周期进程:Node 进程一般会长时间运行,模块也会长期存在。
- 缓存机制:性能优化,避免重复 IO/编译。为什么值拷贝/引用共享?平衡效率和简单——导出对象引用节省内存,但需开发者小心。
- 需要共享:
- 全局状态(如配置、缓存)
- 跨模块共享(如事件总线)
- 连接池
- logger
- 进程级状态 例如:
JS// db.js module.exports = { pool: createPool(), };
缺点 & 2026 年现状
- 不适合浏览器(同步阻塞)。
- 无 tree-shaking(死代码消除),打包体积大。
- 2026 年:遗留项目和老 npm 包还在用。新 Node 项目建议 ESM,但 CJS 不会灭绝(兼容性太强)。
- Node.js 的核心哲学之一是:Don't break the ecosystem。 CJS 是 Node 最底层、最稳定的 ABI 之一,一旦废弃,会造成灾难级破坏所以Node 永远会继续支持 CommonJS。
AMD:浏览器端的“异步救星”
浏览器不能像 Node 那样同步加载(会冻结页面),2010 年左右 RequireJS 和 SeaJS 推出 AMD(Asynchronous Module Definition)规范,专治浏览器模块化痛点。AMD 曾风靡一时,尤其在 Backbone/Angular 1.x 时代。
核心语法:
- define:定义模块。
- require:加载模块。
JS// math.js define(function () { // 或 define(['dep'], function(dep) {}) const add = (a, b) => a + b; return { add }; // 返回导出 }); // main.js define(['./math'], function (math) { // 依赖数组前置 console.log(math.add(2, 3)); // 5 }); // 或全局 require 加载 require(['./math'], function (math) { ... });
特点与原理
- 异步加载:依赖通过回调加载,不阻塞主线程。
- 依赖前置:在 define 中声明依赖,加载器会提前拉取。
- 提前执行:依赖模块加载后立即执行。
- 需要 loader(如 RequireJS)注入 define/require 函数。
但是因为:
- 语法繁琐(回调地狱)。
- 不支持服务器端。
- 打包工具(如 Webpack、Rollup)兴起,AMD 被内置模块系统取代。
CMD: 国服路线(SeaJS 的风格)
CMD 是淘宝前端团队(玉伯)在推广 Sea.js 时提出的规范(2011–2013 年左右)。它本质上是 AMD 的“中国变种”,针对 AMD 的“依赖前置 + 提前执行”痛点做了优化,更接近 CommonJS 的“就近依赖 + 延迟执行”风格。
CMD 当时在阿里系内部和国内很多团队非常流行(Sea.js vs RequireJS 是当年经典对决),但国际影响力较小,最终被 ESM 取代。
核心语法:
- define:定义模块,但不强制依赖前置。
- require:在 factory 函数内部动态 require(延迟执行)。
JS// math.js define(function(require, exports, module) { var add = function(a, b) { return a + b; }; // 内部动态 require(延迟加载) var multiply = require('./multiply'); // 只在需要时加载 exports.add = add; module.exports = { add: add }; // 支持 module.exports 风格 }); // main.js define(function(require) { var math = require('./math'); // 就近依赖 console.log(math.add(2, 3)); // 5 });
特点与原理
- 就近依赖 + 延迟执行:依赖在 factory 函数内部写 require,只有真正用到时才加载/执行。
- AMD:依赖前置(define(['dep1', 'dep2'])),所有依赖提前下载 + 执行。
- CMD:依赖就近(require('dep')),按需加载 + 执行。
- 异步加载:适合浏览器,Sea.js 提供 loader 实现。
- 兼容 CommonJS 风格:factory 函数参数是 require、exports、module(类似 Node 的 wrapper)。
- 循环依赖:处理更友好(延迟执行让循环依赖更容易解决)。
CMD vs AMD
- AMD:依赖前置(先声明 deps)
- CMD:依赖就近(用到再 require)
两者都服务于浏览器异步加载的时代,只是取舍不同:AMD 更利于提前并行加载,CMD 更符合写代码的直觉。
UMD:万能兼容方案
当时的现实是:同一个库要同时跑在:
- 浏览器脚本(全局变量)
- AMD(RequireJS)
- CommonJS(Node)
UMD(Universal Module Definition)出现,作为“万金油”兼容多种环境。
核心语法就是一个模板,判断环境自动切换:
JS(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 { add: (a, b) => a + b }; }));
它的特点与原理是:
- 兼容性极强:浏览器全局、AMD、CJS 一网打尽。
- 本质是 wrapper:运行时检测环境。
为什么 现在不需要 UMD 了?
- 打包工具已普及:Webpack/Rollup 会自动处理多环境兼容
- ESM 成为主流:现代库直接输出 ESM + CJS 双包(通过
exports字段) - 构建工具智能化:tsup、unbuild 等工具一键生成多格式产物
如果你还在手写 UMD,说明你的工具链该升级了 😄
ESM(ES Modules):现代 JS 的官方标准(异步、静态分析
2015 年 ES6 标准引入 ESM(ES Modules),作为官方模块系统。浏览器从 2017 年起原生支持,Node.js 在 2019–2020 年稳定兼容(v12+)。ESM 结束了模块化战争。它的核心语法是:
- 导出:export / export default。
- 导入:import(静态)或 import()(动态)。
JS// math.mjs export const add = (a, b) => a + b; // 命名导出 export default function multiply(a, b) { return a * b; } // 默认导出 // main.mjs import { add } from './math.mjs'; // 静态导入 import multiply from './math.mjs'; // 默认导入 import * as math from './math.mjs'; // 全部导入 // 动态导入(返回 Promise) const { add: asyncAdd } = await import('./math.mjs');
它的关键特点是:
- 静态分析:import/export 必须在模块顶层(不能在 if/函数里),编译时(打包工具阶段)就能确定整个依赖树。
- 异步加载:浏览器
<script type="module">非阻塞,Node 也支持异步解析。 - 值的 live binding:导出是实时引用,修改导出值会立即影响所有导入处(不像 CJS 的快照拷贝)。
- 严格模式默认:自动开启 strict mode。
- top-level await:模块顶层直接 await(Node 14+)。
- 循环依赖:支持,但静态分析让问题更易暴露(需小心设计)。
那么与 AMD 和 CJS 比好在哪?
加载方式:同步 vs 异步 vs 异步 + 静态
CommonJS 对服务端友好但是浏览器致命,因为我们要知道 CommonJS 并不是“不阻塞”,而是“阻塞成本在服务端是可控的、可一次性消化的”。它阻塞的是“进程初始化阶段”或“首次 require 阶段”,而不是而不是高频的请求处理路径。在 Node 中:
JS// app.js const express = require('express'); const router = require('./router'); const db = require('./db'); const app = express(); app.listen(3000);
这些 require() 只在进程启动时执行一次,发生在服务监听端口之前,请求还没进来之前。这段时间的阻塞没有用户请求和 SLA 压力,这是一次性成本,不是运行时成本。并且 CJS 会缓存模块导出,后续 require 不会重复执行。在 Node 中主要的瓶颈在于:
- HTTP 请求
- 数据库查询
- RPC 调用
- 网络 IO
相比之下几百个 JS 文件同时加载的启动延迟是可以接受到,但是在浏览器中
<script>同步加载会阻塞 UI 线程,再加上可能会出现网络延迟、模块数量过多和每次刷新可能会重新加载,用户体验会直线下降这是无法接受的。
你可能会问那如果在请求处理中 require 呢?
JSapp.get('/api', (req, res) => { const heavy = require('./heavy'); // ❌ res.send(heavy.run()); });
这里的主要问题是每次请求都会触发 require ,即使有缓存也会出现路径解析等同步成本,这会导致阻塞事件循环,并发下明显拖慢吞吐量。所以永远不要在高频请求路径中使用 require。
服务端可以接受“慢启动”,浏览器不能接受“慢首屏”。
AMD 专为浏览器而生,它通过异步加载解决了“阻塞”问题。它的核心逻辑是:“先声明,后执行”。
JS// 浏览器加载过程 // 1. 解析依赖数组 ['./a', './b'] // 2. 动态创建 <script> 标签异步下载 a.js 和 b.js // 3. 等两个文件都下载并执行完,再回调 factory 函数 define(['./a', './b'], function(a, b) { return { run: () => a.do() + b.do() }; });
在那个没有打包工具的年代,AMD 是浏览器端的唯一答案。但它也有明显的代价:
- “包裹感”太重:每个文件都要套一层
define,代码显得臃肿。 - 管理负担:依赖数组和 factory 参数必须严格一一对应,错位一个就是灾难。
- 网络开销:模块越多,HTTP 请求越多。在 HTTP/1.1 时代,这会导致明显的延迟。
AMD 解决了“不阻塞”,但带来了“模板代码”和“复杂的异步编排”成本。
ESM 是终极方案,它在语言层面实现了“异步加载 + 静态分析”。
与 CJS 和 AMD 不同,ESM 的加载分为三个阶段:构建(Parsing)、实例化(Instantiating)、运行(Evaluating)。
- 构建:浏览器从入口文件开始,静态分析
import语句,递归生成一张完整的“依赖关系图”。注意:此时代码还没运行,只是在拉取文件。 - 实例化:根据依赖图,在内存中分配空间,并将导出和导入“链接”在一起(即 Live Binding)。
- 运行:最后才真正执行代码。
JS// ESM 的优势:编译时确定依赖 import { add } from './math.js'; // 打包工具(如 Vite/Rollup)通过静态分析, // 知道你只用了 add,没用 subtract,从而把 subtract 删掉(Tree Shaking)。
ESM 的伟大之处在于:它让模块系统从“运行时的逻辑”变成了“编译时的结构”。
ESM 消费 CJS:天然的互通性
在 Node.js 环境中,ESM 引入 CJS 是被官方完美支持的,因为 ESM 诞生之初就考虑到了生态兼容。
JS// 在 ESM 模块中 (app.mjs) import fs from 'node:fs'; // 引入内置 CJS 模块 import path from 'path'; // 引入第三方或本地 CJS // 甚至可以直接解构 import { readFileSync } from 'node:fs';
适配逻辑与问题:
- 默认导出映射:CJS 的
module.exports会被映射为 ESM 的default导出。 - 命名导出识别:Node.js 会通过静态分析(cjs-module-lexer)尝试识别 CJS 中的
exports.xxx,让你能直接import { xxx }。但如果 CJS 是动态生成的导出,静态分析会失败,此时你只能通过import pkg from '...'然后再解构。 - 不可混用
require:在 ESM 文件中,require关键字是不存在的。如果你非要用,得通过createRequire(import.meta.url)手动造一个出来。
| 维度 | CommonJS (CJS) | AMD (RequireJS) | ESM (ES Modules) | 谁完胜?(2026) |
|---|---|---|---|---|
| 加载方式 | 同步阻塞 | 异步回调 | 异步 + 静态分析(非阻塞 + 高效) | ESM |
| 依赖解析时机 | 运行时(动态灵活) | 运行时(前置) | 编译时(tree-shaking 神器) | ESM(打包体积最小) |
| 执行时机 | 同步顺序 | 提前执行(可能浪费) | 延迟 + 可预测 | ESM |
| tree-shaking 支持 | ×(无法静态分析) | × | ✓(极致优化) | ESM |
| 语法简洁度 | 中等(require 简单) | 差(回调地狱) | 优秀(import/export 最干净) | ESM |
| 浏览器原生支持 | ×(必须打包) | ×(必须 loader) | ✓(<script type="module">) | ESM |
| Node 支持 | 原生默认 | × | 原生 + 双端统一 | ESM |
| 动态导入 | 支持(条件 require) | 支持 | 支持(import() Promise) | ESM(更现代) |
| 值拷贝/引用 | 值快照(拷贝) | 值拷贝 | live binding(实时引用) | ESM(更灵活) |
| top-level await | × | × | ✓(模块顶层 await) | ESM |
| 2026 年生态占比 | 遗留项目 + 老 npm 包(~30%) | 几乎 0 | 新项目 95%+,npm 主导 | ESM 完胜 |
浏览器原生 ESM:不仅仅是 import
虽然我们习惯了用 Webpack/Vite 打包,但现代浏览器已经原生支持 ESM。你只需要在 <script> 标签上加一个属性:
HTML<script type="module"> import { init } from './app.js'; init(); </script>
但原生 ESM 在浏览器中运行有几个硬性规则:
- CORS 限制:模块脚本必须通过 CORS 协议加载。如果你直接双击打开 HTML(
file://协议),浏览器会报错。 - 路径必须明确:不能写
import { x } from 'lodash'(这叫 Bare Specifier),必须写完整的路径./lodash.js或 URL。 - 默认延迟执行:
type="module"的脚本默认带有defer属性,会等待 HTML 解析完再运行。
Import Maps:浏览器的“别名表”
为了解决上面提到的“不能写包名”的问题,W3C 引入了 Import Maps。它允许你在 HTML 中定义一个映射表:
HTML<script type="importmap"> { "imports": { "utils": "/js/shared/utils.js", "lodash": "https://cdn.jsdelivr.net/npm/[email protected]/lodash.js" } } </script> <script type="module"> import { debounce } from 'lodash'; // 现在可以这样写了! </script>
Node.js 的 ESM 转型:一场长跑
Node.js 对 ESM 的支持经历了一个漫长且痛苦的过程,因为 CJS 实在太根深蒂固了。在 2026 年的今天,你主要需要掌握这三点:
1. type: module
在 package.json 中设置 "type": "module" 后,该项目下所有的 .js 文件都会被视为 ESM。如果你想保留几个 CJS 文件,可以改后缀为 .cjs。
2. exports 字段:现代包的守门人
传统的 main 字段只能定义一个入口。现在的包建议使用 exports,它不仅能定义入口,还能根据环境自动切换:
JSON{ "name": "my-lib", "exports": { ".": { "import": "./dist/index.mjs", // ESM 加载时用这个 "require": "./dist/index.cjs" // CJS 加载时用这个 }, "./utils": "./dist/utils.js" // 支持子路径导出 } }
3. 双包陷阱(Dual Package Hazard)
当一个 npm 包同时提供 ESM 和 CJS 两个版本时,如果你的项目通过不同方式引入了它,可能会导致同一个包在内存中存在两个完全独立的实例。这就是所谓的"双包陷阱"。
JS// 某个库 my-state-lib 的 package.json { "name": "my-state-lib", "exports": { "import": "./dist/index.mjs", // ESM 入口 "require": "./dist/index.cjs" // CJS 入口 } } // my-state-lib 的实现(简化版) let globalCounter = 0; export function increment() { globalCounter++; } export function getCount() { return globalCounter; } // 使用时 // a.js (CJS 模块) const { increment } = require('my-state-lib'); // 加载 CJS 版本 increment(); increment(); // b.mjs (ESM 模块) import { getCount } from 'my-state-lib'; // 加载 ESM 版本 console.log(getCount()); // 期望输出 2,实际输出 0 ❌
为什么会这样:
require('my-state-lib')走的是 ./dist/index.cjs,在 CJS 模块缓存中保存了一个实例import 'my-state-lib'走的是 ./dist/index.mjs,在 ESM 模块缓存中保存了另一个实例- 两个缓存系统是完全隔离的,所以
globalCounter有两份,互不影响
这会导致一些比如常见的单例模式的库出现问题:
- 状态管理库(zustand):如果一个组件通过 CJS 引入,另一个通过 ESM 引入,它们会操作两个不同的 store
- 数据库连接池: 可能创建两个连接池,浪费资源且难以调试
- 事件总线: 一个模块发布事件,另一个模块订阅不到(因为是两个实例)
而它目前的解决方案有:
- 库作者: 建议只提供 ESM 版本,或者在
package.json中声明exports字段,明确指定 CJS 入口(如./dist/index.cjs)。 - 使用者: 可以通过
import引入 ESM 版本,或者使用require(esm)引入 CJS 版本。
核心原则:双包陷阱本质是"模块系统不统一"导致的缓存隔离。最彻底的解决方案是全面 ESM 化,但在过渡期,库作者和使用者都需要额外小心。
现代转折点:Node.js 支持 require(esm)
在很长一段时间里,CJS 项目无法直接 require 一个 ESM 模块,这被称为 Node.js 生态中最深的沟壑。如果你尝试这么做,Node 会抛出著名的 ERR_REQUIRE_ESM 错误。
但在 2024 年,Node.js 22(以及 v20.19.0+)终于支持了同步的 require(esm)。这是一个划时代的改进,因为它允许 CJS 项目直接消费 ESM 包,而不需要强迫整个项目重构。
它是如何适配的?
Node.js 实现了一个“同步 ESM 图”加载器。当你执行 require('./module.mjs') 时:
- Node 会解析该模块及其所有依赖。
- 它会同步执行这些模块(就像 CJS 一样)。
- 最终返回一个 Module Namespace Object(模块命名空间对象)。
JS// CJS 脚本 const math = require('./math.mjs'); console.log(math.add(1, 2)); // 正常运行!
存在什么问题与限制?
虽然这看起来像魔法,但它有几个硬性的“坑”:
-
Top-Level Await (TLA) 是死穴: 如果被引入的 ESM 模块(或者它的任何子依赖)使用了
await顶层声明,require会立即报错ERR_REQUIRE_ASYNC_MODULE。因为require本质是同步的,它无法等待异步操作。这意味着 带有异步初始化逻辑的 ESM 包仍然无法被 CJS 直接引入。 -
导出结构的微小差异:
require(esm)返回的是命名空间对象。这意味着如果 ESM 只有export default,在 CJS 里你必须通过require('./file.mjs').default来访问。这与习惯上的require行为略有不同,可能导致一些心智负担。 -
生态系统的版本滞后: 虽然 Node 22 支持了,但很多生产环境还在运行 Node 16 或 18。这意味着库作者如果想兼容这些旧版本,仍然需要提供 CJS 版本,
require(esm)并不能立刻消灭“双包”现象。
Node.js 22 引入了受限的同步 require(esm) 能力,主要用于缓解 CJS 遗留项目的迁移压力,但并不改变 Node 对 ESM 作为未来主流的推荐方向。
总结:如何选择模块化方案?
演进史告诉我们,技术的发展总是在“灵活性”与“确定性”之间寻找平衡。
-
如果你在写应用(业务代码): 始终使用 ESM。配合 Vite、Webpack 或 TS,这是目前最现代、工具链支持最好的选择。
-
如果你在写库(供他人使用): 优先输出 ESM 以支持 Tree Shaking。如果需要兼容旧的 Node 生态,利用
exports提供 CJS 降级方案。 -
如果你在做极简的原型或脚本: 浏览器端可以直接用 原生 ESM + Import Maps;Node 端可以直接写
.mjs或在package.json里开type: module。
模块化的核心不在于你用
require还是import,而在于你是否建立了一个**“可预测、可追踪”**的依赖网络。
如果你看到这里,谢谢你花时间阅读。希望这篇演进史能帮你理清那些看似混乱的 JS 模块化术语。