webpack 长缓存实战:用 optimization.runtimeChunk 拆分 Runtime Chunk,让 [chunkhash] 真正生效
在 webpack 中结合 [chunkhash] 与 Code Splitting 时,入口 chunk 因内嵌 webpack runtime(含 chunk hash 映射)而每次构建都变化,导致缓存失效。本文基于仓库示例 chunkhash 讲解如何用 optimization.runtimeChunk 将 runtime 拆分为独立 chunk 并内联进 HTML,恢复各 chunk 的长期缓存能力,并结合 webpack 源码(RuntimeChunkPlugin、normalization.js 等)拆解该选项的完整取值与生效链路,帮助你既掌握可复制的配置,也理解产物中 runtime chunk 的内部结构。
一、问题:为什么 [chunkhash] 在入口 chunk 上"失效"
webpack 对每个 chunk 计算 chunkhash 时,hash 依据是该 chunk 的内容,包括其中所有模块以及 runtime 中记录的 chunk 文件名映射表。而入口 chunk 默认包含两部分内容:
- 入口模块本身的应用代码;
- 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.filename与output.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 变为确定性数字(18、471):
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.js 的 getNormalizedOptimizationRuntimeChunk 中:
| 用户配置 | 规范化结果 | 语义 |
|---|---|---|
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 为真值,就创建 RuntimeChunkPlugin 并 apply(compiler)。
RuntimeChunkPlugin 的实现只有 30 行左右,其核心是监听 compilation.hooks.addEntry(RuntimeChunkPlugin.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;
}
});
});
}
几个从源码可直接确认的事实:
- 默认命名规则:构造函数中
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` 由此而来; - 生效条件:仅当入口未显式设置
runtime选项且不是dependOn依赖型入口时,插件才介入设置data.options.runtime——即entry: { name: { runtime: "..." } }的手动指定优先级更高,dependOn场景下 runtime 归属由主入口决定; - 无匿名入口:
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 规范化为命名函数后,由 RuntimeChunkPlugin 在 addEntry 钩子中把 runtime 归属写入入口数据,默认命名 runtime~<入口名>——理解了这条链路,你就能在多入口、dependOn、手动指定 runtime 等边界场景下做出正确配置。
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