JavaScript 模块化完整进化史
如果你曾经好奇:
- 为什么
<script>标签顺序错了页面就报错?- RequireJS 为什么能自动去服务器上“要”文件?
- CommonJS 在 Node.js 里为什么能“同步”拿到另一个文件的内容?
- ES Modules 的
import和 CommonJS 的require到底有什么不同?- Webpack、Vite 这些工具为什么现在还离不开?
那么这篇文章就是为你准备的。
0. 总地图:模块化进化 5 个时代
| 时代 | 时间 | 核心目标 | 代表技术 |
|---|---|---|---|
| 1. 远古时代 | 1995~1998 | 在网页上跑点简单 JS | 内联 <script> |
| 2. 分文件时代 | 1998~2010 | 复用代码、利用缓存 | <script src="..."> |
| 3. 性能优化时代 | 2009~至今 | 不让 JS 阻塞页面渲染 | async / defer |
| 4. 第三方模块化 | 2010~2015 | 自动管理依赖、避免全局污染 | RequireJS, CommonJS, Webpack |
| 5. 原生模块化 | 2015~至今 | 浏览器原生支持模块 | ES Modules (type="module") |
1. 时代 1:远古时代(1995~1998)
痛点: JS 只是小脚本,没人在意“组织代码”。代码直接写在 <script> 标签里,没有复用,难以维护。
<script>
console.log("Hello JavaScript");
</script>

→ 项目变大后,一个文件几百上千行,维护困难。于是人们想把不同功能放进不同文件。

2. 时代 2:分文件时代(1998~2010)
解法: <script src="..."> 将 JS 抽到独立文件,实现复用和缓存。
<script src="utils.js"></script>
<script src="main.js"></script>

好处: 多页面共用、浏览器缓存。
新问题:
- 阻塞渲染:遇到
<script>会停止解析 HTML,下载并执行完才继续,导致白屏。
- 依赖地狱:必须手动保证依赖顺序(如
utils.js在前),否则报错add is not defined。
3. 时代 3:性能优化时代(2009~至今)
目标: 不让 JS 阻塞渲染,但暂不解决依赖顺序。
浏览器给 <script> 添加两个属性:async 和 defer(都是异步下载,不阻塞 HTML 解析)。
async:下载完立刻执行,谁快谁先,顺序不保证。适合独立脚本(如统计、广告)。
defer:等 HTML 解析完,按出现顺序依次执行。适合大多数业务脚本。⚠️ 注意:
async/defer只解决加载性能,依赖管理问题依然存在。

4. 时代 4:第三方模块化方案(2010~2015)
目标: 自动管理依赖、按需加载、避免全局污染。
两大派别:
- AMD(RequireJS):浏览器端,异步加载模块。
- CommonJS:服务器端(Node.js),同步读取本地文件。

4.1 AMD / RequireJS 模块化原理
4.1.1 核心 API
| API | 职责 | 使用场景 |
|---|---|---|
define |
定义一个模块 | 写功能模块时使用 |
require |
加载模块并使用 | 入口文件或业务代码 |
4.1.2 define 原理
define 不是原生语法,是 RequireJS 的全局函数。它不会立即下载依赖,只做三件事:
- 收集模块信息(id、依赖列表、工厂函数)。
- 注册到内部缓存(不下载)。
- 等待
require触发加载。
// 无依赖
define([], function() { return { add: (a,b) => a+b }; });
// 有依赖
define(['jquery'], function($) { $('#btn').click(() => alert('Hi')); });
- 工厂函数只在第一次被
require时执行,之后返回缓存结果。

4.1.3 require 原理(真正发请求)
require(['./a', './b'], function(a, b) { console.log(a, b); });
内部流程:
- 解析依赖路径 → 完整 URL。
- 检查缓存,未加载则准备加载。
- 动态创建
<script>标签插入<head>,浏览器异步请求文件。 - 等待所有依赖下载并执行(每个依赖文件执行
define注册导出值)。 - 所有依赖就绪后,调用回调,传入模块导出值。

