2015年 ES6 Module(语言层面支持)
模块化需求:浏览器端 vs 服务器端
“浏览器模块化” 和 “服务器模块化” 其实目标一致(解决代码拆分、依赖管理、作用域隔离),但是 运行环境完全不同,导致了实现方式和代码写法都有差别。
根本区别:环境不同
| 特点 | 浏览器端模块化 | 服务器端模块化(Node.js) |
|---|---|---|
| 加载来源 | 通过 网络请求 加载 .js 文件(HTTP 请求) | 从 本地文件系统 读取 .js 文件 |
| 加载时机 | 必须 异步(否则浏览器阻塞,用户体验差) | 可以 同步(本地磁盘速度快,不影响太大) |
| 作用域隔离 | 防止全局污染(早期 <script> 标签都挂 window 上) | 防止变量污染(每个文件都是独立模块) |
| 依赖关系 | 浏览器需要知道“我依赖了谁”,才能按顺序请求 | Node.js 直接 require,同步解析依赖关系 |
| 运行结果 | 模块通常挂到全局(早期)或通过模块系统(AMD/ESM)返回 | 模块通过 module.exports 或 export 返回 |
代码层面的对比
服务器端
CommonJS(Node.js)
Node.js 里的模块化是 同步加载,因为读取本地磁盘文件几乎瞬间完成。
// math.js
function add(a, b) {
return a + b;
}
module.exports = { add };
// app.js
const math = require('./math'); // 同步加载
console.log(math.add(2, 3));
特点:
require会立刻读取文件,执行一次,返回module.exports。- 所以它像函数调用一样:“你要谁,就马上去拿”。
ES Module(Node.js)
// math.js
export function add(a, b) {
return a + b;
}
// app.js
import { add } from './math.js';
console.log(add(2, 3));
场景
👉 Node.js 环境。
Node.js 本身就是一个 JS 运行环境,它能直接执行 .js 文件,不需要 HTML 来“挂载脚本”。
流程:
- 你在命令行运行
node app.js(前提是你的 Node.js 开启了 ESM 支持,比如文件扩展名是.mjs或package.json设置"type": "module")。 - Node.js 发现这是一个模块脚本,会自己处理
import/export。 - 直接执行
app.js,过程中自动加载math.js。
特点:
- 在 Node.js 里,
import会从 文件系统 读取math.js
浏览器端
浏览器要从服务器获取脚本 → 必须异步,否则会卡住页面。
AMD(Asynchronous Module Definition,2009年)
- 出现背景:那时浏览器还 不支持原生模块化,只能用
<script>依次引入 JS 文件,很容易遇到依赖顺序问题。 - 解决方式:AMD 规范(RequireJS 实现)定义了一个 异步加载模块的方式。
AMD 代码示例:
<script src="require.js"></script>
<script>
// 定义模块 math.js
define('math', [], function () {
return {
add: function (a, b) { return a + b; }
};
});
// 使用模块
require(['math'], function (math) {
console.log(math.add(2, 3));
});
</script>
特点:
define()用来定义模块。require()用来异步加载模块。- 必须依赖 第三方库(require.js),浏览器本身不认识。
ES Module(ES6 原生模块,2015年)
- 出现背景:JS 语言规范(ES6)原生支持了模块化,浏览器逐渐内置实现。
- 解决方式:直接在
<script>上加type="module",就能写原生import/export。
ESM 代码示例:
<!-- math.js -->
export function add(a, b) {
return a + b;
}
<!-- main.js -->
import { add } from './math.js';
console.log(add(2, 3));
<!-- HTML -->
<script type="module" src="main.js"></script>
场景
👉 浏览器环境。
浏览器只认识 HTML,它不会直接去执行本地的 main.js,你必须通过 HTML 告诉它:
- “我要加载这个 JS 文件”
- 并且声明
type="module",否则浏览器会把import/export当成语法错误。
流程:
- 浏览器打开 HTML。
- 遇到
<script type="module">,知道这是一个 ESM 脚本。 - 去加载
main.js,里面的import { add } from './math.js'再去请求math.js。 - 模块化加载完成后执行代码。
特点:
- 原生支持,不需要第三方库。
import会发起 HTTP 请求,加载math.js(与Node.js环境下的ES Module语法相同,但运行时的“加载机制”不同)。import/export静态分析,更适合打包优化。- 默认是异步加载。
关系与差别
| 特性 | AMD(RequireJS) | ES Module(ESM) |
|---|---|---|
| 年代 | 2009 左右 | 2015(ES6 标准) |
| 依赖 | 需要 require.js | 浏览器原生支持 |
| 语法 | define() + require() | import + export |
| 加载方式 | 异步,运行时解析 | 静态分析,编译时确定依赖 |
| 优化能力 | 差,依赖图需运行时维护 | 强,能做 tree-shaking、按需加载 |
| 地位 | 过渡方案 | 长远标准 |
- AMD 的写法和
**<script type="module">**完全不同。 - 它们唯一的共同点是:都让浏览器能直接处理模块化,而不是靠 Node.js。
- 区别在于:
- AMD 属于 “历史过渡”(依赖第三方库 hack 出来的)。
- ESM 属于 “正统标准”(语言层面原生支持)。
那么一个完整项目要不要两端都模块化?
- 后端(Node.js):必须模块化(CommonJS / ES Module)。
- 前端(浏览器):同样需要模块化(AMD → ES Module)。
- 大型项目:前后端都需要,只是各自实现方式不同。
👉 可以说:
- 后端模块化:解决“服务器代码如何组织”。
- 前端模块化:解决“浏览器如何按需加载脚本”。
- 两者最终目标一致:让 JS 代码能 拆分 + 组合 + 复用。
总结
- 浏览器模块化:重点在于 异步加载(因为是网络请求)。
- 服务器模块化:重点在于 同步加载(本地磁盘读取很快)。
- 趋势:现在 ES Module 统一了前后端(语法相同,但实现机制不同)。
何为前端模块化?
前置思考
在这个时期,前端项目基于 Node.js 跑,但最终是浏览器在展示,那模块化到底由谁来负责?浏览器模块化是不是就是前端模块化?
两个阶段:开发阶段 vs 运行阶段
| 阶段 | 谁在执行 | 模块化用的是什么 | 说明 |
|---|---|---|---|
| 开发阶段 | Node.js | CommonJS(require)、ESM(import)、打包工具(Webpack、Vite、Rollup) | 我们写的代码先在 Node.js 环境里编译/打包/构建 |
| 运行阶段 | 浏览器 | ES Module(原生)、打包后的单文件(UMD/IIFE) | 浏览器拿到构建结果,解析运行 |
👉 所以:
- Node.js 负责开发阶段(模块化编译、依赖打包、转译)。
- 浏览器负责运行阶段(最终只运行能识别的 JS 格式)。
为什么要 Node.js?
以前的前端:直接 <script> 引入 JS 文件,浏览器自己运行。
现在的前端工程越来越复杂:
- 有成千上万的模块依赖(React、Vue、UI库…)。
- 需要编译(比如 ES6+ 转 ES5、TypeScript 转 JS、Sass 转 CSS)。
- 需要优化(压缩、Tree-shaking、代码分割)。
👉 浏览器本身不具备这些编译/构建能力,所以必须借助 Node.js 在本地先打包好,再交给浏览器。
浏览器模块化是谁负责的?
情况一:老式项目(无打包)
浏览器自己负责模块化(AMD/ESM)。
- 直接
<script type="module">,浏览器发起 HTTP 请求加载依赖。
情况二:现代项目(有打包工具)
打包工具(Webpack/Vite)在 Node.js 中运行,把 模块化代码打平成浏览器可执行的格式,比如:
- IIFE(立即执行函数):适合浏览器直接跑。
- UMD:兼容浏览器和 Node.js。
- ESM(保持原生):现代浏览器可直接解析。
👉 也就是说:
- 开发时的模块化 = 前端工程师用 Node.js 的“开发模块化”(写模块化代码,交给工具处理)。
- 运行时的模块化 = 浏览器端的“执行模块化”(不管是工具打包结果,还是浏览器自己加载 ESM)。
回到前置思考
浏览器模块化是不是就是前端模块化?
严格说:
- 前端模块化 = 更宽的概念,包含 “开发时(Node.js 工具处理)” + “运行时(浏览器加载执行)”。
- 浏览器模块化 = 仅指运行时阶段,浏览器如何加载模块(AMD/ESM)。
所以:
- 现代前端开发,模块化的重担主要由 Node.js 构建工具承担。
- 浏览器模块化只是最后执行结果的“载体”。
现代前端开发里,既然模块化的重担主要由 Node.js 构建工具承担,那么“浏览器模块化”到底还重要不重要?
现代主流项目(有构建工具)
- 模块化重担确实主要由 Node.js 工具链承担。
- 我们写的
import/export,其实是写给 **打包工具(Webpack/Vite/Rollup)**看的。 - 工具会在 Node.js 环境里把这些模块都分析好、打包成一个或几个 bundle 文件。
- 浏览器拿到的已经是“拼装好的结果”,比如:
- 一个大文件(IIFE/UMD 格式)
- 若干 chunk(分包后的代码,仍然用 ESM 语法衔接)
- 浏览器此时只管执行,不再需要“自己去解决模块化依赖的问题”。
- 我们写的
👉 这时候可以说:浏览器模块化退居二线,成了“兜底能力”。
无构建/轻量场景
- 并不是所有项目都用打包工具。比如:
- 小型 Demo
- 内部管理工具
- 原型验证项目
- 在这些场景下,前端开发者可能直接在浏览器里用:
<script type="module">
import { add } from './math.js';
console.log(add(1, 2));
</script>
- 这里浏览器就会自己承担模块化(原生 ESM),每个
import都触发一个 HTTP 请求。
👉 所以,浏览器模块化依然存在,并不是被完全抛弃,只是“现代大项目里,大多数工作交给了 Node.js 构建工具”。
回到问题
- 现代复杂前端项目:主要依赖 Node.js 构建工具来做模块化 → 浏览器只负责运行打包好的结果。
- 轻量级或无打包项目:浏览器依然可以用 ESM 自己搞定模块化。
所以:
✅ 现在浏览器模块化仍然存在,但在工程化项目里,它不是“核心担当”,而是“备用和兜底”。
打包对于浏览器模块化的影响
前置思考
前面说打包后就不需要浏览器模块化,不打包就需要浏览器模块化,那么“打包”和“不打包”到底解决了什么问题?为什么打包后浏览器就不用再管模块化了?
不打包:浏览器自己负责模块化
运行流程:
- 你写了 ES Module:
// main.js
import { add } from './math.js';
console.log(add(1, 2));
- 浏览器遇到
import './math.js'时,会自己发 HTTP 请求去拉取math.js文件。 - 如果
math.js里又有import './util.js',浏览器会再发一个请求去拿util.js。
特点:
- 浏览器亲自解析依赖关系,发起多个请求。
- 模块多的话,可能会发成百上千个 HTTP 请求(性能差)。
- 不支持旧浏览器(ESM 是 ES6 以后的功能)。
👉 所以“不打包”就意味着 浏览器要自己去做模块化。
打包:Node.js 构建工具负责模块化
运行流程:
- Webpack/Rollup/Vite 会在 Node.js 里先扫描所有
import/export,生成完整的依赖图。 - 工具会把所有模块“拼装”成一个或少量几个文件,输出结果可能是:
- 一个大文件(bundle.js):里面已经把所有依赖合成。
- 若干 chunk:分包优化,浏览器只需按需加载。
浏览器收到的:
- 不是一堆零散的模块文件,而是一个(或少量)已经拼好的成品。
- 浏览器直接执行这些打包好的 JS,不再需要“逐个解析依赖”。
👉 所以“打包”就意味着 模块化已经在构建阶段完成,浏览器不用再操心。
根本区别总结
| 特性 | 不打包(浏览器模块化) | 打包(Node.js 构建工具模块化) |
|---|---|---|
| 依赖解析 | 浏览器运行时解析 | 构建工具编译时解析 |
| HTTP 请求 | 每个 import都会发请求(很多) | 只请求打包好的少量文件 |
| 兼容性 | 只支持现代浏览器(支持 ESM 的) | 可转译成旧浏览器能运行的代码 |
| 优化 | 几乎没有优化 | Tree-shaking、代码分割、压缩、Polyfill 等优化 |
| 适用场景 | 小型 demo、原型项目 | 现代大型项目 |
总结
- 不打包 = 你点菜,服务员每次都跑去厨房拿一个菜回来,几十个菜就要跑几十趟。
- 打包 = 厨师在后厨一次性把你点的所有菜拼成套餐,服务员只跑一趟就上齐。
✅ 所以问题答案是:
- 打包后不需要浏览器模块化,是因为 依赖关系已经在构建阶段处理好了,浏览器拿到的是“合并后的成品”。
- 不打包时,浏览器必须自己“现场拼装模块”,所以它需要自己负责模块化。
同一个项目(打包 vs 不打包)的真实代码对比
我们用一个极简小项目来对比:
项目结构:
project/
├─ math.js
├─ util.js
└─ main.js
不打包(浏览器原生模块化)
代码就是原汁原味的 ES Module:
math.js
import { double } from './util.js';
export function add(a, b) {
return double(a + b);
}
util.js
export function double(x) {
return x * 2;
}
main.js
import { add } from './math.js';
console.log(add(2, 3));
HTML
<script type="module" src="./main.js"></script>
👉 执行过程:
- 浏览器先加载
main.js。 - 发现
import { add } from './math.js'→ 发请求加载math.js。 math.js又 import 了util.js→ 再发请求加载util.js。- 全部解析完依赖后,才运行
console.log(add(2, 3))。
特点:模块关系是浏览器在运行时自己解析的。
打包后(Webpack/Rollup 输出结果)
构建工具会把依赖图“打平”,通常打包成一个 bundle.js 文件:
bundle.js(简化示意)
// util.js 打包进来
function double(x) {
return x * 2;
}
// math.js 打包进来
function add(a, b) {
return double(a + b);
}
// main.js 打包进来
console.log(add(2, 3));
HTML
<script src="./bundle.js"></script>
👉 执行过程:
- 浏览器只加载一个
bundle.js文件,直接运行。 - 模块依赖关系在 打包阶段 已经解决好了,浏览器无需再解析。
特点:模块化逻辑在构建时完成,浏览器只管跑成品。
差异一目了然
| 对比项 | 不打包(ESM 原生) | 打包(Webpack 等) |
|---|---|---|
| 文件数 | 多个文件(math.js、util.js、main.js) | 一个或少量几个文件(bundle.js) |
| 加载方式 | 浏览器遇到 import 就发请求 | 浏览器一次性加载 bundle |
| 依赖处理 | 浏览器运行时解析 | Node.js 构建工具编译时解析 |
| 代码形态 | 保持模块化语法 | 合并/内联成普通函数调用 |
✅ 直观理解:
- 不打包:浏览器像“拼乐高”,自己把小积木(模块文件)拼起来。
- 打包:Node.js 构建工具提前帮你拼好一个完整的乐高城堡,浏览器只要“直接摆上去”。
项目的运行:不打包 vs 打包
前置思考
打包和不打包,项目分别是如何被运行的?基于Node.js开发项目的时候,通常运行npm run dev,这是干了什么?
不打包的项目(原生 ESM 模块)
1. 文件形态
就是多个 .js 模块文件 + 一个 HTML:
<script type="module" src="./main.js"></script>
浏览器自己去加载依赖。
2. 运行方式
直接在本地起一个静态服务器(不能用 file://,因为跨文件 import 会报错)。
- 用
python -m http.server - 或
npx serve - 或者任何 Web 服务器(Apache/Nginx/Node.js Express)
然后访问 http://localhost:3000/ 就能跑起来。
👉 关键点:没有“打包”,浏览器自己负责模块化加载。
打包的项目(Webpack/Vite/Rollup 等)
- 文件形态
源代码里用的是模块化写法(import/export),但浏览器最终跑的不是这些源文件,而是打包工具生成的 bundle.js。
- 开发时的
npm run dev
- 其实是调用 打包工具自带的开发服务器(比如 webpack-dev-server / vite dev server)。
- 这个开发服务器会:
- 监听文件变化 → 实时重新打包。
- 提供 HTTP 服务 → 浏览器访问时返回打包产物。
- 支持 HMR(热更新) → 改代码不用刷新页面。
例如 Vite:
npm run dev
# 相当于执行 vite
浏览器访问 http://localhost:5173/,看到的 JS 代码已经是 Vite 处理过的。
生产时的 npm run build
- 打包工具会输出真正的静态文件(bundle.js、index.html、assets/…)。
- 这些文件可以部署到任何 Web 服务器上(Nginx、静态空间、CDN)。
- 浏览器加载时根本不管你的源码模块化逻辑,只执行 bundle.js。
👉 关键点:模块化在 Node.js 里“编译解决”,浏览器只要执行最终产物。
对比总结
| 项目类型 | 运行方式 | 模块化逻辑在哪里解决? | 浏览器看到的 |
|---|---|---|---|
| 不打包 | 静态服务器 + 浏览器原生 type="module" | 浏览器运行时 | 多个 .js文件 |
| 打包 | npm run dev(打包工具 dev server)或 npm run build+ 部署 | Node.js 构建时 | 一个或少量 bundle.js |
✅ 换句话说:
- 不打包:浏览器是“工人”,自己拼装零件。
- 打包:Node.js 是“工厂”,提前拼好产品,浏览器只负责展示。
为什么常说“打包后是一个或少量的 bundle.js”?
(这里先对打包有个初步的印象,便于整体理解,后续会对打包有专题深入,届时会细细深入。)
前置思考
一个前端项目里应该有很多种类型的文件(比如最常见的.html和.css),可是看你说“不打包是多个 .js 文件,打包后是一个或少量 bundle.js”,怎么只有js文件了?
不打包的项目(浏览器原生加载)
一个典型小项目可能长这样:
index.html
style.css
main.js
utils.js
math.js
logo.png
- HTML:入口,浏览器直接解析。
- CSS:通过
<link rel="stylesheet" href="style.css">引入,浏览器去请求style.css。 - JS:如果你用的是 ESM,就写
<script type="module" src="main.js"></script>,浏览器会根据import再去请求utils.js、math.js。 - 图片:HTML 或 CSS 中写
<img src="logo.png">,浏览器就发一个请求获取图片。
👉 这种模式下,每一个资源文件都是 独立的 HTTP 请求。
打包后的项目
假设用 Webpack/Vite 打包,可能变成这样:
dist/
index.html
bundle.js
style.css
logo.8f4a9.png
为什么看起来只剩下 JS?
- JS:所有
.js文件(main.js、utils.js、math.js)会被打包、合并、压缩,变成一个bundle.js或少量几个分块的 JS。 - CSS:构建工具可能会:
- 单独抽离成一个
style.css(生产模式常见),在index.html里<link>引入; - 或者直接 **内联进 **
**bundle.js**,运行时用<style>标签插入页面。
- 单独抽离成一个
- 图片/字体:会被处理成:
- 小文件 → Base64 内联到 JS 或 CSS 里(避免额外请求)。
- 大文件 → 被复制到
dist/,改名加 hash(防缓存),再由 HTML/CSS/JS 引用。
👉 所以打包之后你看到的“只有 bundle.js”,是因为:
- JS 被合并成一个大文件。
- CSS 有时被内联到了 JS(所以文件树上看不到独立 CSS)。
- 图片、字体可能被哈希重命名,看起来“消失了”,其实是挪到
dist/里了。
本质差异
| 方面 | 不打包 | 打包 |
|---|---|---|
| 资源数量 | 多个 .js、.css、图片文件,浏览器分别请求 | 少量文件(bundle.js、style.css、打包后的资源) |
| 请求次数 | 多,HTTP/1.1 时性能差 | 少,合并请求,提高性能 |
| 文件内容 | 原始代码,结构清晰 | 压缩、混淆、hash,难以直接看懂 |
| 模块化支持 | 浏览器负责(ESM / AMD) | 构建工具负责(Webpack / Vite),浏览器只跑结果 |
✅ 一句话总结:
打包 ≠ 只剩 JS,而是:
- JS 被合并 → bundle.js
- CSS 可能抽离或内联
- 图片/字体 可能哈希化或内联
浏览器最终只管运行打包产物(少量优化后的文件)。
为什么要有 ES6 Module?
在 2015 年之前,前端模块化一片混乱:
- 服务器端:CommonJS(Node.js)。
- 浏览器端:AMD(RequireJS)、CMD(SeaJS)、UMD(兼容方案)。
- 还夹杂着大量“全局变量污染”的传统写法。
问题:
- 语法不统一:不同平台要写不同的导入导出语法。
- 性能不稳定:AMD 异步、CMD 就近依赖、CommonJS 同步。
- 工具层面 hack 太多:库作者不得不写多个版本或用 UMD。
👉 所以 TC39(JS 标准委员会)在 ES6(2015) 中正式引入了 语言级别的模块化 —— ES Module。
浏览器环境 vs Node.js 环境
浏览器环境:有 <script type="module"> 的版本
<!-- index.html -->
<script type="module" src="main.js"></script>
// main.js
import { add } from './math.js';
console.log(add(2, 3));
// math.js
export function add(a, b) { return a + b; }
场景
👉 浏览器环境。
浏览器只认识 HTML,它不会直接去执行本地的 main.js,你必须通过 HTML 告诉它:
- “我要加载这个 JS 文件”
- 并且声明
type="module",否则浏览器会把import/export当成语法错误。
流程:
- 浏览器解析 HTML,发现
<script type="module" src="main.js">,浏览器知道这是一个 ESM 脚本(模块脚本)。 - 请求
main.js,发现有import './math.js'。 - 再请求
math.js,发现有export。 - 建立依赖图:
index.html → main.js → math.js
- 模块化加载完成后,浏览器确保
math.js先执行,再执行main.js。 - 打印结果
5。
特点:
- ESM 自动是 延迟执行(类似
defer),不会阻塞 HTML 解析。 - ESM 默认跨文件作用域,不会把变量挂到
window上。
Node.js环境:没有 <script> 的版本
// math.js
export function add(a, b) {
return a + b;
}
// app.js
import { add } from './math.js';
console.log(add(2, 3));
场景
👉 Node.js 环境。
Node.js 原本只支持 CommonJS,后来逐渐加入 ESM:
- 早期(Node 12 之前):需要
.mjs扩展名,或者package.json里加"type": "module"。 - 现在(Node 14+):默认支持 ESM,但依然兼容 CommonJS。
区别于浏览器,Node.js 本身就是一个 JS 运行环境,它能直接执行 .js 文件,不需要 HTML 来“挂载脚本”。
流程:
- 你在命令行运行
node app.js(前提是你的 Node.js 开启了 ESM 支持,比如文件扩展名是.mjs或package.json设置"type": "module")。 - Node.js 发现这是一个模块脚本,会自己处理
import/export。 - 直接执行
app.js,过程中自动加载math.js。
核心区别
| 特点 | 浏览器 | Node.js |
|---|---|---|
| 启动入口 | HTML | JS 文件 |
是否需要 <script> | ✅ 必须,告诉浏览器要加载哪个脚本 | ❌ 不需要,直接运行 JS |
| 模块化识别 | type="module" 属性 | .mjs 扩展名 或 package.json: { "type": "module" } |
- 浏览器跑 JS:入口是 HTML →
<script type="module">。 - Node.js 跑 JS:入口就是 JS 文件本身,不需要 HTML。
ESM 的核心机制
在 浏览器和 Node.js 中,ESM 的执行机制是一致的,核心包含以下流程:
1. 静态解析(编译阶段)
- ESM 必须在编译阶段 就确定依赖关系。
import和export只能写在顶层,不允许放在函数、条件语句中。
// ✅ 合法
import { add } from './math.js';
// ❌ 不允许
if (true) {
import { add } from './math.js';
}
- 这样,JS 引擎在代码运行前,就能 构建模块依赖图(Dependency Graph)。
- 方便工具做 Tree-Shaking(按需打包)。
2. 模块加载
- 浏览器遇到
<script type="module">,会 递归请求依赖:main.js→ 发现import './math.js'→ 发起网络请求加载math.js→ 如果math.js又有依赖,继续请求。
- Node.js 遇到
.mjs或package.json: { "type": "module" },会按路径解析并加载模块文件。
3. 模块实例化
- 每个模块只会执行一次,并且会生成一个 模块记录(Module Record)。
- 这个记录里保存了:
- 导出的绑定(exported bindings)
- 模块的执行上下文
- 即使多次
import,拿到的都是 同一个模块实例(单例)。
4. 绑定机制(Live Binding)
- ESM 的变量导出是 绑定(binding),而不是拷贝。
- 这意味着如果导出值发生变化,导入方能实时看到更新:
// counter.js
export let count = 0;
export function inc() { count++; }
// main.js
import { count, inc } from './counter.js';
console.log(count); // 0
inc();
console.log(count); // 1 ✅ 发生了变化
5. 执行顺序
- ESM 默认是异步加载,不会阻塞 HTML 解析。
- 但模块之间的执行顺序严格按照依赖关系:
- 先解析依赖,再执行。
- 保证某个模块执行时,它的依赖已加载完成。
ESM 的关键特性
1. 异步加载,避免阻塞
背景问题
- 早期
<script>默认是 同步加载 + 阻塞执行:- HTML 解析到
<script>时必须停下来,下载并执行完 JS 才继续渲染 DOM。 - 大量 JS 文件时,页面白屏、加载缓慢。
- HTML 解析到
解决方案
- ESM 模块加载是异步的:
<script type="module" src="main.js"></script>
- 这会触发 **并行下载**依赖文件(`import` 的模块)。
- 等 DOM 树解析完成后再顺序执行(相当于默认 `defer`)。
意义
- 页面更快可见(HTML 先解析,不被阻塞)。
- 依赖自动并行加载,不用再手写
<script>标签顺序。
2. 顶层静态依赖
背景问题
- CommonJS 里
require()是运行时调用,你甚至可以写:
if (Math.random() > 0.5) {
require('./a.js');
} else {
require('./b.js');
}
👉 JS 引擎和构建工具在编译时无法预测依赖关系。
解决方案
- ESM 强制 顶层静态 import:
import { add } from './math.js';
- 不能放在 if、循环、函数里(顶层语法限制)。
- JS 引擎在解析阶段就能知道完整依赖图。
意义
- Tree Shaking:打包时能分析哪些导出没用,直接删除无效代码。
- 并行加载优化:浏览器能提前下载所有依赖。
3. 严格模式(Strict Mode)
背景问题
- 传统 JS 容易出现隐式错误,比如:
x = 10; // 没声明变量,自动变成全局变量
- 这在大型项目里会导致难以追踪的 bug。
解决方案
- ESM 自动启用严格模式,即使你没写
"use strict"。- 禁止隐式全局变量。
this默认是undefined(而不是window)。- 一些不安全的语法直接报错。
意义
- 模块内部更安全,避免污染全局作用域。
- 保证不同模块之间不会无意干扰。
4. 跨平台标准化
背景问题
- JS 发展早期,模块化没有统一标准:
- 浏览器有 AMD(RequireJS)、
- Node.js 有 CommonJS、
- 还出现过 CMD、UMD 等。
- 结果就是:写的代码到处都要改,无法通用。
解决方案
- ESM 被写入 ECMAScript 规范(ES6 起),成为官方标准。
- 现在浏览器、Node.js、Deno、Bun 都原生支持。
意义
- 一次编写,多端运行。
- 工具链统一:Rollup、Webpack、Vite 全都以 ESM 为核心。
- JS 生态终于有了 官方的、长期稳定的模块系统。
总结(对前端工程师的认识要求)
- 异步加载:理解浏览器不再阻塞渲染,利于性能优化。
- 静态依赖:知道 Tree Shaking 的原理,能写出可优化的代码。
- 严格模式:写模块时默认就更安全,不要再依赖非严格写法。
- 跨平台:理解为什么 Node.js、Deno 都选择 ESM,代码可以更容易复用。
ESM vs 之前的模块化
| 特点 | CommonJS | AMD | CMD | UMD | ESM |
|---|---|---|---|---|---|
| 规范提出 | 2009 | 2010 | 2011 | 2012 | 2015 |
| 依赖解析 | 同步 | 异步(依赖前置) | 异步(依赖就近) | 兼容写法 | 异步 |
| 运行环境 | Node.js | 浏览器 | 浏览器 | 通用 | 浏览器 & Node.js |
| 是否标准 | ❌ | ❌ | ❌ | ❌ | ✅(语言层面) |
| 是否 Tree-shaking | ❌ | ❌ | ❌ | ❌ | ✅ |
👉 根本差异:ESM 是 ECMAScript 标准的一部分,不再依赖第三方库或工具。
ESM vs CommonJS(Node.js 的老系统)
| 对比点 | CommonJS | ESM |
|---|---|---|
| 定义 | 社区标准 | ECMAScript 官方标准 |
| 加载方式 | 运行时加载(require) | 编译时静态分析(import/export) |
| 依赖解析 | 同步(阻塞) | 异步(并行加载) |
| 导出机制 | 值拷贝 | Live Binding(实时绑定) |
| 执行环境 | Node.js | 浏览器 & Node.js |
| 优化支持 | 不利于 Tree-Shaking | 天然支持 Tree-Shaking |
为什么 ESM 是“最终解”?
- 语言层面内建 → 不依赖第三方库。
- 跨平台统一 → 同一套写法同时支持浏览器和 Node.js。
- 编译时静态分析 → 更适合工具优化(Tree-shaking、打包、代码分割)。
- 动态 import → 兼顾异步按需加载的灵活性。
总结一下:
ES6 Module(ESM)是 JavaScript 官方的模块化标准, 其核心创新在于 —— 静态化 + 异步化 + Live Binding,让 JavaScript 模块化第一次成为语言层面的统一标准,它通过 静态分析、独立作用域、标准语法 彻底统一了前端与后端的模块化写法,取代了 CommonJS / AMD / CMD / UMD 等“过渡性方案”。
运行时加载和编译时静态分析
前置思考
为什么我们要区分 运行时加载 和 编译时静态分析?
——这直接影响前端的 模块加载效率、打包方式、性能优化。
背景:为什么要提出这个区分?
最初(2000s ~ Node.js 出现前后),前端/JS 模块是靠 运行时加载 的:
- 比如 CommonJS 的
require():- 代码跑到
require('./math')时,Node.js 才会去加载math.js文件。 - 加载是 动态 的,不提前知道依赖结构。
- 代码跑到
问题:
- 无法在编译阶段(打包之前)做优化,因为依赖关系是 运行时才知道。
- 这会导致 打包臃肿 / Tree-shaking 无法彻底 / 性能差。
于是,后来(ES6 → 工程化)引入了 静态分析(编译时处理):
- ES Module (
**import/export**) 必须在代码最顶层声明,不能放到 if/for 里。 - 这样,打包工具一读源码,就能知道模块依赖关系,不必等到运行时。
- 好处:能提前优化、裁剪无用代码、并行加载。
什么是“运行时加载”
特点:
- 动态:模块在程序运行到某一行时才会被加载。
- 无法预测依赖:编译阶段看不出真实依赖(可能依赖是变量拼接的路径)。
- 典型代表:
- CommonJS(Node.js 的
require) - AMD / CMD(前端早期异步模块规范)
- CommonJS(Node.js 的
例子(Node.js / CommonJS):
// main.js
if (condition) {
const math = require('./math'); // 运行到这里才加载
console.log(math.add(1, 2));
}
👉 在打包或编译阶段,根本无法提前确定会不会用 math,只能等程序跑起来再说。
问题:
- 打包工具没法摇树优化(Tree-shaking)。
- 浏览器无法并行预加载所有依赖。
什么是“编译时静态分析”
特点:
- 静态:依赖在代码编译阶段就能确定。
- 可预测:编译器能画出完整的依赖图。
- 典型代表:
- ES Module (
import/export)
- ES Module (
例子(ESM):
// main.js
import { add } from './math.js';
console.log(add(1, 2));
特点:
import必须写在顶层,不能放到 if/for 里。- 这样打包工具一眼就能看出 main.js 依赖了 math.js。
好处:
- 打包工具可以做 Tree-shaking(只引入 add,sub 不会打包)。
- 浏览器也能在解析 HTML 时就提前请求依赖(HTTP/2 push 或 ES module 原生支持)。
两者的根本差异
| 特性 | 运行时加载 | 编译时静态分析 |
|---|---|---|
| 加载时机 | 执行到 require/import 时 | 编译/打包阶段就能解析依赖 |
| 动态性 | 支持条件加载、路径拼接 | 必须是固定路径、顶层语句 |
| 优化 | 难以优化,无法摇树 | 可 Tree-shaking、代码分割 |
| 代表 | CommonJS、AMD、CMD | ES Module |
| 应用场景 | Node.js 脚本,灵活加载 | 前端工程化,依赖优化 |
总结
- 运行时加载:灵活,但优化困难(CommonJS 的
require)。 - 编译时静态分析:限制更多,但能让工具提前优化、提升性能(ESM 的
import)。
👉 这就是为什么现代前端框架(React/Vue 等)几乎都基于 ESM,而 Node.js 也开始支持 ESM(代替 CommonJS)。
拆解 ESM 的执行过程——从「JavaScript 代码的生命周期」的角度
前置思考
我们前面提到“ESM必须在编译阶段确定依赖关系,后由JS引擎运行代码”,这里涉及到两个阶段“编译”和“运行”,还有其其他阶段吗?每个阶段是干什么的?又是由谁负责?
总体框架
一个 JS 程序(特别是带模块化的 ESM)从写好到跑起来,一般会经历三个主要阶段:
- 加载(Loading / Fetching)
把 JS 源码文件下载 / 读取到内存。
👉 浏览器:发 HTTP 请求,获取.js文件。
👉 Node.js:从文件系统读取.mjs或.js文件。 - 编译(Parsing + Compilation)
JS 引擎(比如 V8)把源码解析成抽象语法树(AST),生成可执行的中间表示(字节码)。
👉 在 ESM,这一步同时做 依赖解析(Import/Export 静态分析)。 - 执行(Execution / Runtime)
JS 引擎执行字节码(或者即时编译为机器码),运行程序逻辑,产出结果。
细化分工
我们可以更精细地拆成 六个步骤,分别看 浏览器 vs Node.js 干什么。
1. 资源获取(Loading)
- 浏览器:
- 解析 HTML,发现
<script type="module">。 - 通过 URL 发送 HTTP 请求,获取模块文件。
- 如果模块里有
import,继续递归发请求。
- 解析 HTML,发现
- Node.js:
- 通过文件路径(相对 / 绝对 /
node_modules),去磁盘查找并读取文件。
- 通过文件路径(相对 / 绝对 /
👉 负责者:宿主环境(浏览器 / Node.js)。
2. 模块依赖图构建(Dependency Graph Building)
- 发现
import/export,并在解析阶段就确定依赖关系。 - 形成一个有向无环图(DAG),记录:
- 每个模块的路径
- 它依赖的模块
- 导入/导出的符号
👉 负责者:JS 引擎 + 宿主环境(浏览器 / Node.js)配合
- 引擎负责语法解析
- 宿主负责递归加载依赖文件
3. 解析与编译(Parsing + Compilation)
- JS 引擎(如 V8)把源码解析成 AST(抽象语法树)。
- AST 转换成 字节码 或 JIT 优化后的机器码。
- 这一步也会为
import和export建立符号表,供执行时引用。
👉 负责者:JS 引擎(V8 / SpiderMonkey / JavaScriptCore)。
4. 模块实例化(Instantiation)
- 给每个模块分配一个「执行上下文」:
- 变量环境(Variable Environment)
- 导出绑定(Exported Bindings)
- 导入绑定(Imported Bindings → 指向依赖模块的导出)
- 特别之处:ESM 是 Live Binding,即导入变量和原始导出是同一个引用,不是拷贝。
👉 负责者:JS 引擎。
5. 执行(Execution)
- 执行模块的代码,从入口模块开始。
- 遇到函数调用、表达式求值、DOM API 调用等,逐步运行。
- 按依赖图的拓扑顺序,保证先执行依赖,再执行使用方。
👉 负责者:JS 引擎。
6. 运行时交互(Runtime Interaction)
- JS 在执行过程中可能与宿主环境交互:
- 浏览器:DOM API、定时器、事件循环。
- Node.js:文件系统、网络请求、事件循环。
- JS 引擎只负责「解释字节码」,而具体的 I/O 交给宿主环境。
👉 负责者:宿主环境(浏览器、Node.js)。
总结为「四层责任分工」
- 宿主环境(浏览器/Node.js)
- 文件加载(HTTP / 文件系统)
- 提供 API(DOM / FS / Net / Timer)
- 管理事件循环
- 模块加载器(Loader)
- 解析路径,递归加载依赖
- 构建依赖图
- JS 引擎(V8 等)
- 解析 → 编译 → 执行
- 管理作用域、上下文、内存
- 实现 ESM 的 binding 机制
- 运行时(Runtime)
- 实际跑代码
- 与宿主环境 API 交互
✅ 所以问题的答案可以总结成一句话:
ESM 模块的生命周期包含 加载 → 编译 → 实例化 → 执行,其中 宿主环境负责加载与 API 提供,JS 引擎负责编译与执行。
导出机制:CommonJS vs ESM
CommonJS 的导出机制
在 Node.js 里,每个模块内部有 module.exports(对象),你 require 时,本质是 把这个对象的引用拿出来,然后存到缓存里。
情况一:导出对象
// counter.js
let count = 0;
function inc() { count++; }
module.exports = { count, inc };
// main.js
const c = require('./counter');
console.log(c.count); // 0
c.inc();
console.log(c.count); // 0 ❌ 没变
为什么?因为 count 的值是 数字,被放进 { count: 0 } 里后,require 拿到的是一个「拷贝的数值」,而不是动态绑定。
👉 值拷贝的表现。
情况二:导出对象的引用
// counter.js
let count = 0;
function inc() { count++; }
module.exports = { get count() { return count }, inc };
// main.js
const c = require('./counter');
console.log(c.count); // 0
c.inc();
console.log(c.count); // 1 ✅ 动态变化
为什么?因为这里用了 getter,本质还是「通过函数访问模块内部的真实变量」,所以表现得像引用。
👉 但这是我们手动写 getter 实现的,CommonJS 自身不保证这一点。
✅ 结论:
- CommonJS 默认是值拷贝(值按 require 时的快照给你)。
- 但如果你导出对象 / 函数(本身是引用类型),就能间接访问到最新值,看起来像“引用”。
- 所以说 CommonJS 是值拷贝 + 缓存机制,只是对象导出时能间接模拟“引用”。
ESM 的导出机制
ESM 的规范里明文规定:
export出去的变量是 Live Binding(实时绑定)。import的地方,拿到的是对原始变量的「引用」,不会复制数值。
例子:
// counter.mjs
export let count = 0;
export function inc() { count++; }
// main.mjs
import { count, inc } from './counter.mjs';
console.log(count); // 0
inc();
console.log(count); // 1 ✅ 自动更新
👉 无论是基本类型还是对象,import 绑定的始终是原变量。
差异总结
| 特性 | CommonJS | ESM |
|---|---|---|
| 导出时机 | 运行时 | 编译时(静态分析) |
| 导出机制 | 基础类型为值拷贝,引用类型会引用传递(缓存 module.exports 对象) | Live Binding(实时绑定) |
| 更新同步 | 默认不同步(需要手动 getter 才能看到最新值) | 自动同步 |
| 加载方式 | 同步(require) | 异步(import,浏览器支持并行加载) |
- CommonJS require 时拿到的是 module.exports 的引用,但里面的 基础值是拷贝。
- ESM 是直接绑定变量本身,保证 Live Binding。
。 - 彻底统一了前端模块化的写法。
模块化演进时间线(重点在 2009–2015)
-
CommonJS(2009 左右)
- Node.js 采用的规范。
- 核心特征:
require同步加载、module.exports导出。 - 优势:在服务器端运行快、简单直接。
- 劣势:不适合浏览器(浏览器加载网络资源不同步)。
-
AMD(Asynchronous Module Definition,2010 前后,RequireJS 推广)
- 适合浏览器异步加载模块:
define(['dep1', 'dep2'], function(dep1, dep2) {
return { foo: dep1.bar() };
});
- 优点:浏览器端不卡顿。
- 缺点:写法繁琐,可读性差。
3. CMD(Common Module Definition,2011 左右,SeaJS 推广)
- 中国团队(阿里玉伯)主导,想改进 AMD。
- 特点:**依赖就近、延迟执行**。
define(function(require, exports, module) {
var $ = require('jquery');
exports.foo = function() { $('#id').show(); };
});
- 优点:代码更像 CommonJS,语义直观。
- 缺点:需要运行时解析 `require`,性能上不如 AMD。
4. UMD(Universal Module Definition,大约 2012–2013)
- 不是新规范,而是一种 **兼容写法**:
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD
define(['jquery'], factory);
} else if (typeof exports === 'object') {
// CommonJS
module.exports = factory(require('jquery'));
} else {
// 全局变量
root.myLib = factory(root.jQuery);
}
}(this, function ($) {
return { foo: function() {} };
}));
-
- 意义:一个库打包一次,同时支持 AMD / CommonJS / 全局变量,避免写多个版本。
- ESM(ES6 Module,2015)
-
- 官方标准:
import/export。 - 最终取代上面所有“民间规范”。
- 官方标准:
📌 它们的出现顺序
CommonJS → AMD → CMD → UMD → ESM
(CommonJS 在 Node,AMD/CMD 在浏览器,UMD 是折中产物,最终 ESM 统一大局)
学习重点
- 必须掌握:CommonJS(Node.js 依然广泛使用)、ESM(现代前端标准)。
- 略知一二:AMD/CMD/UMD(理解历史、读老项目源码、看三方库兼容写法)。
- 没必要深挖:AMD/CMD 细节现在几乎用不到,UMD 只要知道是兼容模式就够。
👉 换句话说:
- 面试 & 历史脉络 → 要能说清楚它们的出现原因和区别。
- 日常开发 → 只需要会用 CommonJS 和 ESM。
模块化的意义与影响
- 开发体验:从“脚本拼接” → “模块化编程”。
- 依赖管理:能明确写清楚“我依赖谁”。
- 生产环境:模块化让工程化工具(Webpack、Rollup、Vite)有了施展空间。
- 团队协作:不同人写的代码不会互相污染。
👉 可以说,没有模块化,就不会有后来的工程化。
总结一下:
- 模块化的出现,是为了解决 代码组织混乱、依赖管理困难、全局污染 的痛点。
- 发展脉络:无模块 → CommonJS/AMD/CMD → ES6 Module。
- 它让前端从“玩具脚本”真正迈向了“软件工程”。