webpack 代码分割实战:从 require.ensure 示例看懂 chunk 拆分与 JSONP 按需加载机制
本文基于 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 中的划分逻辑,各模块的去向如下:
a和b通过 CommonJS 正常require,属于入口依赖,直接进入入口 chunk;c出现在require.ensure的第一个参数数组中——它只是被"声明可用"(made available, but doesn't get execute),webpack 会按需加载它,但在回调执行前不会执行其模块代码;b和d在require.ensure的回调内部通过 CommonJSrequire。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 内只注册了模块 a 和 b(模块索引 1 和 2),入口 example.js 与运行时模块另有其位。异步 chunk 中的 c、d 完全不在此文件中。
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 中,其工作方式值得逐段理解:
installedChunks状态表:undefined表示 chunk 未加载、null表示已预加载/预取、0表示已加载完成、[resolve, reject, Promise]表示加载中。本例初始状态为{ "main": 0 },即入口 chunk 视为已安装。__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。webpackJsonpCallback(JSONP 回调函数):入口 chunk 最后把自身的push方法替换为该回调,并接管全局数组self["webpackChunk"]上已存在的历史数据。当异步 chunk 执行push([chunkIds, moreModules, runtime])时,回调被触发:- 将
moreModules合并进__webpack_require__.m(模块注册表),于是模块 3、4(c、d)从此对__webpack_require__可见; - 若携带
runtime段则先执行运行时补丁; - 最后把每个
chunkIds在installedChunks中置为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、回调中的b与d),都会成为异步 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,只有c和d,直接印证了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 代码分割的完整知识闭环:
- 同步依赖归入口:顶层
require的a、b留在output.js; - 异步边界由
AsyncDependenciesBlock表达:require.ensure的数组项与回调内依赖都成为异步 chunk 成员; - 重复依赖自动去重:回调中已存在于父 chunk 的
b被优化掉,不重复打包; - 运行时通过 JSONP 注入模块:
installedChunks状态机 + 全局webpackChunk数组的 push 劫持,实现跨 chunk 的模块注册; - named chunkIds 保证产物稳定:不同模式下文件名一致,便于构建产物比对。
如果需要在现代项目中实践代码分割,仓库中的相邻示例可作为进阶参照:examples/code-splitting-harmony 演示 ES import() 语法的分割方式,examples/code-splitting-bundle-loader 展示用 bundle-loader 做外部 chunk 延迟加载,examples/chunkhash 则讲解如何用 [chunkhash] 给异步 chunk 加缓存友好哈希。但理解这些进阶用法之前,本示例的 require.ensure 机制——同步/异步依赖的判定、JSONP 运行时的加载状态机——是最值得逐行读懂的基础。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00