4.1.4 协同工作示例
// math.js
define([], function() { return { add: (a,b) => a+b }; });
// main.js
require(['math'], function(math) { console.log(math.add(2,3)); });
时序: 页面加载 RequireJS → require 动态创建 <script src="math.js"> → 下载执行 math.js → define 注册导出 → RequireJS 缓存并调用回调。

4.1.5 一句话总结
define:注册模块信息,不发请求。require:动态创建<script>发请求,加载依赖,执行回调。
4.1.6 常见误解澄清
| 误解 | 事实 |
|---|---|
define 会立即下载依赖 |
不会,只注册 |
| 依赖数组顺序可以任意 | 必须与回调参数顺序对应 |

4.2 CommonJS —— 为什么能“同步拿到”?
Node.js 内置模块系统,文件在本地硬盘。
// utils.js
module.exports = { add: (a,b) => a+b };
// main.js
const utils = require('./utils.js');
console.log(utils.add(1,2));
内部原理:
- 路径转绝对路径。
- 调用
fs.readFileSync同步读取文件内容(本地几毫秒)。 - 将代码字符串包裹进函数并立即执行。
- 执行
module.exports = ...,赋值导出对象。 - 返回
module.exports。
浏览器不能用 CommonJS,因为没有
fs.readFileSync,网络请求天然异步。

4.3 核心揭秘:为什么一个模块“就有值了”?(包裹函数、执行、缓存)
无论 AMD、CommonJS 还是 ES Modules,都依赖三个基本操作:
4.3.1 包裹函数
模块加载器将文件代码包在一个函数里,防止全局污染并注入 exports, require, module 等。
// 原始 a.js
const name = 'module A';
module.exports = { name };
// 包裹后
function (exports, require, module, __filename, __dirname) {
const name = 'module A';
module.exports = { name };
return module.exports;
}

4.3.2 执行函数
第一次 require 时,立刻执行包裹函数,执行赋值语句,得到导出对象。

4.3.3 缓存结果
将 module.exports 存入内部缓存(键为文件绝对路径)。后续 require 直接返回缓存对象,不再重新执行。
const cache = { '/project/utils.js': { add: ... } };
你能拿到另一个文件的东西,是因为那份代码已执行过,结果被缓存了。

4.4 打包工具(Webpack)的兴起
浏览器不原生支持 AMD/CommonJS 语法,打包工具应运而生。
Webpack 做法:
- 从入口文件分析所有
require/define,生成依赖关系图。 - 将所有文件内容用“包裹函数 + 缓存”的方式合并成一个
bundle.js。 - 在
bundle.js中实现一个浏览器可跑的require函数。 - HTML 只需一个
<script src="bundle.js">,依赖顺序自动解决。
打包工具本质上是在模拟一个模块化系统。

5. 时代 5:现代原生模块化(2015~至今)
目标: 浏览器原生支持模块,不再需要第三方库和复杂打包。
ES6 标准加入 import / export:
<script type="module" src="app.js"></script>
// utils.js
export function add(a, b) { return a + b; }
// app.js
import { add } from './utils.js';
console.log(add(1, 2));
5.1 ES Modules 工作三阶段:构建 → 实例化 → 求值
① 构建阶段(异步并行下载)
浏览器遇到 <script type="module"> 时:
- 异步下载入口
app.js(不阻塞页面)。 - 解析
import语句,立即并行下载所有依赖文件,递归形成依赖图。 - 比 RequireJS 串行请求快得多。

② 实例化阶段(建立“实时绑定”)
为每个模块创建模块环境记录,记录导出内容,并建立导入与导出之间的活绑定(不拷贝值,而是指向原变量的地址连接)。
比喻: 像视频通话,看到的是实时画面,不是照片。

