首页
/ webpack 按需加载入门:用 `require.ensure` 实现 Code Splitting 的完整示例剖析

webpack 按需加载入门:用 `require.ensure` 实现 Code Splitting 的完整示例剖析

2026-09-07 09:25:42作者:秋阔奎Evelyn

本指南围绕 webpack 官方示例中的最小化代码分割场景展开:仅用不到 20 行业务代码,演示如何通过 require.ensure 把一个 CommonJS 应用拆分为“入口 chunk + 按需加载 chunk”,并逐一拆解两个产物的内容构成、JSONP 运行时加载机制与打包器对模块的去重优化。读完你将掌握 require.ensure 的调用语法与三种参数形态、如何解读 webpack 的拆包产物与 Stats 输出,以及该 API 在当前源码中的解析实现与其与现代 import() 的演进关系。

示例概览:一个最小的 Code Splitting 场景

示例位于仓库的 examples/code-splitting 目录,由四个文件构成:

文件 作用
example.js 入口业务代码,演示 require.ensure 的用法
webpack.config.js 仅指定 chunkIds: "named" 的极简配置
template.md 示例的“写作模板”,定义待填充的产物与输出结构
README.md 构建后生成的完整文档,含真实的产物代码与构建统计

示例中的模块依赖关系非常简单:ab 通过 CommonJS 被正常引用;c 只出现在 require.ensure 的依赖数组里(被“预先暴露”但不立即执行);d 只在异步回调内部被引用。构建后会得到两个文件——入口 chunk output.js 与按需加载的 node_modules_c_js-node_modules_d_js.output.js,这一文件名恰恰由 chunkIds: "named"webpack.config.js)派生,目的是让不同模式(普通 / production)下生成的文件名保持一致。

核心语法:逐行解读 require.ensure 调用

示例的业务代码全部位于 example.js,原文如下:

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

这四行代码对应文档 template.md 归纳的四条行为规则,逐条说明如下:

  1. ab 以普通 CommonJS 方式被引用。由于它们出现在顶层同步代码中,webpack 会把它们编入入口 chunk(即主 chunk),随首屏一起加载。
  2. c 通过 require.ensure 的依赖数组“暴露”给运行时require.ensure(["c"], ...) 的第一个参数是模块列表,语义是“c 预先加载好并放进模块表,但不执行它”。webpack 据此知道 c 属于按需 chunk,会为其生成一次独立的网络请求。
  3. bdrequire.ensure 的回调函数内部被 CommonJS 引用。回调体形如 function(require) { ... },webpack 会分析这个回调的整个函数体,凡是在其中被引用的模块都被标记为“按需依赖”,最终一起进入异步 chunk。
  4. 优化器可以把回调里的 b 从异步 chunk 中剔除。因为 b 已经存在于父级(入口)chunk 中,异步 chunk 不需要再携带一份副本;运行时在回调中 require("b") 时命中入口 chunk 中已缓存的模块即可。

需要特别说明的一点是:c 只被“保证可用”,并不会因为出现在依赖数组中就被执行——真正触发执行的只有回调函数体内的 require 语句。这正是“Code Splitting”(把加载时机与执行时机分离)的精髓。

完整的调用形态与参数可选性

require.ensure 实际支持 2~4 个参数。从其解析器插件 lib/dependencies/RequireEnsureDependenciesBlockParserPlugin.js 中对 expr.arguments.lengthswitch 分支可以推断出完整签名:

require.ensure(dependencies, callback);
require.ensure(dependencies, callback, errorCallback);
require.ensure(dependencies, callback, errorCallback, chunkName);
// 也支持将第三个字符串参数当作 chunkName 的形态:
require.ensure(dependencies, callback, "my-chunk-name");
  • 依赖数组(必填):支持字符串组成的数组;从源码看,若传入单个表达式且不是数组,会被包装成单元素数组处理(isArray() ? items : [expr])。
  • 成功回调(必填):回调体内所有 require(...) 会被递归遍历,全部进入异步块。
  • 错误回调 / chunk 名(可选):当第 3 个参数是函数时视为错误回调;当它不是函数而是一个可求值为字符串的表达式时,则按 chunk 名处理;第 4 个参数则为显式 chunk 名。

在该插件中,webpack 每遇到一次 require.ensure 调用,就会在语法树上构造一个 RequireEnsureDependenciesBlock(异步依赖块),把依赖数组里的每个模块字符串转成 RequireEnsureItemDependency,并把回调函数体内的 require(...) 依依赖关系解析到对应的模块上——这正是“回调中的模块被识别为按需加载”这一行为得以成立的根本原因。

