如果你曾经好奇:

  • 为什么 <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> 添加两个属性:asyncdefer(都是异步下载,不阻塞 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 的全局函数。它不会立即下载依赖,只做三件事:

  1. 收集模块信息(id、依赖列表、工厂函数)。
  2. 注册到内部缓存(不下载)。
  3. 等待 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); });

内部流程:

  1. 解析依赖路径 → 完整 URL。
  2. 检查缓存,未加载则准备加载。
  3. 动态创建 <script> 标签插入 <head>,浏览器异步请求文件。
  4. 等待所有依赖下载并执行(每个依赖文件执行 define 注册导出值)。
  5. 所有依赖就绪后,调用回调,传入模块导出值。

在这里插入图片描述

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.jsdefine 注册导出 → 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));

内部原理:

  1. 路径转绝对路径。
  2. 调用 fs.readFileSync 同步读取文件内容(本地几毫秒)。
  3. 将代码字符串包裹进函数并立即执行
  4. 执行 module.exports = ...,赋值导出对象。
  5. 返回 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 做法:

  1. 从入口文件分析所有 require/define,生成依赖关系图。
  2. 将所有文件内容用“包裹函数 + 缓存”的方式合并成一个 bundle.js
  3. bundle.js 中实现一个浏览器可跑的 require 函数。
  4. 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?

  1. 兼容老旧浏览器:打包工具可将代码转成 ES5,让所有浏览器能跑。
  2. 性能优化:合并小文件,减少请求数,配合代码分割(code splitting)实现首屏按需加载。
  3. Tree Shaking:静态分析,摇掉未使用的导出代码,减小体积。
  4. 处理非 JS 资源:原生 ESM 只能导入 JS,打包工具允许 import './style.css' 或图片等。
  5. 开发体验(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.jsaVal=1,调用 bFuncbFunc 通过活绑定读到 aVal=1,返回 2。

注意: 若在 bFunc 中访问 aVala.js 尚未执行到赋值,aValundefined。解决办法:把逻辑放在函数内,等模块全部执行完再调用。

在这里插入图片描述

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 = 10x00A11
  • 活绑定记录了变量所在的存储位置(栈地址),读取时直接取该地址当前值。
  • 对于引用类型,活绑定记录栈地址(存着堆地址),通过堆地址访问对象。

简图:

栈内存                    堆内存
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 给你实时视频线。

更多推荐