③ 求值阶段(执行代码)
从入口模块开始执行代码,export 赋值语句真正把值放到变量中。因为绑定已建立,import 的地方直接可用。所有依赖提前异步下载,写起来像同步。
ES Modules 天然支持循环依赖(因为连接是活的)。

5.2 动态 import() —— 执行到才发请求
if (someCondition) {
const module = await import('./heavy.js');
module.run();
}
- 返回 Promise,执行到该行时才请求文件(异步)。
- 适合路由懒加载、按需加载。
区分: 静态
import→ 代码运行前全部下载好;动态import()→ 需要时才下载。

6. 关键区别:CommonJS 与 ES Modules 导出的值
6.1 CommonJS:值的拷贝(浅拷贝,像“照片”)
// counter.js
let count = 1;
function increment() { count++; }
module.exports = { count, increment };
// main.js
const { count, increment } = require('./counter.js');
console.log(count); // 1
increment();
console.log(count); // 仍然是 1 ❌
原因: require 将 { count: 1, increment } 浅拷贝给了 main.js。基本类型拷贝的是值,后续原模块的 count 改变不影响已拷贝的副本。
- 若导出的是对象,
require拷贝的是对象引用,修改属性会反映,但整体替换不会。
比喻: 拍张照片,10 年后人变了,照片不变。

6.2 ES Modules:实时绑定(活连接,像“视频通话”)
// counter.js
export let count = 1;
export function increment() { count++; }
// main.js
import { count, increment } from './counter.js';
console.log(count); // 1
increment();
console.log(count); // 2 ✅ 实时更新
原因: import 建立的是指向原变量内存地址的绑定,读取时永远取当前值。
比喻: 视频通话,对方白板上数字改了,你立刻看到新数字。

6.3 对比总结
| 对比项 | CommonJS (require) |
ES Modules (import) |
|---|---|---|
| 导出值的本质 | 值的拷贝(浅拷贝) | 实时绑定(活连接) |
| 基本类型 | 拿到旧值副本,后续修改不影响 | 导入变量实时更新 |
| 对象/数组 | 拷贝引用,修改属性会反映,整体替换不会 | 完全实时,任何修改都反映 |
| 能否修改导入变量 | 可以(但不推荐) | 不可以(只读) |
| 比喻 | 照片 | 视频通话 |

一句话: CommonJS 给“复印件”,ES Modules 给“实时窗口”。
7. 为什么有了原生 ESM 还要用 Webpack / Vite?
- 兼容老旧浏览器:打包工具可将代码转成 ES5,让所有浏览器能跑。
- 性能优化:合并小文件,减少请求数,配合代码分割(code splitting)实现首屏按需加载。
- Tree Shaking:静态分析,摇掉未使用的导出代码,减小体积。
- 处理非 JS 资源:原生 ESM 只能导入 JS,打包工具允许
import './style.css'或图片等。 - 开发体验(Vite):开发时利用浏览器原生 ESM 实现极快冷启动;生产构建仍用 Rollup 打包以获得优化。
结论: 原生 ESM 是未来,但生产环境打包工具仍是必需品。

8. 循环依赖
8.1 什么是循环依赖?
模块 A 依赖 B,模块 B 依赖 A,形成环:
// a.js
import { bFunc } from './b.js';
// b.js
import { aVal } from './a.js';
8.2 CommonJS 的处理(简单但脆弱)
CommonJS 只导出当前已执行的部分。执行顺序可能导致拿到不完整或旧值,容易出 bug,不推荐依赖。

8.3 ES Modules 的处理(优雅可靠)
利用“先连接 → 后求值”两阶段:
- 连接阶段:扫描所有
import/export,建立活绑定(指向内存地址的引用),不执行代码。 - 求值阶段:从入口执行,遇到
import变量直接通过引用读取当前值。即使被依赖模块尚未执行完,只要最终赋值,引用就能看到最新值。
示例:
// a.js
import { bFunc } from './b.js';
export let aVal = 1;
console.log(bFunc()); // 2
// b.js
import { aVal } from './a.js';
export function bFunc() { return aVal + 1; }
执行过程:连接阶段建立绑定 → 执行 a.js,aVal=1,调用 bFunc → bFunc 通过活绑定读到 aVal=1,返回 2。
注意: 若在
bFunc中访问aVal时a.js尚未执行到赋值,aVal为undefined。解决办法:把逻辑放在函数内,等模块全部执行完再调用。

