webpack 长缓存实战:用 optimization.runtimeChunk 拆分 Runtime Chunk,让 [chunkhash] 真正生效
【免费下载链接】webpackA bundler for javascript and friends. Packs many modules into a few bundled assets. Code Splitting allows for loading parts of the application on demand. Through "loaders", modules can be CommonJs, AMD, ES6 modules, CSS, Images, JSON, Coffeescript, LESS, ... and your custom stuff.项目地址: https://gitcode.com/GitHub_Trending/web/webpack
在 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 bytes3.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://link.gitcode.com/i/b98e0241732aeaadc85109e1aea88a1f#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 (whenoptimization.runtimeChunksplits 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等边界场景下做出正确配置。
【免费下载链接】webpackA bundler for javascript and friends. Packs many modules into a few bundled assets. Code Splitting allows for loading parts of the application on demand. Through "loaders", modules can be CommonJs, AMD, ES6 modules, CSS, Images, JSON, Coffeescript, LESS, ... and your custom stuff.项目地址: https://gitcode.com/GitHub_Trending/web/webpack
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考