首页
/ webpack 代码分割实战:从 require.ensure 示例看懂 chunk 拆分与 JSONP 按需加载机制

webpack 代码分割实战:从 require.ensure 示例看懂 chunk 拆分与 JSONP 按需加载机制

2026-09-06 17:59:57作者:卓艾滢Kingsley

本文基于 webpack 官方示例 examples/code-splitting 展开,完整讲解代码分割(Code Splitting)的最简案例:通过 require.ensure 将模块拆分为入口 chunk 与按需加载 chunk。读完后你能掌握 webpack 如何决定"哪些模块留在主包、哪些模块延迟加载"、产物中两个 chunk 各自的构成,以及 JSONP 运行时加载机制在源码层面的真实实现路径。

示例要解决的问题

代码分割的核心目标是:把不在首屏立即需要的模块从入口 chunk 中剥离,变成独立的异步 chunk,用户触发时才通过脚本标签下载。示例源码 只有 6 行,却涵盖了 webpack 拆分 chunk 的全部典型场景:

var a = require("a");
var b = require("b");
require.ensure(["c"], function(require) {
    require("b").xyz();
    var d = require("d");
});

按照 README 中的划分逻辑,各模块的去向如下:

  • ab 通过 CommonJS 正常 require,属于入口依赖,直接进入入口 chunk;
  • c 出现在 require.ensure 的第一个参数数组中——它只是被"声明可用"(made available, but doesn't get execute),webpack 会按需加载它,但在回调执行前不会执行其模块代码;
  • bdrequire.ensure 的回调内部通过 CommonJS require。webpack 能检测到这些依赖位于"按需回调"(on-demand callback)作用域内,因此会把它们也标记为按需加载;
  • 其中 b 有一个值得注意的优化点:webpack 的优化器可以把回调里的 b "优化掉",因为它已经通过父级 chunk(入口 chunk)提供了,无需在异步 chunk 中重复包含。

最终产物是两个 chunk 文件:

产物文件 角色 包含内容
output.js 入口 chunk(main) 模块系统(module system)、chunk 加载逻辑(运行时)、入口点 example.js、模块 a、模块 b
1.output.js(named 模式下为 node_modules_c_js-node_modules_d_js.output.js 额外 chunk,按需加载 模块 c、模块 d

异步 chunk 通过 JSONP 方式加载,且由于体积小,压缩后非常紧凑。

构建配置:named chunkIds 的作用

本示例的 webpack.config.js 极简,只有一个关键配置:

"use strict";

/** @type {import("webpack").Configuration} */
const config = {
	optimization: {
		chunkIds: "named" // To keep filename consistent between different modes (for example building only)
	}
};

module.exports = config;

optimization.chunkIds: "named" 的作用是让 chunk 文件名在不同构建模式(如 development / production、单独构建)之间保持一致。默认的数字 chunk ID(如 1)取决于模块解析顺序,任何一处变动都可能让 ID 重排;而 named 模式直接使用 chunk 内含模块的路径生成标识(本例中为 node_modules_c_js-node_modules_d_js),从而让 README 中引用的产物文件名稳定可复现。这也解释了为什么产物文件实际叫 node_modules_c_js-node_modules_d_js.output.js 而不是 README 叙述部分提到的 1.output.js

该示例的 README 本身是由构建系统根据 template.md 自动生成的:模板中的 {{example.js}}{{dist/output.js}}{{stdout}} 等占位符会在构建后替换为真实文件内容与编译输出,production: 前缀占位符则对应 production 模式构建结果。所有示例统一由 examples/buildAll.js 逐个目录执行构建脚本,任何一个示例构建失败都会抛出错误。

产物剖析一:入口 chunk(dist/output.js)

入口 chunk 是完整自包含的 IIFE,其结构与 README 给出的输出一致,可分为三段:

1. 模块注册表 __webpack_modules__

入口 chunk 内只注册了模块 ab(模块索引 1 和 2),入口 example.js 与运行时模块另有其位。异步 chunk 中的 cd 完全不在此文件中。

2. webpack 运行时(runtime)

这是本示例信息密度最高的部分,包含 6 个 runtime 模块(stats 显示 runtime modules 4.83 KiB 6 modules):

  • 模块缓存与 require 函数__webpack_module_cache__ 缓存已执行模块,__webpack_require__(moduleId) 先查缓存、未命中则创建 module 对象(含空 exports)、执行模块函数并返回 exports。这就是 webpack 打包后所有模块共用的运行时执行环境。
  • __webpack_require__.e(ensure chunk):chunk 加载的总入口,聚合 __webpack_require__.f 上注册的所有 chunk 获取函数并返回 Promise 集合。require.ensure 转换后正是调用它:
__webpack_require__.e = (chunkId) => {
    return Promise.all(Object.keys(__webpack_require__.f).reduce((promises, key) => {
        __webpack_require__.fkey;
        return promises;
    }, []));
};
  • __webpack_require__.u(get javascript chunk filename):由 chunkId 推导文件名,本例为 (chunkId) => (chunkId + ".output.js"),对应 output.filename 配置。
  • __webpack_require__.l(load script):通过 <script> 标签加载脚本,带 inProgress 去重表(同一 URL 并发请求只挂一个 script)、CSP nonce 支持(__webpack_require__.nc 时设置 nonce 属性)、120 秒超时保护。
  • __webpack_require__.p(publicPath):本例为 "dist/",是请求异步 chunk 时的基础路径。
  • JSONP chunk loading:这是整个按需加载的核心,见下一节。

3. 入口模块执行

入口 example.js 被包裹在 IIFE 中执行(README 原注释:This entry needs to be wrapped in an IIFE because it needs to be isolated against other modules in the chunk),其中的 require.ensure 调用已被静态改写:

var a = __webpack_require__(/*! a */ 1);
var b = __webpack_require__(/*! b */ 2);
__webpack_require__.e(/*! require.ensure */ "node_modules_c_js-node_modules_d_js").then((function(require) {
    (__webpack_require__(/*! b */ 2).xyz)();
    var d = __webpack_require__(/*! d */ 4);
}).bind(null, __webpack_require__))'catch';

对照 示例源码 可以看到三点改写事实:

  • require.ensure([...], cb) 整体变成了 __webpack_require__.e(chunkName).then(cb).bind(null, __webpack_require__)——回调收到的 require 参数就是被绑定进来的 __webpack_require__
  • require("b") 变为 __webpack_require__(2),说明 b 直接复用入口 chunk 里的模块 2(印证了"优化器把 b 优化掉"的叙述:它并未进入异步 chunk);
  • require("d") 变为 __webpack_require__(4),模块 4 并不在入口 chunk 中,它会随异步 chunk 通过 moreModules 注入到 __webpack_require__.m
  • 尾部 'catch' 是全局未捕获错误处理器 __webpack_require__.oe,chunk 加载失败(如网络错误)会在此以 ChunkLoadError 形式被捕获。

产物剖析二:异步 chunk 与 JSONP 加载

异步 chunk 的完整内容只有 4 个模块槽位 + 两个空函数(对应被优化掉的模块),本质是一次 JSONP 回调 push:

(self["webpackChunk"] = self["webpackChunk"] || []).push([["node_modules_c_js-node_modules_d_js"],[
/* 0 */, /* 1 */, /* 2 */,
/* 3 */ (() => {
// module c
/***/ }),
/* 4 */ (() => {
// module d
/***/ })
]]);

它对应 README 压缩版输出——整个 chunk 压缩后仅 108 字节:

(self.webpackChunk=self.webpackChunk||[]).push([["node_modules_c_js-node_modules_d_js"],{605(){},576(){}}]);

JSONP 的"接收端"在入口 chunk 的 jsonp chunk loading runtime 中,其工作方式值得逐段理解:

  1. installedChunks 状态表undefined 表示 chunk 未加载、null 表示已预加载/预取、0 表示已加载完成、[resolve, reject, Promise] 表示加载中。本例初始状态为 { "main": 0 },即入口 chunk 视为已安装。
  2. __webpack_require__.f.j(JSONP 获取函数):当 __webpack_require__.e(chunkId) 被调用时进入。若该 chunk 正在加载则复用已有 Promise(保证并发请求只下载一次);否则新建 Promise,调用 __webpack_require__.l(publicPath + u(chunkId), loadingEnded, ...) 插入 script 标签。loadingEnded 回调在加载完成时判断状态:若 chunk 仍未标记为已安装,则构造 ChunkLoadError(携带 missing/超时类型与真实 src)并 reject。
  3. webpackJsonpCallback(JSONP 回调函数):入口 chunk 最后把自身的 push 方法替换为该回调,并接管全局数组 self["webpackChunk"] 上已存在的历史数据。当异步 chunk 执行 push([chunkIds, moreModules, runtime]) 时,回调被触发:
    • moreModules 合并进 __webpack_require__.m(模块注册表),于是模块 3、4(cd)从此对 __webpack_require__ 可见;
    • 若携带 runtime 段则先执行运行时补丁;
    • 最后把每个 chunkIdsinstalledChunks 中置为 0,并 resolve 对应的等待 Promise——回调函数体(require("b").xyz()require("d"))就此得以执行。

这套机制的巧妙之处在于:异步 chunk 是一个普通脚本,没有任何 webpack 私有协议,它只负责往全局数组 webpackChunk 上 push 数据;入口 chunk 通过"劫持 push"拿到数据。这正是"JSONP"在 webpack 中的落地形式。

源码层实现:require.ensure 是如何被改写的

上面的产物不是运行时魔法,而是编译期 parser 插件的静态改写结果。仓库中对应的实现链路如下:

  • lib/dependencies/RequireEnsureDependenciesBlockParserPlugin.js:通过 parser.hooks.call.for("require.ensure") 拦截对 require.ensure 的调用。hook 触发时,parser 会为 require.ensure 的回调体创建一个 AsyncDependenciesBlock——这是 webpack 中"异步边界"的核心数据结构:凡是挂在该 block 之下的模块依赖(本例即 c、回调中的 bd),都会成为异步 chunk 的候选成员。这也正是 README 所说"webpack detects that these are in the on-demand-callback and will load them on demand"的实现依据。
  • lib/dependencies/RequireEnsurePlugin.js:为 require.ensure 声明 chunk 构建规则(.for("require.ensure")),让 AsyncDependenciesBlock 按"每个 require.ensure 调用生成一个 chunk"的策略拆分。
  • lib/dependencies/RequireEnsureItemDependency.js:类型标识为 "require.ensure item",对应数组中的每一项(本例是 c)。数组项被解析为依赖,保证即使回调中不 require 它,c 也会被加载进 chunk(只加载、不执行,执行留待回调内 require 时)。
  • lib/dependencies/RequireEnsureDependency.js:其 Template.apply 方法就是产物中那段 .then(...) 改写的来源——它通过 runtimeTemplate.blockPromise 生成 __webpack_require__.e(...) 的 Promise 表达式,再把源码中 require.ensure(...) 的调用范围替换为 `${promise}.then((``).bind(null, __webpack_require__))'catch'`。若原代码带有第三个 error handler 参数,catch 会绑定到用户提供的处理器,否则绑定到全局 __webpack_require__.oe。这与产物中看到的 'catch' 完全对应。

也就是说,require.ensure 的"按需"语义在编译期就固化成了两样东西:一个指向异步 chunk 的 Promise(__webpack_require__.e),以及回调依赖挂在其下的 AsyncDependenciesBlock

编译统计:Unoptimized 与 Production mode 对比

README 末尾保留了两种模式的 stats 输出,它们恰好量化了 chunk 的构成。

Unoptimized(development,README#L320-L341

asset output.js 9.16 KiB [emitted] (name: main)
asset node_modules_c_js-node_modules_d_js.output.js 562 bytes [emitted]
chunk (runtime: main) output.js (main) 161 bytes (javascript) 4.83 KiB (runtime) [entry] [rendered]
  > ./example.js main
  runtime modules 4.83 KiB 6 modules
  dependent modules 22 bytes [dependent] 2 modules
  ./example.js 139 bytes [built] [code generated]
    [used exports unknown]
    entry ./example.js main
chunk (runtime: main) node_modules_c_js-node_modules_d_js.output.js 22 bytes [rendered]
  > ./example.js 3:0-6:2
  ./node_modules/c.js 11 bytes [built] [code generated]
    [used exports unknown]
    require.ensure item c ./example.js 3:0-6:2
  ./node_modules/d.js 11 bytes [built] [code generated]
    [used exports unknown]
    cjs require d ./example.js 5:12-24
webpack X.X.X compiled successfully

几个可验证的细节:

  • 入口 chunk 中 javascript 本体仅 161 字节,而 runtime 占 4.83 KiB(6 个 runtime 模块)——小应用的"首包"几乎全是运行时,这是理解 chunk 加载开销的重要参照;
  • 异步 chunk 的来源标注 > ./example.js 3:0-6:2,即 example.js 第 3–6 行整个 require.ensure 调用区间,依赖来源标注为 require.ensure item c(对应 RequireEnsureItemDependency 的 type 名)与 cjs require d
  • 异步 chunk 内没有 b,只有 cd,直接印证了 b 被优化掉、复用入口 chunk 的模块 2。

Production mode

asset output.js 1.8 KiB [emitted] [minimized] (name: main)
asset node_modules_c_js-node_modules_d_js.output.js 108 bytes [emitted] [minimized]

production 模式下两个文件都被压缩([minimized]):入口 chunk 从 9.16 KiB 降到 1.8 KiB,异步 chunk 从 562 字节降到 108 字节。异步 chunk 之所以能压到 108 字节,是因为它不含运行时(runtime 只在 main chunk 中),压缩后只剩一次 push 调用与两个空函数。

小结与延伸

这个 6 行示例覆盖了 webpack 代码分割的完整知识闭环:

  1. 同步依赖归入口:顶层 requireab 留在 output.js
  2. 异步边界由 AsyncDependenciesBlock 表达require.ensure 的数组项与回调内依赖都成为异步 chunk 成员;
  3. 重复依赖自动去重:回调中已存在于父 chunk 的 b 被优化掉,不重复打包;
  4. 运行时通过 JSONP 注入模块installedChunks 状态机 + 全局 webpackChunk 数组的 push 劫持,实现跨 chunk 的模块注册;
  5. named chunkIds 保证产物稳定:不同模式下文件名一致,便于构建产物比对。

如果需要在现代项目中实践代码分割,仓库中的相邻示例可作为进阶参照:examples/code-splitting-harmony 演示 ES import() 语法的分割方式,examples/code-splitting-bundle-loader 展示用 bundle-loader 做外部 chunk 延迟加载,examples/chunkhash 则讲解如何用 [chunkhash] 给异步 chunk 加缓存友好哈希。但理解这些进阶用法之前,本示例的 require.ensure 机制——同步/异步依赖的判定、JSONP 运行时的加载状态机——是最值得逐行读懂的基础。

登录后查看全文
热门项目推荐
相关项目推荐