8.4 对比总结
| 特性 | CommonJS | ES Modules |
|---|---|---|
| 依赖处理阶段 | 运行时同步 | 编译时建立链接,运行时求值 |
| 循环依赖支持 | 有限(返回部分导出) | 良好(活绑定) |
| 导出值 | 值的拷贝(快照) | 实时引用 |
通俗总结: 循环依赖像两人互相等着对方先给东西。CommonJS “先给一半先用”易乱;ES Modules 事先说好“只记抽屉位置,东西随时放进去都能实时看到”,因此不怕循环依赖。

9. 深入理解:变量、内存与活绑定
9.1 JS 变量与内存基础
- 基本类型(number, string 等):变量直接存值在栈上。
- 对象(包括数组、函数):变量存地址(指针),指向堆内存中的对象。
赋值/传参时:
- 基本类型:拷贝值。
- 对象:拷贝地址(指向同一对象)。

9.2 CommonJS 导出的本质(值/引用快照)
let count = 1;
module.exports.count = count; // 把值 1 拷贝给 exports.count
setTimeout(() => count = 2, 1000);
外部拿到的是拷贝的 1,后续原 count 改变不影响 exports.count。导出对象时拷贝地址,修改属性会反映,但整体替换不会。
比喻: 把数字写在纸条上给人,自己改数字,纸条不变。

9.3 ES Modules 导出的本质(内存地址绑定)
export let count = 1 不是导出值 1,而是导出变量 count 的内存地址绑定。外部 import { count } 拿到的是指向该地址的引用,永远读到最新值。
比喻: 在家门口挂白板写数字,别人直接看白板,你改数字他们实时看到。

9.4 堆栈内存与活绑定的关系
- 栈内存也有地址(如
0x00A1),let count = 1在0x00A1存1。 - 活绑定记录了变量所在的存储位置(栈地址),读取时直接取该地址当前值。
- 对于引用类型,活绑定记录栈地址(存着堆地址),通过堆地址访问对象。
简图:
栈内存 堆内存
0x00A1: 1 <--- count (基本类型)
0x00B2: 0x1000 <--- obj
|
v
0x1000: {x:1} (对象)
ES Module 活绑定:
- import 的 count 直接绑定到 0x00A1
- import 的 obj 绑定到 0x00B2(再通过 0x1000 访问对象)
最终结论: 活绑定保存的是通往变量本身的路径(存储位置),而不是变量当时持有的值。这是 ES Module 实现实时更新的根本原因。

10. 最终总结
| 时代 | 时间 | 要解决的问题 | 代表技术 | 现状 |
|---|---|---|---|---|
| 内联 JS | 1995~1998 | 写点简单交互 | <script> 内部 JS |
淘汰 |
| 分文件 JS | 1998~2010 | 代码复用、利用缓存 | <script src> |
仍在使用 |
| 性能优化 | 2009~至今 | 不让 JS 阻塞渲染 | async / defer |
常用 |
| 第三方模块化 | 2010~2015 | 自动管理依赖、避免全局污染 | RequireJS, CommonJS, Webpack | 渐被取代 |
| 原生模块化 | 2015~至今 | 浏览器原生支持模块 | ES Modules (type="module") |
主流 |
一句话核心
JavaScript 从“直接写在页面”到“分文件”,再到“第三方模块化”,再到“浏览器原生模块化”——每一次变化,都是为了解决上一个时代的痛点。
而这一切的背后,函数包裹 + 执行 + 缓存是永远不变的基石。
CommonJS 给你复印件,ES Modules 给你实时视频线。
更多推荐

所有评论(0)