一场关于「作用域隔离 × 依赖管理 × 按需加载」的二十年追寻史
一、为什么需要模块化:三大原罪
在讲演进之前,先回答一个根本问题:我们到底在对抗什么?
早期前端开发没有 "模块" 这个概念,一切 JS 都写在 script 标签里,共享同一个全局作用域。这带来了三个几乎无解的问题:
① 全局变量污染。 每个文件都在 window 上挂变量,A 文件定义的 data 可能被 B 文件覆盖,排查起来如同大海捞针。
② 依赖顺序脆弱。 文件之间的依赖靠 "手工保证 script 顺序",少引一个、顺序错了,运行时直接报错,而且必须在运行时才能发现。
③ 无法按需加载。 所有代码一次性加载进页面,哪怕只用到一个工具函数,也要下载整个脚本库。
javascript
// 史前时代:两个文件互相踩脚
// a.js
var data = [1, 2, 3];
function show() { console.log(data); }
// b.js ------ 也定义了一个 data,直接把 a.js 的覆盖了
var data = "被覆盖了!";
核心矛盾 业务复杂度不断上升,而 JS 天生只有全局作用域。模块化要解决的本质,就是把 "全局共享" 变成 "局部隔离 + 显式依赖"。
二、史前时代:全局函数与命名空间
第一代尝试非常朴素:先把代码拆成多个文件,再用「命名空间对象」减少冲突。
ini
// 命名空间模式:把变量收进一个对象里
var MyApp = {};
MyApp.utils = {
formatDate: function (d) { /* ... */ }
};
MyApp.user = {
list: [] /* ... */
};
比起散落的全局函数,这算是一种 "心理上的隔离"。但它只是减少了冲突概率 :MyApp 本身还是全局变量,任何代码都能改它;依赖关系依然要靠加载顺序来保证;一旦团队多人协作,命名空间同样会撞车(比如两个库都叫 utils)。
阶段小结 这一阶段的本质是「命名约定」,而不是「语言机制」------ 靠自觉,不靠约束。
三、觉醒时刻:IIFE 与闭包
真正的转折来自 IIFE(Immediately Invoked Function Expression,立即执行函数表达式)。它利用函数作用域 + 闭包,第一次实现了真正的「变量私有」。
javascript
// IIFE + 模块暴露模式:jQuery 时代最常见的写法
var MyModule = (function () {
// 私有变量:外部永远访问不到
var count = 0;
// 私有函数
function inc() { count++; }
// 只暴露想公开的接口
return {
add: function () { inc(); return count; },
get: function () { return count; }
};
})();
MyModule.add(); // 1
MyModule.count; // undefined ------ 访问不到私有变量
IIFE 解决了作用域隔离:模块内部的东西默认不污染全局,对外只暴露一个 "门面"。但它依然有两个硬伤:
一是依赖仍需手工注入 ------ 依赖另一个模块时,得手动把它作为参数传进来,顺序错了照样崩;二是依然全量加载,没有 "按需" 的概念。不过,IIFE 奠定的 "函数作用域 + 返回接口" 思想,成了后来所有模块方案的基因。
四、服务端之光:CommonJS
2009 年,Node.js 诞生,JS 第一次走进服务端。服务端没有 "下载脚本" 的苦恼,读取本地文件是同步且廉价的 ------ 于是 CommonJS 规范顺势而生:
javascript
// CommonJS:require 引入,module.exports 导出
// math.js
const PI = 3.14159;
function area(r) { return PI * r * r; }
module.exports = { area };
// main.js
const math = require('./math.js');
console.log(math.area(2)); // 12.56636
CommonJS 的特点:同步加载 (读文件很快)、模块缓存 (重复 require 只执行一次)、简单直观。它极大推动了 Node 生态,npm 也因此成为世界上最大的包管理器。
但把它搬到浏览器就出问题了:浏览器里 require 是同步 IO ,而网络请求是异步的、有延迟的 ------ 同步加载意味着页面会白屏卡死。浏览器需要一套异步的模块方案。
关键分叉 CommonJS 选「同步」,适合服务端;浏览器环境要求「异步」,于是催生了下一章的两大浏览器方案。
五、浏览器自救:AMD 与 CMD
2010 年前后,浏览器端模块化迎来两条路线,堪称当年的 "世纪之争"。
AMD ------ RequireJS(依赖前置,异步加载)
AMD(Asynchronous Module Definition) 由 RequireJS 推广。核心是 define + require,模块定义时就声明全部依赖,加载器并行发起网络请求,全部就绪后执行工厂函数。
javascript
// AMD:依赖前置 ------ 声明即加载
define(['jquery', './math'], function ($, math) {
// 所有依赖已就绪,才开始执行本模块
return {
render: function () { console.log($, math.area(2)); }
};
});
CMD ------ Sea.js(依赖就近,用时才执行)
CMD(Common Module Definition) 由国内玉伯主导的 Sea.js 推广。理念是 "就近依赖":只有在执行到 require 的那一行,才真正加载并执行依赖。
javascript
// CMD:依赖就近 ------ 用到才 require
define(function (require, exports, module) {
// 前面不加载任何东西,代码很"同步"
var $ = require('jquery'); // 用到时才解析
var math = require('./math');
exports.render = function () { ... };
});
| 对比维度 | AMD(RequireJS) | CMD(Sea.js) |
|---|---|---|
| 依赖声明时机 | 依赖前置,模块开头一次性声明 | 依赖就近,用到才 require |
| 加载策略 | 并行预加载所有依赖 | 按需解析,惰性执行 |
| 代码风格 | 更 "异步"、显式 | 更接近 CommonJS 的同步写法 |
| 社区 | 国际主流,生态大 | 国内为主,理念先进 |
| 结局 | 被构建工具时代与 ES Modules 取代,均淡出历史 | 被构建工具时代与 ES Modules 取代,均淡出历史 |
这两套方案解决了浏览器的异步加载问题,但写法繁琐(要包一层 define)、配置复杂(路径映射、shim),且都属于 "社区规范" 而非标准。它们最大的历史功绩,是把 "加载器" 这个概念种进了前端工程师的脑子里 ------ 后来的一切,都在想如何干掉它。
六、兼容的妥协:UMD
随着 CommonJS(Node)与 AMD(浏览器)并存,"一个库在两个环境都能用" 成了刚需。UMD(Universal Module Definition) 本质是一个环境探测包裹器:
javascript
// UMD:同一份代码兼容三种环境(简化示意)
(function (root, factory) {
// 1. AMD 环境 → 走 define
if (typeof define === 'function' && define.amd) {
define(['jquery'], factory);
}
// 2. CommonJS 环境 → 走 module.exports
else if (typeof module === 'object' && module.exports) {
module.exports = factory(require('jquery'));
}
// 3. 都没有 → 挂到全局变量
else {
root.MyLib = factory(root.$);
}
}(this, function ($) {
// 库的真实实现
return { version: '1.0.0' };
}));
UMD 是典型的 "工程妥协":它不解决任何新问题,只负责让库到处能用。今天你在 npm 上拉下来的一些老库,包里还带着 UMD 的构建产物。它被广泛使用,却也以 "三套环境判断" 的丑陋而闻名。
七、正统标准:ES Modules
2015 年 6 月,ECMAScript 2015(ES6) 正式把 import / export 写进语言标准。至此,模块化终于从 "社区方案" 升格为 "语言特性"。这才是真正的分水岭。
javascript
// ES Modules:语言级别的模块化
// math.js
export const PI = 3.14159;
export function area(r) { return PI * r * r; }
// main.js ------ import 必须写在顶层,路径必须是字符串
import { area } from './math.js';
console.log(area(2)); // 12.56636
ES Modules 之所以是 "正统",在于它有三个决定性优势:
① 静态分析。 import 必须在模块顶层、依赖名必须是字符串字面量 ------ 这保证了依赖关系在代码解析阶段 就能被完整看清,无需执行。编译器可以据此做优化,其中最著名的就是 Tree-shaking(摇树优化):把没被 import 到的导出代码在打包时 "摇掉",减小产物体积。
② 天然异步 + 浏览器原生支持。 <script type="module"> 让浏览器直接支持模块化,加载天然异步、自带 defer 语义,不再依赖任何加载器。
③ 严格模式 + 循环依赖处理。 ESM 模块默认处于严格模式;它还设计了 live binding(实时绑定),让循环引用也能部分工作 ------ 这在 CommonJS 里是著名的坑。
| 对比维度 | CommonJS | ES Modules |
|---|---|---|
| 加载时机 | 运行时(require 执行时才加载) | 编译期静态分析 + 运行时取值 |
| 输出方式 | module.exports 整体赋值 | 具名 / 默认导出,实时绑定(live binding) |
| 同步 / 异步 | 同步(适合服务端) | 异步(适合浏览器,原生支持) |
| Tree-shaking | 不支持(动态 require) | 天然支持 |
| 循环依赖 | 容易踩坑 | live binding 部分兜底 |
八、工程化时代:模块化 × 构建工具 × 包管理器
ESM 是标准,但浏览器的原生支持来得并不快(Safari/Edge 直到 2018-2019 才跟上)。在这个窗口期,构建工具扛起了 "把任何模块写法编译成浏览器能跑的代码" 的大旗,前端正式进入工程化时代。
8.1 打包器:三座里程碑
| 工具 | 诞生年份 | 核心哲学 | 标志性贡献 |
|---|---|---|---|
| Webpack | 2012 | 一切皆模块(CSS、图片、字体都是模块) | loader /plugin 生态,SPA 事实标准 |
| Rollup | 2015 | 面向 ESM 的打包器 | Tree-shaking 的极致践行者 |
| Vite | 2020 | 开发用原生 ESM,构建用 Rollup | 开发服务器毫秒级冷启动(esbuild 预构建) |
Webpack 让 "模块" 的概念从 JS 扩展到一切静态资源,配合 code splitting(代码分割)实现真正的按需加载 ------ 这是它在 2015-2020 年统治前端的原因。
Rollup 则证明了一条更轻的路线:既然 ESM 是可静态分析的,打包时就能把没用到的代码精确剔除,产出更纯净的产物。它因此成为库(library)发布的首选。
Vite 的出现代表思维反转:开发时不打 bundle,直接用浏览器原生 ESM 加载源码,仅用 esbuild 把 node_modules 里的依赖预构建成 ESM;生产构建再交给 Rollup。冷启动从 "秒" 级降到 "毫秒" 级。
8.2 包管理器:从 npm 到 pnpm
模块化离不开依赖分发。npm(2010)首创 registry 模式;yarn(2016)用锁文件与离线缓存解决一致性;pnpm(2017) 用内容寻址存储 + 硬链接,让同一版本的依赖在全局只存一份,磁盘占用和安装速度都大幅优化 ------ 这背后,是对 "node_modules 黑洞" 的持续反抗。
8.3 模块化的极致形态:微前端
当 "一个应用 = 一个模块图" 还不够,业界把模块化的思想延伸到应用之间:
运行时集成 :qiankun(蚂蚁,2019)、single-spa,通过 JS 沙箱 + 样式隔离让多个独立部署的子应用共存于一个基座。
构建时 / 运行时共享 :Webpack 5 Module Federation(2020),让两个独立构建的应用在运行时互相 "借" 模块 ------ 既不用一起打包,又能共享依赖,是微前端里 "共享" 问题的优雅解。
一条暗线 从文件模块 → 应用模块 → 微前端,模块化的边界一直在 "向外扩",但底层逻辑始终不变:隔离作用域、声明依赖、按需加载。
演进时间线

