webpack异步加载原理梳理解构

70 阅读7分钟

背景:

这几天重新梳理了一下 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 的对比

  1. 脚本加载机制相同

// 两者都使用相同的基础加载方式

const script = document.createElement('script');

script.src = 'resource.js';

document.head.appendChild(script);

  1. 全局回调设计

// JSONP

window.jsonpCallback = function(data) { ... }

// Webpack

window.webpackJsonp = [];

window.webpackJsonp.push = function(data) { ... }

  1. 执行流程相似

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异步加载原理 我们已经大致清楚了,是不是还挺有意思的,同时也能给我们一些启发,尤其精妙设计那里,希望有机会用到,这样我们也是站在巨人的肩膀上了~