构建产物拆解:入口 chunk 与异步 chunk 各装了什么

根据 template.md(构建后的完整版见 README.md),这次构建产出两个 chunk:

dist/output.js —— 入口 chunk(main)

包含:

  • 模块系统运行时:模块缓存表、__webpack_require__ 模块加载函数,以及对模块表的引用 __webpack_require__.m
  • chunk 加载逻辑__webpack_require__.e(异步加载入口)、__webpack_require__.u(由 chunk id 计算文件名)、__webpack_require__.l(动态创建 <script> 标签)、JSONP 回调注册等运行时片段;
  • 入口点 example.js 以及模块 ab

入口代码在编译后从 var a = require("a") 变为对模块表下标的调用,而 require.ensure 调用则被替换为一次运行时异步加载(节选自 README.mddist/output.js 的入口包装部分):

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';

注意三点变化:

  • b 的二次引用被编译为 __webpack_require__(2),仍指向入口 chunk 中已有的模块 2;
  • cd 所在的模块 3、4 并没有出现在这段入口代码里,而是由 __webpack_require__.e(...) 触发按需请求;
  • 原先的回调被改造成 .then(...),并以 'catch' 兜底未捕获的错误,即“错误回调缺省时统一收口到 __webpack_require__.oe”。

dist/node_modules_c_js-node_modules_d_js.output.js —— 异步 chunk

这个按需文件只包含模块 cd,完整内容如下(节选自 README.md):

(self["webpackChunk"] = self["webpackChunk"] || []).push([["node_modules_c_js-node_modules_d_js"],[
/* 0 */,
/* 1 */,
/* 2 */,
/* 3 */
/*!***************************!*\
  !*** ./node_modules/c.js ***!
  \***************************/
/***/ (() => {

// module c

/***/ }),
/* 4 */
/*!***************************!*\
  !*** ./node_modules/d.js ***!
  \***************************/
/***/ (() => {

// module d

/***/ })
]]);

可以看到异步 chunk 的模块表与入口 chunk 的模块表共享同一份“下标空间”,因此模块 3、4 的编号是入口模块 1、2 的顺延。优化证据也直接写在这份产物里:异步 chunk 的下标 0、1、2 全部留空——尤其是下标 2(即模块 b),说明打包器确实没有把回调中引用到的 b 复制进异步 chunk。

压缩后的体积对比

该异步文件本身极小,压缩后只有一行:

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

文档 template.md 对此给出的结论是:按需 chunk 通常很小,压缩收益明显——这正是“把不常用的代码拆出去、让主包更轻”这一实践价值的直接体现。

JSONP 式 chunk 加载与运行时协作

文档明确说明“chunks are loaded via JSONP”(chunk 通过 JSONP 方式加载)。结合 README.md 中完整展示的 dist/output.js 运行时代码,可以看清整套协作链条:

  1. 入口 chunk 的运行时维护 installedChunks 状态表:undefined 表示未加载,0 表示已加载完成,数组中存放 [resolve, reject, Promise] 表示加载中。
  2. 异步加载函数 __webpack_require__.e(chunkId) 汇总所有 chunk 加载策略(此处为 JSONP 策略 __webpack_require__.f.j),首次加载时创建一个 Promise 记录到状态表,随后通过 __webpack_require__.l(...) 动态插入 <script src="dist/" + chunkId + ".output.js">
  3. 全局挂载 self["webpackChunk"] 数组作为 JSONP 回调池:异步 chunk 文件头部执行 (self["webpackChunk"] = self["webpackChunk"] || []).push([...]),被 push 的数据形如 [chunkIds, moreModules, runtime];入口运行时用自定义的 push 包装(webpackJsonpCallback)接管该数组,把 moreModules 合并进模块表、把对应 chunk 标记为已加载,并 resolve 之前创建的 Promise。
  4. 加载失败(含脚本 404、超时等)时,运行时构造带 name: "ChunkLoadError"requestevent 等字段的错误对象,最终流转到入口代码里的 'catch'

这套机制即“把异步脚本的加载结果以 JSONP 回调形式回灌给模块系统”的完整闭环,它与浏览器端 chunk 加载在 webpack 源码中的实现对应关系为:运行时源码位于 lib/runtime(含 jsonp chunk loading、ensure chunk、load script 等运行时模块),而本示例产物中“仅含入口 chunk、运行时模块 6 个、共 4.83 KiB”的描述可直接从构建统计中验证。

用 Stats 验证两种模式的差异

示例构建会分别以普通模式与 production 模式各跑一次,把 webpack X.X.X compiled successfully 的统计输出写入文档。以 README.md 的 “Info” 章节为据:

