首页
/ webpack 长缓存实战:用 optimization.runtimeChunk 拆分 Runtime Chunk,让 [chunkhash] 真正生效

webpack 长缓存实战:用 optimization.runtimeChunk 拆分 Runtime Chunk,让 [chunkhash] 真正生效

2026-09-06 16:27:50作者:裴锟轩Denise

在 webpack 中结合 [chunkhash] 与 Code Splitting 时,入口 chunk 因内嵌 webpack runtime(含 chunk hash 映射)而每次构建都变化,导致缓存失效。本文基于仓库示例 chunkhash 讲解如何用 optimization.runtimeChunk 将 runtime 拆分为独立 chunk 并内联进 HTML,恢复各 chunk 的长期缓存能力,并结合 webpack 源码(RuntimeChunkPluginnormalization.js 等)拆解该选项的完整取值与生效链路,帮助你既掌握可复制的配置,也理解产物中 runtime chunk 的内部结构。

一、问题:为什么 [chunkhash] 在入口 chunk 上"失效"

webpack 对每个 chunk 计算 chunkhash 时,hash 依据是该 chunk 的内容,包括其中所有模块以及 runtime 中记录的 chunk 文件名映射表。而入口 chunk 默认包含两部分内容:

  1. 入口模块本身的应用代码;
  2. webpack runtime,其中包括 chunk 加载逻辑,以及 __webpack_require__.u 这类"chunkId → 带 hash 文件名"的映射函数。

问题在于:只要任何一个异步 chunk 的内容发生变化,runtime 里的映射表就会更新,进而导致 入口 chunk 的内容变化、[chunkhash] 改变、文件名变化。入口 chunk 每次发版都无法命中浏览器缓存,[chunkhash] 对最重要的那个文件几乎失去了意义。

这正是 chunkhash 示例 开头指出的核心矛盾:"入口 chunk 包含 webpack runtime 和 chunkhash 映射,它总是被更新,[chunkhash] 因此形同虚设。"

二、解决方案:把 runtime 单独拆成一个 chunk

解决思路很直接:创建一个只包含 webpack runtime(含 chunkhash 映射)的独立 chunk。这样应用代码的任何变化只会影响各自 chunk 的 hash;runtime chunk 只在"新增/删除 chunk"这类结构变化时更新,其 hash 可以长期稳定。

webpack 通过 optimization.runtimeChunk 选项实现这一点。为了让这个很小的 chunk 不额外产生一次网络请求,官方示例进一步把它 内联进 HTML 页面

示例要求的核心配置有两项:

  • output.filename 中使用 [chunkhash]
  • output.chunkFilename 中使用 [chunkhash]

说明:仓库中该示例的实际配置文件写的是 "[name].chunkhash.js" 字面量(见下文),这是示例生成基础设施的限制——构建产物的 hash 部分由模板系统替换。README 明确提示:在真实项目中你"应该"使用 [chunkhash] 占位符。

2.1 示例源码

入口模块 example.js 触发两个异步 chunk:

// some module
import("./async1");
import("./async2");

2.2 完整 webpack 配置

以下是 webpack.config.js 的完整内容,逐项标注了作用:

"use strict";

const path = require("path");

/** @type {import("webpack").Configuration} */
const config = {
	// mode: "development" || "production",
	entry: {
		main: "./example"          // 命名入口 main,决定 runtime chunk 名为 runtime~main
	},
	optimization: {
		runtimeChunk: true          // 为每个入口创建独立 runtime chunk("multiple" 语义)
	},
	output: {
		path: path.join(__dirname, "dist"),
		filename: "[name].chunkhash.js",    // 实际项目应写作 "[name].[chunkhash].js"
		chunkFilename: "[name].chunkhash.js" // 异步 chunk 同样带上 hash
	}
};

module.exports = config;

要点:

  • runtimeChunk: true 等价于 "multiple",即每个入口各生成一个 runtime chunk,命名为 runtime~<入口名>
  • 若多个入口希望共用一个 runtime,可改为 runtimeChunk: "single",chunk 名固定为 runtime
  • output.filenameoutput.chunkFilename 都带上 [chunkhash],入口 chunk 与异步 chunk 均可随内容变化而换名,未变化的 chunk 保持缓存命中。