九、各方案横向对比
| 方案 | 出现时间 | 加载方式 | 运行环境 | 静态分析 | Tree-shaking | 现状 |
|---|---|---|---|---|---|---|
| 全局 / 命名空间 | 1995+ | 同步、全量 | 浏览器 | ✗ | ✗ | 已被淘汰 |
| IIFE | ≈2005 | 同步、全量 | 浏览器 | ✗ | ✗ | 仅历史代码 |
| CommonJS | 2009 | 同步 | Node.js | ✗(运行时 require) | ✗ | 服务端仍为主流 |
| AMD | 2011 | 异步 | 浏览器 | ✗ | ✗ | 被构建工具取代 |
| CMD | 2011 | 异步 / 惰性 | 浏览器 | ✗ | ✗ | 被构建工具取代 |
| UMD | 2014 | 环境探测 | 多端兼容 | ✗ | ✗ | 老库产物中常见 |
| ES Modules | 2015 | 异步 | 浏览器 + Node | ✓ | ✓ | 事实标准 |
表格里能看出清晰的趋势:越晚出现的方案,静态分析能力越强(从 "运行时才知道依赖" 到 "编译期就知道依赖"),这也正是性能优化的底层来源。
十、现在与未来
站在 2026 年回看,模块化正在走向 "标准收敛 + 少打包" 的方向:
① Import Maps(2021,Chrome 89 起支持)。 浏览器原生支持把 import 'vue' 这样的裸模块名映射到具体 URL,不再需要打包器帮忙做 "模块名解析":
xml
<!-- import map:浏览器直接理解裸模块名 -->
<script type="importmap">
{
"imports": {
"vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js"
}
}
</script>
<script type="module">
import { createApp } from 'vue'; // 浏览器原生解析
</script>
② ESM CDN / bundler-less。 esm.sh、esm.run、jspm 等服务把 npm 包直接转换成浏览器可用的 ESM 并托管,配合 Import Maps,一个 index.html 就能跑起应用,开发期可以完全 "零打包"。
③ Rust 重写构建链。 Rolldown(Rust 版 Rollup)、Turbopack(Rust,Next.js 14 默认)、esbuild 已经证明了 "编译速度" 还有数量级的提升空间。构建工具不会消失,但会越来越快、越来越薄。
未来主线 「标准(ESM、Import Maps)负责基础能力,构建工具只负责标准未覆盖的优化」------ 模块化的最终形态,大概率是浏览器原生为主、打包为辅 。
十一、总结:一条主线
如果把二十多年的演进压缩成一句话,它其实只围绕三条主线螺旋上升:
| 主线 | 起点 | 演进路径 | 终点形态 |
|---|---|---|---|
| 作用域隔离 | 全局变量共享 | 命名空间 → IIFE 闭包 → CJS/AMD 模块作用域 → ESM 模块作用域 | 语言级隔离 |
| 依赖管理 | 手工保证 script 顺序 | require/define 显式声明 → import 静态声明 → Import Maps 原生解析 | 编译期可见 |
| 按需加载 | 全量加载 | AMD/CMD 异步回调 → 代码分割 → Tree-shaking → 原生 ESM 按需 | 浏览器原生按需 |
每一次 "方案之争"(AMD vs CMD、CommonJS vs ESM、Webpack vs Vite),本质都是这三条主线在不同阶段的不同侧重。而最终,标准会吞并一切自定义方案------ 这是前端模块化演进最确定的历史规律。
下次再面对 "该不该引入一个新模块化工具" 的争论时,不妨先问一句:它在三条主线的哪一条上,比现状多解决了什么?