背景:
这几天重新梳理了一下 webpack异步加载的原理,并对实现细节进行了一番拆解,再次让我感叹:真是万变不离其宗,基础知识真的是构建上层建筑的坚实底座,在此也分享给大家,希望大家可以领略到webpack实现异步加载之美
基座一:webpack模块化方案
你可能也吐槽过 webpack产物为啥这么丑?
原始源代码
// src/index.js (入口文件)
let title = require('./title.js')
console.log(title);
// src/title.js
module.exports = 'bu';
构建后产物结构概览
/******/ (() => { // webpack bootstrap 启动函数
/******/ var __webpack_modules__ = ([
/* 0 */ /* title.js 模块 */
((module) => {
module.exports = 'bu';
}),
/* 1 */ /* index.js 模块 */
((__unused_webpack_module, __unused_webpack_exports, __webpack_require__) => {
let title = __webpack_require__(0); // 加载模块0
console.log(title);
})
/******/ ]);
/******/ /* 模块缓存 */
/******/ var __webpack_module_cache__ = {};
/******/ /* Webpack 自实现的 require 函数 */
/******/ function __webpack_require__(moduleId) {
/******/ /* 检查缓存 */
/******/ var cachedModule = __webpack_module_cache__[moduleId];
/******/ if (cachedModule !== undefined) {
/******/ return cachedModule.exports;
/******/ }
/******/ /* 创建新模块并加入缓存 */
/******/ var module = __webpack_module_cache__[moduleId] = {
/******/ exports: {}
/******/ };
/******/ /* 执行模块函数 */
/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
/******/ /* 返回模块的 exports 对象 */
/******/ return module.exports;
/******/ }
/******/ /* 启动入口模块 */
/******/ return __webpack_require__(1); // 加载入口模块(index.js)
/******/ })()
;
✅ 核心原因:规范转换成本(模块系统适配)
浏览器原生不支持模块规范
浏览器无法直接执行 CommonJS/AMP/UMD 模块语法(如 require() / module.exports / define())。Webpack 必须将这些规范统一转换为浏览器可执行的函数包装形式。
-
整个打包产物被包裹在一个外层 IIFE 中(立即执行)
- 意义:创建独立作用域
// 未包裹:变量暴露全局 var utils = {...} // 可能覆盖其他脚本的同名变量 // IIFE 包裹后: (function() { var utils = {...} // 安全隔离 })(); -
每个模块被转换为标准函数(非立即执行),作为参数传递给运行时:function(module, exports...)
- 意义:模块环境隔离 -每次调用模块函数时都会创建新的:
const module = { exports: {} }; modules[moduleId].call(module.exports, ...); -
模块路径被替换为 数字 ID
- 提升性能 & 减少体积
- ✅ 大幅缩短引用路径:
'./src/utils/string-format.js'→__webpack_require__(17) - ✅ 避免路径解析开销:浏览器无需处理文件路径逻辑
- ✅ 大幅缩短引用路径:
- 提升性能 & 减少体积
-
原生
require/module被替换为 Webpack 自实现的__webpack_require__函数- 规范统一:将 ESM/CommonJS/AMD 转为浏览器可执行格式
-
生成复杂的 运行时(runtime)代码 处理模块加载/缓存
- 避免重复执行(如多次
require同一模块)
- 避免重复执行(如多次
-
模块字典(所有代码被打包成键值对(键:上面提到的数字ID,值:上面提到的每个模块被转换的标准函数))
- ✅ 快速索引:通过数字 ID 实现 O(1) 复杂度的模块查找
基座二:jsonp
传统的 JSONP 流程:
sequenceDiagram
participant Client as 客户端
participant Server as 服务器
Client->>Client: 创建回调函数
Note right of Client: window.myCallback = <br/>function(data){...}
Client->>Server: 动态创建<script src="api?callback=myCallback">
Server->>Server: 准备数据
Server->>Client: 返回 myCallback({...数据...})
Client->>Client: 执行回调函数
Webpack 异步加载机制与 JSONP 的对比
- 脚本加载机制相同:
// 两者都使用相同的基础加载方式
const script = document.createElement('script');
script.src = 'resource.js';
document.head.appendChild(script);
- 全局回调设计:
// JSONP
window.jsonpCallback = function(data) { ... }
// Webpack
window.webpackJsonp = [];
window.webpackJsonp.push = function(data) { ... }
- 执行流程相似:
graph TD
A[创建 script 标签] --> B[设置 src]
B --> C[添加到 DOM]
C --> D[服务器返回 JS]
D --> E[执行 JS 代码]
E --> F[触发全局回调]
那么不一样的地方在哪呢?
对了就是全局回调,异步加载的回调函数设计是非常精妙的,咱们往下看
精妙的全局回调设计-webpackJsonpCallback
好了,现在我们拿到异步组件脚本了,我们应该做什么呢?
对了,就是要接入 我们上面聊的 webpack模块化方案,只有这样webpack 才能正常加载并缓存我们的异步模块
那么问题来了,怎样才能和上面说的 webpack模块化方案 接上轨呢?
答案就在 这个jsonp的全局回调上
显然我们要在回调里 把当前模块加入到 模块字典里,及 webpack_modules 里,
我们管这一步,叫做 全局模块注册,注意这里仅仅是注册模块,并没有执行模块
按需执行 如何做到
好了,注册模块我们实现了,那么按需执行呢?
按需执行,即我们希望由我们来控制 什么时候执行该模块,那么如何实现呢?
精妙设计一:加载模块和执行模块分离--解耦请求与响应
graph LR
A[发起请求] --> B[存储控制器]
C[响应到达] --> D[取出控制器]
D --> E[触发回调]
// 步骤1: 初始化Promise
const promise = new Promise((resolve, reject) => {
// 这个回调会立即执行!
installedChunks[chunkId] = [[resolve, reject]];
});
// 步骤2: 文件加载完成后
function chunkLoaded() {
const callbacks = installedChunks[chunkId];
for (const [res] of callbacks) {
res(); // 手动触发所有resolve
}
installedChunks[chunkId] = 0; // 标记为已加载
}
// 步骤3: 触发.then()
promise.then(() => {
// 这里才会执行!
__webpack_require__(moduleId);
});
也就是说在异步加载模块流程会封装成一个promise, 在加载模块前,我们会提前将该promise的resolve回调存储起来,存到 installedChunks;
当加载模块请求响应回来之后,我们从 installedChunks里拿到 resolve 回调执行 我们可以在resolve 里面控制何时 执行模块
sequenceDiagram
participant T as .then()调用
participant R as Runtime(运行时)
participant S as 网络请求
T->>R: __webpack_require__.e("hello_chunk")
activate R
R->>R: 创建Promise<br>installedChunks["hello_chunk"] = [[resolve, reject]]
R->>S: 发起chunk加载请求
deactivate R
S-->>R: 返回chunk内容
activate R
R->>R: 执行webpackJsonpCallback
R->>R: 找到对应resolve函数
R->>Promise: 执行resolve()
deactivate R
Promise-->>T: 触发.then()回调
到这里我们可以给出__webpack_require__.e 和 webpackJsonpCallback的代码了:
// 异步加载函数 (修正版)
__webpack_require__.e = (chunkId) => {
return new Promise((resolve, reject) => {
// 检查模块是否已加载
if (installedChunks[chunkId] === 0) {
resolve();
return;
}
// 检查是否已在加载中
if (installedChunks[chunkId]) {
installedChunks[chunkId].push([resolve, reject]);
return;
}
// 初始化加载状态
installedChunks[chunkId] = [[resolve, reject]];
// 创建脚本标签加载 chunk
const script = document.createElement('script');
script.src = `${chunkId}.js`;
script.onerror = () => {
reject(new Error(`Failed to load chunk ${chunkId}`));
// 清理加载状态
if (installedChunks[chunkId]) {
installedChunks[chunkId] = undefined;
}
};
// 关键步骤:将脚本添加到文档头部 (之前遗漏的部分)
document.head.appendChild(script);
});
};
function webpackJsonpCallback(data) {
const [chunkIds, moreModules] = data;
// 注册新模块
for (const moduleId in moreModules) {
__webpack_modules__[moduleId] = moreModules[moduleId];
}
// 处理每个 chunk 的 Promise
for (const chunkId of chunkIds) {
const chunkState = installedChunks[chunkId];
if (!chunkState) continue;
// 执行所有 resolve 回调
for (const [resolve] of chunkState) {
resolve();
}
// 标记 chunk 为已加载
installedChunks[chunkId] = 0;
}
}
是不是很棒,加载由我们控制,执行也由我们控制,
精妙设计二:加载时序问题解决方案
当我们加载异步组件后,我们发现它并不是用 webpackJsonpCallback 包裹起来,而是用 webpackJsonp.push
// hello_chunk.js
webpackJsonp.push([["hello_chunk"], {"./src/hello.js": function(){...}}])
在webpack 实现里我们会看到这句话:
webpackJsonp.push = webpackJsonpCallback;
为什么 webpack 要多此一举 ?为什么不直接用 webpackJsonpCallback 包裹起来呢?
核心原因:解决异步加载的顺序问题
加载顺序不确定性:
-
异步 chunk 可能在主 runtime 加载完成之前就加载完毕(
关键:此时webpackJsonpCallback 可能还没有定义,因为 webpackJsonpCallback 是写在主 runtime里的) -
也可能在主 runtime 加载完成之后才加载
关键设计:劫持push方法
此时就算找不到 webpackJsonpCallback,但是 webpackJsonp.push 是原生方法,肯定可以找到,这样即使 webpackJsonpCallback 未定义,也不会让 已加载的分块 丢失
也就是允许任何时间加载的分块,webpack都能处理,完全解耦加载顺序
// 关键设计:劫持push方法
webpackJsonp.push = webpackJsonpCallback;
// 处理初始化前已加载的分块
for (var i = 0; i < webpackJsonp.length; i++) {
webpackJsonpCallback(webpackJsonp[i]);
}
webpackJsonp.length = 0; // 清空初始队列
// 将处理后的队列暴露到全局
window.webpackJsonp = webpackJsonp;
场景1:分块在主runtime之前加载完成
sequenceDiagram
participant Browser
participant Chunk as 异步分块
participant Runtime as Webpack Runtime
Browser->>Chunk: 1. 加载分块文件
Chunk->>Browser: 2. 执行分块代码
Note right of Chunk: webpackJsonp.push([[1], modules])
Browser->>Runtime: 3. 加载主runtime
Runtime->>Runtime: 4. 初始化时重写push方法
Runtime->>Runtime: 5. 处理已缓存的推送
Runtime->>Runtime: 6. 执行回调逻辑
场景2:分块在主runtime之后加载完成
sequenceDiagram
participant Browser
participant Runtime as Webpack Runtime
participant Chunk as 异步分块
Browser->>Runtime: 1. 加载主runtime
Runtime->>Runtime: 2. 初始化时重写push方法
Runtime->>Runtime: 3. 处理初始队列(空)
Browser->>Chunk: 4. 加载分块文件
Chunk->>Browser: 5. 执行分块代码
Note right of Chunk: webpackJsonp.push([[1], modules])
Browser->>Runtime: 6. 推送触发回调
总流程概览
1. 编译阶段(Build Time)
-
语法识别:Webpack 解析 AST 时识别
import('./LazyComponent')语法 -
模块分离:
// 原始代码
import('./LazyComponent');
// Webpack 处理:
1. 将 LazyComponent 及其依赖抽离为独立 chunk(如 `src_LazyComponent_js.js`)
2. 生成 chunk ID(如 "chunk-lazy")
3. 生成模块 ID(如 42)
- **代码转换**:
```javascript
// 转换后代码
__webpack_require__.e("chunk-lazy")
.then(() => webpack_require(/* moduleId */))
- 生成 chunk 文件:
// src_LazyComponent_js.js 内容
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([
["chunk-lazy"],
{
"./src/LazyComponent.js": (module, __webpack_exports__, __webpack_require__) => {
console.log("模块顶层代码执行");
const data = "初始化数据";
// 顶层变量初始化
function MyComponent() {
console.log("组件渲染执行");
return <div>{data}</div>;
}
module.exports = { default: MyComponent, helper: function() {}
};
}
}
]);
2. 运行时阶段(Runtime)
- 触发加载:
// 执行编译后的代码
const promise = __webpack_require__.e("chunk-lazy");
- 加载器执行:
// __webpack_require__.e 核心逻辑
__webpack_require__.e = (chunkId) => {
// 检查缓存
if (installedChunks[chunkId] === 0) return Promise.resolve();
// 创建加载 Promise
const promise = new Promise((resolve, reject) => {
// 创建 script 标签
const script = document.createElement('script');
script.src = `${publicPath}${chunkId}.chunk.js`;
// 错误处理
script.onerror = () => reject(new Error(`Loading failed ${chunkId}`));
// 注册全局回调
const originalPush = webpackJsonp.push.bind(webpackJsonp);
webpackJsonp.push = (item) => {
webpackJsonpCallback(item);
originalPush(item);
};
// 处理运行时初始化前已加载的数据
for (let i = 0; i < webpackJsonp.length; i++) {
webpackJsonpCallback(webpackJsonp[i]);
}
// 清空队列但不移除引用
webpackJsonp.splice(0, webpackJsonp.length);
// 触发加载
document.head.appendChild(script);
});
// 标记为加载中
installedChunks[chunkId] = [promise, resolve, reject];
return promise;
};
3. 模块注册阶段(Chunk Execution)
- Chunk 脚本执行:
// 浏览器加载并执行 src_LazyComponent_js.js
window.webpackJsonp.push([
["chunk-lazy"],
{
"./src/LazyComponent.js": function(module, exports) {
// 组件实现
exports.default = function LazyComp() { ... }
}
}
]);
- 回调触发:
function webpackJsonpCallback(data) {
const [chunkIds, modules] = data;
// 1. 注册模块到全局存储
for (const moduleId in modules) {
__webpack_modules__[moduleId] = modules[moduleId];
}
// 2. 标记chunk为已加载
chunkIds.forEach(chunkId => {
installedChunks[chunkId] = 0; // 0 = 已加载
});
// 3. 执行所有等待中的resolve
const resolves = [];
chunkIds.forEach(chunkId => {
if (installedChunks[chunkId]) {
resolves.push(installedChunks[chunkId][1]); // 获取resolve函数
installedChunks[chunkId] = 0; // 清除等待状态
}
});
// resolve:将promise1状态改成fulfilled,并且触发.then回调:() => webpack_require(/* moduleId */)返回 module.export
//
resolves.forEach(resolve => resolve());
}
至此,webpack异步加载原理 我们已经大致清楚了,是不是还挺有意思的,同时也能给我们一些启发,尤其精妙设计那里,希望有机会用到,这样我们也是站在巨人的肩膀上了~