2.3 HTML 模板:内联 runtime chunk

index.html 中,runtime 文件的压缩后内容被直接内联到 <script> 中,只有应用入口 chunk 走外部请求:

<html>
	<head> </head>
	<body>
		<!-- inlined minimized file "runtime~main.[chunkhash].js" -->
		<script>
			{/* 此处内联压缩后的 runtime~main.[chunkhash].js 全部内容,
			    即产物中那段自执行 IIFE,见 2.4 节分析 */}
		</script>

		<script src="dist/main.[chunkhash].js"></script>
	</body>
</html>

在真实工程里,这段内联通常由模板引擎(如 html-webpack-plugin 的模板占位符)在构建时注入。其收益是:runtime 不再占用一次独立请求;代价是 runtime 内容随 chunk 结构变化时,HTML 本身会变化——但这不影响各 JS 文件的缓存。

三、读懂构建产物:runtime chunk 里到底有什么

按上述配置构建后,产物包含 4 个文件(开发模式下的 stats 输出):

asset runtime~main.[chunkhash].js 11.7 KiB [emitted] (name: runtime~main)
asset main.[chunkhash].js 813 bytes [emitted] (name: main)
asset 2.[chunkhash].js 285 bytes [emitted]
asset 3.[chunkhash].js 267 bytes [emitted]
Entrypoint main 12.5 KiB = runtime~main.[chunkhash].js 11.7 KiB main.[chunkhash].js 813 bytes
chunk (runtime: runtime~main) main.[chunkhash].js (main) 55 bytes [initial] [rendered]
chunk (runtime: runtime~main) runtime~main.[chunkhash].js (runtime~main) 7.41 KiB [entry] [rendered]
  > ./example main
  runtime modules 7.41 KiB 10 modules
chunk (runtime: runtime~main) 2.[chunkhash].js 28 bytes [rendered]
chunk (runtime: runtime~main) 3.[chunkhash].js 28 bytes [rendered]
webpack X.X.X compiled successfully

生产模式(开启压缩)下 runtime chunk 压缩到 2.74 KiB,异步 chunk id 变为确定性数字(18471):

asset runtime~main.[chunkhash].js 2.74 KiB [emitted] [minimized] (name: runtime~main)
asset main.[chunkhash].js 142 bytes [emitted] [minimized] (name: main)
asset 471.[chunkhash].js 66 bytes [emitted] [minimized]
asset 18.[chunkhash].js 64 bytes [emitted] [minimized]
Entrypoint main 2.88 KiB = runtime~main.[chunkhash].js 2.74 KiB main.[chunkhash].js 142 bytes

3.1 runtime~main chunk 的关键结构

runtime~main 产物 是一个自执行 IIFE,共含 10 个 runtime module(stats 中 runtime modules 7.41 KiB 10 modules)。摘出与本主题最相关的几段:

/******/ (() => { // webpackBootstrap
/******/ 	"use strict";
/******/ 	var __webpack_modules__ = ({});   // 注意:模块表为空
/******/ 	const __webpack_module_cache__ = {};

模块表 __webpack_modules__ 是空的——没有任何真实模块代码。它只有机制,没有内容。继续看 chunk 文件名映射函数,这就是让入口 hash 变化的"元凶",现在它被隔离在这个 chunk 里:

/******/ 	/* webpack/runtime/get javascript chunk filename */
/******/ 	// This function allow to reference async chunks
/******/ 	__webpack_require__.u = (chunkId) => (chunkId + ".[chunkhash].js");

以及 JSONP 加载机制中的 installedChunks 状态表。注意运行时 installedChunks 只记录了入口 chunk(id 为 1)为"已加载",runtime chunk 自身(id 0)不在其中:

/******/ 	/* webpack/runtime/jsonp chunk loading */
/******/ 	(() => {
/******/ 		// undefined = chunk not loaded, null = chunk preloaded/prefetched
/******/ 		// [resolve, reject, Promise] = chunk loading, 0 = chunk loaded
/******/ 		const installedChunks = {
/******/ 			1: 0
/******/ 		};
/******/ 		__webpack_require__.f.j = (chunkId, promises) => { /* JSONP 加载逻辑 */ };
/******/ 		__webpack_require__.O.j = (chunkId) => (installedChunks[chunkId] === 0);
/******/ 		const webpackJsonpCallback = (parentChunkLoadingFunction, data) => {
/******/ 			let [chunkIds, moreModules, runtime] = data;
/******/ 			/* 将 moreModules 合入 __webpack_modules__,标记 chunkIds 为已加载,
/******/ 			   执行附带的 runtime,并触发 __webpack_require__.O 的 deferred 回调 */
/******/ 		}
/******/ 		const chunkLoadingGlobal = self["webpackChunk"] = self["webpackChunk"] || [];
/******/ 		chunkLoadingGlobal.forEach(webpackJsonpCallback.bind(null, 0));
/******/ 		chunkLoadingGlobal.push = webpackJsonpCallback.bind(null, chunkLoadingGlobal.push.bind(chunkLoadingGlobal));
/******/ 	})();

其余 runtime module(__webpack_require__.e 异步 ensure、__webpack_require__.l 注入 script 标签、__webpack_require__.t 创建 fake namespace、__webpack_require__.d 定义 getter、__webpack_require__.r 标记 __esModule__webpack_require__.p = "dist/" 等)在完整产物 dist/runtime~main 一节中均有原始注释,可对照阅读。

3.2 main chunk 变得极其"干净"

main 产物 只剩入口模块代码,通过全局 webpackChunk 数组把模块 push 进 runtime 已建好的加载机制:

(self["webpackChunk"] = self["webpackChunk"] || []).push([[0],[
/* 0 */
/*!********************!*\
  !*** ./example.js ***!
  \********************/
/***/ ((__unused_webpack_module, __unused_webpack_exports, __webpack_require__) => {

// some module
__webpack_require__.e(/*! import() */ 2).then(() => (__webpack_require__.t(/*! ./async1 */ 1, 23)));
__webpack_require__.e(/*! import() */ 3).then(() => (__webpack_require__.t(/*! ./async2 */ 2, 23)));

/***/ })
],
/******/ __webpack_require__ => { // webpackRuntimeModules
/******/ var __webpack_exec__ = (moduleId) => (__webpack_require__(moduleId))
/******/ var __webpack_exports__ = (__webpack_exec__(0));
/******/ }
]);

可以看到 main chunk 里不再有任何加载机制代码。__webpack_require__.e(2)e(3) 调用由 runtime chunk 提供的机制完成,随后经 __webpack_require__.t(module, 23) 创建 namespace。这意味着 main chunk 的 [chunkhash] 只取决于 example.js 自身与异步 chunk id 的分配,二者稳定时缓存即可长期命中。

四、源码深潜:optimization.runtimeChunk 的取值与生效链路

4.1 默认值与规范化

defaults.js 可以看到该选项默认关闭:D(optimization, "runtimeChunk", false)

各取值的规范化逻辑集中在 normalization.jsgetNormalizedOptimizationRuntimeChunk 中:

用户配置 规范化结果 语义
false(默认) false 不拆分,runtime 留在入口 chunk
"single" { name: () => "runtime" } 所有入口共用一个名为 runtime 的 runtime chunk
true / "multiple" { name: (entrypoint) => \runtime~${entrypoint.name}` }` 每个入口一个 runtime chunk,如 runtime~main
{ name: "xxx" } { name: () => "xxx" } 自定义固定名称
{ name: (ep) => ... } 原样保留函数 按入口动态命名

4.2 插件注册与命名

配置生效入口在 WebpackOptionsApply.js:只要 options.optimization.runtimeChunk 为真值,就创建 RuntimeChunkPluginapply(compiler)

RuntimeChunkPlugin 的实现只有 30 行左右,其核心是监听 compilation.hooks.addEntryRuntimeChunkPlugin.js):

apply(compiler) {
	compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => {
		compilation.hooks.addEntry.tap(PLUGIN_NAME, (_, { name: entryName }) => {
			if (entryName === undefined) return;
			const data = compilation.entries.get(entryName);
			if (data.options.runtime === undefined && !data.options.dependOn) {
				// Determine runtime chunk name
				let name = this.options.name;
				if (typeof name === "function") {
					name = name({ name: entryName });
				}
				data.options.runtime = name;
			}
		});
	});
}

几个从源码可直接确认的事实:

  1. 默认命名规则:构造函数中 name 缺省为 (entrypoint) => \runtime~${entrypoint.name}`([RuntimeChunkPlugin.js](https://gitcode.com/GitHub_Trending/web/webpack/blob/b6ccff123ebd90f2811a99dcbb01692fda113353/lib/optimize/RuntimeChunkPlugin.js?utm_source=gitcode_repo_files#L20-L26)),产物名 runtime~main` 由此而来;
  2. 生效条件:仅当入口未显式设置 runtime 选项且不是 dependOn 依赖型入口时,插件才介入设置 data.options.runtime——即 entry: { name: { runtime: "..." } } 的手动指定优先级更高,dependOn 场景下 runtime 归属由主入口决定;
  3. 无匿名入口entryName === undefined 时直接跳过,说明 runtimeChunk 与无名字入口不配合。

随后,Entrypoint 通过 setRuntimeChunk / getRuntimeChunk 维护入口点与 runtime chunk 的绑定;当存在 HTML 入口时,HtmlEntryDependency 会保证 runtime chunk 的 script 被排在入口 chunk 之前注入(源码注释明确提到 "runtime chunk first (when optimization.runtimeChunk splits it off)")——这与手工内联 HTML 中"runtime 脚本在前、main 脚本在后"的顺序要求完全一致。

五、实践建议与相关示例

  • 缓存粒度权衡:拆分 runtime 后,[chunkhash] 的失效范围从"任何模块变化都改变入口"收窄到"chunk 集合/结构变化才改变 runtime chunk"。日常业务迭代中绝大多数发布,未改动的 chunk 都能稳定命中缓存。
  • runtime 内联 vs 独立请求:runtime chunk(压缩后本例约 2.74 KiB)内容变化频率低、体积小,内联进 HTML 可省掉一次请求并避免它自身的缓存问题;若用独立 <script> 引用,它同样带 [chunkhash],也能长期缓存。两种做法在 chunkhash 示例 中都有体现(模板注释 inlined minimized file "runtime~main.[chunkhash].js")。
  • 多入口场景:单页多入口用 "multiple"(各页面只加载自己那份 runtime);微前端/多应用共享同一 runtime 用 "single"。注意 "single" 下任何入口的变化都可能使共享 runtime 的 hash 变化,取舍在于"runtime 稳定性"与"入口隔离性"。
  • 与 splitChunks 结合:仓库中 http2-aggressive-splitting 示例 演示了另一半拼图——optimization.splitChunks 配合 filename: "[chunkhash].js",利用"chunk 内容确定性"让未变化 chunk 保持相同 hash;它与本文的 runtime 拆分互补,共同构成完整的长期缓存方案。
  • 验证手段:构建后检查 stats 输出中是否存在独立的 runtime~<entry> chunk(runtime modules N modules,模块表为空),以及入口 chunk 中是否只剩 self["webpackChunk"]...push 形态的代码——这是 runtime 拆分生效的直接证据,可对照 chunkhash 示例的 Info 段落 中 Unoptimized 与 Production 两组输出。

六、小结

[chunkhash] 与 Code Splitting 的矛盾根源在于 runtime(含 chunk 文件名映射)寄生在入口 chunk 中。optimization.runtimeChunk 以极低成本(一个插件、每入口一个几乎只含机制代码的小 chunk)将 runtime 隔离出去,再配合 HTML 内联,即可让"未变化即缓存"的长缓存策略在整个产物集上真正成立。源码层面,该选项经 normalization.js 规范化为命名函数后,由 RuntimeChunkPluginaddEntry 钩子中把 runtime 归属写入入口数据,默认命名 runtime~<入口名>——理解了这条链路,你就能在多入口、dependOn、手动指定 runtime 等边界场景下做出正确配置。

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