一、原生 ESM:浏览器如何加载模块
1.1 三阶段流程
浏览器处理 ESM 严格按照三个阶段顺序执行:
① 构建(Construction) → 静态分析依赖,下载所有模块,构建模块依赖图
② 实例化(Linking) → 为所有导出变量分配内存空间(此时无值)
③ 求值(Evaluation) → 按依赖顺序执行模块代码,填入真实值
关键特性:
- 遇到
<script type="module">等同于defer,不阻塞 HTML 解析 - 同一模块只执行一次,多次 import 返回同一实例(幂等性)
- 模块下载并行,但执行顺序遵循依赖拓扑排序
1.2 Live Binding(动态绑定)
ESM 的 import 不是值拷贝,而是对原始变量内存地址的引用绑定:
// counter.js
export let count = 0;
export function increment() { count++; }
// main.js
import { count, increment } from './counter.js';
increment();
console.log(count); // 1,而不是 0
count 始终指向 counter.js 中那块内存,值变化会实时反映。
1.3 浏览器网络层细节
- 每个模块对应一个独立 HTTP 请求
- 响应头必须是合法的 JS MIME 类型(
application/javascript) - 跨域模块受 CORS 限制,需服务器配置
Access-Control-Allow-Origin - 相同 URL 的模块全局只加载一次(浏览器 Module Map 缓存)
二、静态 vs 动态:import 的两种形态
理解这个区别是理解整个模块系统的核心。
2.1 什么是"编译时"与"运行时"
浏览器没有离线的编译阶段,这里的区分是相对时序:
| 概念 | 含义 |
|---|---|
| "编译时" / 静态 | 代码执行之前,JS 引擎解析 AST 阶段 |
| "运行时" / 动态 | 模块顶层代码真正开始执行之后 |
2.2 静态 import 语句
// ✅ 合法:路径是字面量,解析 AST 时就能确定
import { foo } from './foo.js'
// ❌ 非法:路径是表达式,解析阶段无法求值
import { foo } from './' + name + '.js'
import 语句在解析 AST 时就被提取,此时代码尚未执行,所以路径必须是静态字符串。这保证了浏览器能在执行任何代码前构建完整的依赖图。
2.3 动态 import() 函数
// 执行到这一行时,才发起请求
const mod = await import('./foo.js')
// 路径可以是任意表达式
const mod = await import(`./locales/${lang}.js`)
import() 本质是一个运行时函数调用,返回 Promise,适合按需加载、懒加载场景。
2.4 总结对比
| 时机 | 本质 | 用途 | |
|---|---|---|---|
import 语句 | 解析 AST 时(执行前) | 静态声明 | 常规依赖 |
import() 函数 | 执行到该行时 | 运行时调用 | 懒加载、条件加载 |
| Live Binding 的值 | 求值阶段填入 | 运行时赋值 | — |
一句话:ESM 的依赖关系是静态的,但变量的值是运行时的。
三、ESM vs CommonJS
| ESM | CommonJS | |
|---|---|---|
| 依赖分析 | 静态,执行前确定 | 动态,运行时执行 require() |
| 导出绑定 | Live Binding(引用) | 值拷贝 |
| 循环依赖 | 可处理(binding 已建立) | 可能拿到未完成的对象 |
| 异步加载 | 原生支持 | 同步阻塞 |
| Tree Shaking | 天然支持 | 难以静态分析 |
四、Webpack:将模块编译为函数
Webpack 在构建时将所有模块编译成函数,自行实现一套模块加载系统,不依赖浏览器原生 ESM。
4.1 核心结构
(function(modules) {
var installedModules = {}; // 模块缓存
function __webpack_require__(moduleId) {
// 命中缓存直接返回,保证每个模块只执行一次
if (installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
// 创建模块对象并缓存
var module = installedModules[moduleId] = { exports: {} };
// 执行模块函数,注入 module、exports、require
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
return module.exports;
}
return __webpack_require__('./src/index.js'); // 从入口启动
})({
'./src/index.js': function(module, exports, __webpack_require__) {
const foo = __webpack_require__('./src/foo.js');
console.log(foo);
},
'./src/foo.js': function(module, exports) {
exports.default = 'hello';
}
});
4.2 Live Binding 的模拟
Webpack 模拟的是 CJS 语义,导出本质是值拷贝。但为了兼容 ESM 的 Live Binding,Webpack 用 Object.defineProperty 的 getter 做了补丁:
// 你写的 ESM
export let count = 0;
// Webpack 编译后
Object.defineProperty(exports, 'count', {
get: function() { return count; } // 每次读取都重新取值,模拟引用
});
4.3 缓存机制对比
| Webpack | 原生 ESM | |
|---|---|---|
| 缓存位置 | installedModules 对象(JS 堆内存) | 浏览器 Module Map |
| 缓存 Key | 模块路径字符串 | 模块完整 URL |
| 执行次数 | 只执行一次 | 只执行一次 |
| 缓存清除 | 不能(除非 HMR 介入) | 不能(页面级别) |
4.4 HMR 的本质
Hot Module Replacement 正是利用了这套缓存机制:
检测到文件变更
↓
删除 installedModules 中对应模块的缓存
↓
注入新的模块函数
↓
重新执行该模块 → 更新界面
不需要刷新页面,只替换变更的那块缓存。
五、Webpack 异步加载(动态 import)
当你写 import() 时,Webpack 会把对应模块拆成独立的 chunk 文件,运行时按需加载。
5.1 核心机制:JSONP
// 你写的
const mod = await import('./foo.js')
// Webpack 编译后
__webpack_require__.e('chunk-foo') // 异步加载 chunk
.then(() => __webpack_require__('./src/foo.js')) // 从缓存同步取模块
5.2 webpack_require.e 的实现
__webpack_require__.e = function(chunkId) {
// 已加载,直接返回
if (installedChunks[chunkId] === 0) return Promise.resolve();
// 加载中,返回同一个 Promise(防止重复请求)
if (installedChunks[chunkId]) return installedChunks[chunkId][2];
// 首次加载:创建 Promise + 动态插入 <script>
var promise = new Promise((resolve, reject) => {
installedChunks[chunkId] = [resolve, reject];
});
installedChunks[chunkId][2] = promise;
var script = document.createElement('script');
script.src = chunkId + '.bundle.js';
document.head.appendChild(script);
return promise;
};
5.3 chunk 文件结构
// chunk-foo.bundle.js
(self["webpackChunk"] = self["webpackChunk"] || []).push([
['chunk-foo'],
{
'./src/foo.js': function(module, exports) {
exports.default = 'hello'
}
}
]);
主 bundle 中拦截了 webpackChunk.push,chunk 文件执行时自动触发:
self["webpackChunk"].push = function([chunkIds, modules]) {
Object.assign(__webpack_modules__, modules); // 注册新模块
chunkIds.forEach(id => {
installedChunks[id] = 0; // 标记已加载
installedChunks[id][0](); // resolve Promise
});
};
5.4 完整时序
import('./foo.js')
↓
__webpack_require__.e('chunk-foo')
↓
installedChunks 无缓存 → 创建 Promise + 插入 <script> 标签
↓
浏览器下载 chunk-foo.bundle.js
↓
chunk 执行 → webpackChunk.push() 被拦截
↓
模块注册进 __webpack_modules__ → resolve Promise
↓
.then(() => __webpack_require__('./src/foo.js'))
↓
从 installedModules 缓存同步取出 → 返回模块
5.5 其他细节
- 防重复请求:同一 chunk 并发多次
import(),共享同一个 Promise - 预加载:
/* webpackPrefetch: true */会生成<link rel="prefetch">提前加载资源 - 错误处理:
script.onerror触发时 reject Promise,可被try/catch捕获
总结
原生 ESM
静态分析依赖 → 并行下载 → 分配内存 → 顺序执行
Live Binding = 真实内存引用
import() = 运行时动态请求
Webpack
编译阶段:所有模块 → 函数 + installedModules 缓存
同步加载:__webpack_require__ + 缓存命中
异步加载:动态插入 <script> + JSONP 回调 + Promise
HMR: 删缓存 → 注入新函数 → 重执行