Unoptimized(普通模式)

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]
  runtime modules 4.83 KiB 6 modules
  dependent modules 22 bytes [dependent] 2 modules
  ./example.js 139 bytes [built] [code generated]
chunk (runtime: main) node_modules_c_js-node_modules_d_js.output.js 22 bytes [rendered]
  ./node_modules/c.js 11 bytes [built] [code generated]
    require.ensure item c ./example.js 3:0-6:2
  ./node_modules/d.js 11 bytes [built] [code generated]
    cjs require d ./example.js 5:12-24

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]

两段输出值得关注的信息点:

  • 模块归属一目了然require.ensure item c ./example.js 3:0-6:2 标注了 c 来自 example.js 第 3 行的 require.ensure 依赖数组;cjs require d ./example.js 5:12-24 标注了 d 来自回调体内的第 5 行 CommonJS require。这正是前述“依赖数组中的模块”与“回调中的模块”两种来源在 Stats 中的可读化呈现。
  • 体积优化显著:入口 chunk 从 9.16 KiB 压缩到 1.8 KiB;异步 chunk 从 562 字节压到 108 字节,而其中的业务模块本体仅 22 字节(cd 各 11 字节),几乎可以认为压缩后就是最优规模。
  • 统计中不会出现模块 b 的重复条目:异步 chunk 的 22 字节只由 cd 构成,印证了优化器对父 chunk 已存在模块的去重。

require.ensure 到现代 import() 的演进与实现细节

require.ensure 是 webpack 早期(webpack 1 时代、ES 模块尚未普及)提供的第一代按需加载 API,配合 AMD/CommonJS 生态而生。在源码中,它的解析实现集中在 lib/dependencies/RequireEnsurePlugin.jslib/dependencies/RequireEnsureDependenciesBlockParserPlugin.js:插件会为 javascript/autojavascript/dynamic 两类 JavaScript 模块的 parser 挂上 require.ensure 的调用钩子,并额外把 typeof require.ensure 编译期求值为字符串 "function",保证“特性探测”类代码也能正常通过解析。同时,若配置中显式声明 module.parser.javascript.requireEnsure: false,parser 将跳过注册,从而禁用该 API。

需要强调的是,这一示例属于“最基础的历史形态”。webpack 的 Code Splitting 能力在今天已全面转向标准化的动态 import() 语法,仓库中与之配套的演进示例还包括:

若在 import() 动态导入之外探索更多“代码切分”手段,还可参考 examples/common-chunk-and-vendor-chunk(公共 chunk / 第三方 vendor chunk)以及 examples/reexport-components(按需 re-export 组件)等目录。

在本地复现该示例

若要亲手跑出上述两份产物与统计,可按 examples/README.md 末尾 “Building an Example” 一节的官方流程执行:

  1. 在仓库根目录运行 yarn 安装依赖;
  2. 运行 yarn setup 完成示例环境初始化;
  3. 在仓库根目录运行 yarn add --dev webpack-cli,确保 CLI 可用;
  4. 进入具体示例目录后执行示例自身的构建脚本(构建完成后,产物会落在该目录的 dist/ 下,并把 template.md 中的 _{{...}}_ 占位符替换为真实产物与 Stats,生成 README.md)。

如需一次性构建全部示例,可在根目录执行 npm run build:examples。构建入口与占位符替换逻辑分别由 examples/buildAll.js(遍历所有含 template.md 的目录、逐个执行构建)与 examples/template-common.js(实现 _{{stdout}}__{{production:stdout}}__{{dist/xxx}}_ 等占位符的文件读取与正则替换)承载。

小结:从本示例能带走什么

通过这份最小示例,可以串联起一条完整的知识链:

  • API 语义require.ensure(deps, cb) 中,依赖数组负责“预加载”,回调体内的 require 负责“执行时引用”,两者共同决定异步 chunk 的内容边界;
  • 产物形态:入口 chunk 携带模块系统与运行时,异步 chunk 只携带业务模块,二者共享模块编号空间,并通过 JSONP 数组完成“加载即回灌”;
  • 优化行为:已被父 chunk 持有的模块不会被重复打包进异步 chunk,示例中的 b 即是最直观的验证;
  • 测量方法:普通与 production 两套 Stats 给出了量化的体积对照,可作为评估“拆包收益”的标准参照。

对现代工程而言,推荐直接使用 import() 动态导入享受同样的能力(且获得浏览器原生语义支持),但理解 require.ensure 背后的 chunk 划分与 JSONP 加载模型,仍然是读懂 webpack 产物、排查异步加载问题的一把钥匙。

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