
1. 从构建工具到运行时体验的思维转变很多团队在聊 Webpack 优化时第一反应往往是“打包体积又大了”“构建时间又慢了”于是把精力全砸在 splitChunks、tree shaking、压缩插件这些构建期手段上。但真正上线之后你会发现一个尴尬的事实构建产物明明已经压到极限用户端该卡还是卡首屏该慢还是慢。问题出在哪出在我们把“优化”这件事的边界划得太窄了。Webpack5 这一代工具链真正有意思的地方是它把一部分优化能力从“构建期”延伸到了“运行时”。Preload 预加载、Network Cache 网络缓存、Core-js 按需注入、PWA 离线能力这四个东西凑在一起本质上是在回答同一个问题代码打包出来之后怎么让它在用户的浏览器里跑得更快、更稳、更省流量。这跟单纯砍体积是两条腿走路缺一条都走不远。我拿一个真实场景举例。之前接手过一个中后台系统构建产物 gzip 后也就 800KB 出头按体积算不算离谱但用户反馈“点进去要转圈好几秒”。排查下来发现主 chunk 里塞了一堆首屏根本用不到的重型依赖浏览器解析执行的时间被白白浪费同时静态资源没配缓存策略用户每次刷新都在重新下载同一批文件。这两个问题一个靠 Preload 和代码分割解决一个靠 Network Cache 解决跟“把包压得更小”完全是两码事。所以这篇内容我想聊的不是“怎么把 Webpack 配置写得更花哨”而是站在一个真正跑过线上项目的从业者角度把这四个运行时优化手段拆开揉碎讲清楚它们各自解决什么问题、底层原理是什么、配置怎么写、参数怎么算、踩过哪些坑。适合已经能跑通 Webpack5 基础配置、想让产物在真实网络环境下表现更好的前端同学。如果你还在纠结 entry 和 output 怎么写建议先把基础打牢再来看这块不然容易知其然不知其所以然。2. 四个优化手段的定位与选型逻辑2.1 它们分别解决什么层次的问题先把这四个东西的“职责边界”理清楚不然很容易混着用最后效果互相打架。Preload 属于资源加载优先级调度。浏览器解析 HTML 时对script、link这些标签有自己的默认优先级判断但这个判断经常跟我们的真实意图不一致。比如某个异步 chunk 明明马上要用浏览器却把它排在图片后面慢慢加载。Preload 就是告诉浏览器“这个资源我很急请提前、高优先级地拉取。”Network Cache 属于传输层复用。它的核心是 HTTP 缓存策略通过文件名 hash 强缓存让用户第二次访问时直接从本地磁盘读一个字节都不用重新下载。这跟 Preload 是两个维度的事一个管“什么时候下”一个管“要不要下”。Core-js 属于语法兼容的按需供给。老浏览器不认识 Promise、async/await、Array.prototype.flat 这些新特性需要 polyfill 兜底。但全量引入 core-js 会让包体积暴涨按需注入才是正解。它解决的是“代码能不能跑”的问题而不是“跑得快不快”。PWA 属于离线与缓存编排。Service Worker 拦截网络请求配合 Cache Storage 做资源缓存让应用在弱网甚至断网时依然可用。它跟 Network Cache 有重叠但层次更高一个在 HTTP 层一个在应用层。2.2 为什么这四个要一起做单独做任何一个都有收益但组合起来才能形成闭环。我画个简单的逻辑链你就明白了代码分割把产物拆成多个 chunk这是前提Preload 让关键 chunk 提前加载缩短首屏关键路径Network Cache 让非首次访问几乎零下载Core-js 保证老浏览器不白屏PWA 兜住弱网和离线场景。少了代码分割Preload 无从谈起少了 Network CachePreload 每次都在重复下载少了 Core-js老设备直接报错前面优化全白费少了 PWA弱网用户体验断崖式下跌。它们是一条链上的不同环节不是四个独立的开关。2.3 选型时的取舍原则这里有个经验不要为了用而用。我见过一些项目明明是个内部工具用户全是内网千兆环境还硬上 PWA 和 Preload结果配置复杂度上去了收益几乎为零。判断标准很简单问自己三个问题用户主要在什么网络环境下访问弱网占比高不高首屏关键资源有多少有没有明显的加载优先级错配目标浏览器范围是什么需不需要兼容老设备这三个问题的答案直接决定你该重点投入哪一块。下面我逐个展开讲。3. Preload 预加载把关键资源抢在起跑线3.1 浏览器默认加载优先级的坑浏览器给资源排优先级依据的是资源类型和它在文档中的位置。大致规律是HTML 最高CSS 和同步 script 次之图片、字体、异步 script 靠后。但这个默认策略经常跟实际需求对不上。举个典型例子。你用import()动态导入了一个路由组件Webpack 会把它单独打成一个 chunk运行时通过动态插入script标签加载。这个标签是 JS 运行时插进去的浏览器发现它的时候往往已经在解析主 bundle 了优先级被压得很低。如果这个 chunk 是首屏路由需要的用户就会看到明显的延迟。再比如字体文件。CSS 里font-face引用的字体浏览器要等 CSS 解析完、发现确实用到了才去下载这个时间差会导致文字闪烁FOIT/FOUT。Preload 能把这个字体提前拉下来。3.2 Preload 与 Prefetch 的区别这俩经常被搞混我用一句话区分Preload 是“当前页面马上要用”Prefetch 是“下一个页面可能会用”。特性PreloadPrefetch优先级高低空闲时加载使用时机当前导航关键资源未来导航可能资源浏览器支持较广泛较广泛误用后果抢占带宽拖慢真正关键资源浪费流量Preload 用错了代价很大因为它优先级高会跟首屏真正关键的 CSS、HTML 抢带宽。我踩过的坑就是给一堆异步 chunk 全加了 Preload结果首屏 CSS 反而被挤到后面LCP 指标不降反升。所以 Preload 一定要克制只给“首屏渲染关键路径上、且浏览器默认优先级偏低”的资源用。3.3 Webpack5 中配置 Preload 的实操Webpack5 内置了对 Preload/Prefetch 的支持通过魔法注释就能控制// 给动态导入的 chunk 加 preload import(/* webpackPreload: true */ ./CriticalComponent); // 给动态导入的 chunk 加 prefetch import(/* webpackPrefetch: true */ ./NextPageComponent);但这里有个关键点webpackPreload只在父 chunk 被加载时才会触发预加载而且它依赖运行时的__webpack_require__.e逻辑。如果你想让某个 chunk 在 HTML 解析阶段就被预加载更可靠的做法是用preload-webpack-plugin或者手写 HTML 注入。我一般用vue/preload-webpack-pluginReact 项目可以用preload-webpack-plugin的社区维护版配置大概长这样const PreloadWebpackPlugin require(vue/preload-webpack-plugin); module.exports { plugins: [ new PreloadWebpackPlugin({ rel: preload, as(entry) { if (/\.css$/.test(entry)) return style; if (/\.woff2?$/.test(entry)) return font; return script; }, include: asyncChunks, // 只给异步 chunk 加避免主 chunk 重复 fileBlacklist: [/\.map$/, /hot-update\.js$/], }), ], };include: asyncChunks这个配置很关键。如果设成allChunks主 bundle 也会被加上 preload但主 bundle 本来就是同步加载的再加 preload 纯属浪费甚至可能触发重复下载警告。3.4 参数计算与效果验证Preload 的效果怎么量化我一般看两个指标关键请求链长度和首屏资源加载完成时间。假设首屏需要 main.js200KB、vendor.js300KB、critical.css50KB三个资源。默认情况下浏览器解析 HTML 发现 main.js下载执行main.js 里再动态加载 vendor这是一条串行链。加了 Preload 之后三个资源并行下载理论加载时间从t1 t2 t3变成max(t1, t2, t3)。用 Chrome DevTools 的 Network 面板把 throttling 设成 Fast 3G对比加 Preload 前后的瀑布图能直观看到并行度提升。Lighthouse 里的 Preload key requests 审计项也会给出建议。注意Preload 的资源如果 3 秒内没被使用Chrome 会在控制台警告 The resource was preloaded but not used within a few seconds。看到这个警告说明你 Preload 用多了赶紧删。4. Network Cache让第二次访问几乎零成本4.1 HTTP 缓存的两种策略Network Cache 的本质是 HTTP 缓存分强缓存和协商缓存两种。强缓存靠Cache-Control: max-age31536000和Expires头。浏览器在有效期内直接用本地副本连请求都不发。这是性能最好的状态。协商缓存靠ETag和Last-Modified。浏览器发请求带上If-None-Match服务端比对后返回 304虽然省了 body 传输但请求往返的延迟还在。对静态资源我们的目标就是全部命中强缓存。做法是文件名带 content hash内容变了 hash 就变URL 就变浏览器自然认为是新资源内容没变 hash 不变URL 不变强缓存一直有效。4.2 Webpack 的 hash 策略选择Webpack 提供三种 hashhash、chunkhash、contenthash。选错了缓存直接失效。hash整个构建的 hash任何一个文件改动所有文件名都变。绝对不要用于生产环境。chunkhash按 chunk 计算但同一个 chunk 里的 CSS 和 JS 会共享 hash改 JS 会导致 CSS 文件名也变。contenthash按文件内容计算最精确。生产环境首选。配置示例module.exports { output: { filename: js/[name].[contenthash:8].js, chunkFilename: js/[name].[contenthash:8].chunk.js, }, plugins: [ new MiniCssExtractPlugin({ filename: css/[name].[contenthash:8].css, }), ], };contenthash:8取前 8 位碰撞概率极低同时文件名更短。4.3 runtimeChunk 与 moduleIds 的配合光有 contenthash 还不够有个隐蔽的坑Webpack 的 runtime 代码里包含了 chunk 的映射关系。如果 runtime 被打进主 bundle那么任何一个 chunk 的 hash 变化都会导致 runtime 变化进而导致主 bundle 的 contenthash 变化缓存全部失效。解决办法是把 runtime 单独抽出来module.exports { optimization: { runtimeChunk: single, moduleIds: deterministic, }, };runtimeChunk: single把 runtime 抽成独立的 runtime.js它的体积很小几 KB变化频繁也无所谓。moduleIds: deterministic保证模块 ID 在多次构建间稳定不会因为模块顺序变化导致 hash 抖动。我实测过一个项目没配这两项时改一个业务组件会导致 60% 的文件 hash 变化配上之后只有真正改动的那几个文件 hash 变缓存命中率从 40% 提升到 90% 以上。4.4 服务端缓存头的正确姿势前端配好了 hash服务端还得配合。Nginx 配置大概这样location ~* \.(js|css|woff2?|png|jpg|svg)$ { expires 1y; add_header Cache-Control public, immutable; } location /index.html { add_header Cache-Control no-cache, no-store, must-revalidate; }两个要点带 hash 的静态资源设一年强缓存加 immutableimmutable告诉浏览器即使用户刷新也不要发协商请求index.html 绝对不能缓存否则用户永远拿不到新的资源引用。踩坑记录有一次线上发版后用户反馈“页面还是旧的”排查半天发现是 CDN 把 index.html 也缓存了。后来在 CDN 配置里单独给 html 文件设了不缓存规则才解决。这个坑很常见务必检查。5. Core-js 按需注入兼容与体积的平衡术5.1 为什么不能全量引入 core-jscore-js 是 JS 标准库的 polyfill 集合全量引入大概 200KB压缩后。但一个项目实际用到的 API 可能只有十几个全量引入等于 90% 的代码是浪费。更麻烦的是全量引入还会污染全局环境某些 polyfill 的实现可能跟业务代码冲突。所以按需注入是唯一正确的做法。5.2 useBuiltIns 的三种模式Babel 的babel/preset-env提供useBuiltIns配置有三个值模式行为适用场景false不自动引入需手动 import库开发entry在入口统一引入按 targets 裁剪应用开发旧方案usage按实际使用自动引入应用开发推荐usage模式最省心Babel 会扫描代码里用到的 API只引入对应的 polyfill。配置module.exports { presets: [ [babel/preset-env, { useBuiltIns: usage, corejs: 3, targets: 0.5%, last 2 versions, not dead, }], ], };corejs: 3指定用 core-js 3.x 版本这个必须显式声明否则 Babel 会警告。5.3 targets 配置的取舍targets决定了要兼容哪些浏览器直接影响 polyfill 的数量。配得越宽注入越多。我的经验是不要无脑兼容 IE。如果产品明确不需要 IEtargets 里直接排除能省下大量 polyfill。用browserslist配置 0.5% last 2 versions not dead not ie 11not dead排除官方已停止维护的浏览器not ie 11明确排除 IE11。这两条一加polyfill 体积能砍掉一半以上。5.4 一个容易忽略的细节polyfill 的加载时机usage模式注入的 polyfill 是跟业务代码混在一起的这意味着 polyfill 会在业务代码执行前先执行。这本身没问题但如果你用了代码分割polyfill 可能被分到某个异步 chunk 里导致主 chunk 执行时 API 还不存在。解决办法是用babel/plugin-transform-runtime配合corejs选项把 polyfill 转成模块化引入避免全局污染同时保证依赖关系正确module.exports { plugins: [ [babel/plugin-transform-runtime, { corejs: 3, helpers: true, regenerator: true, }], ], };注意transform-runtime和useBuiltIns: usage不要同时用二选一即可。前者适合库后者适合应用。我一般应用项目用usage简单直接。6. PWA 离线能力弱网场景的兜底方案6.1 Service Worker 的工作机制PWA 的核心是 Service Worker一个运行在浏览器后台的独立线程能拦截页面的网络请求。它的生命周期分三步注册、安装、激活。注册在页面 JS 里做if (serviceWorker in navigator) { window.addEventListener(load, () { navigator.serviceWorker.register(/sw.js); }); }安装阶段通常做资源预缓存激活阶段清理旧缓存。拦截请求靠fetch事件self.addEventListener(fetch, (event) { event.respondWith( caches.match(event.request).then((cached) { return cached || fetch(event.request); }) ); });这段逻辑是“缓存优先”适合静态资源。对 API 请求一般用“网络优先”保证数据新鲜度。6.2 Workbox 的集成方式手写 Service Worker 容易出错推荐用 Workbox。Webpack 里用workbox-webpack-pluginconst { GenerateSW } require(workbox-webpack-plugin); module.exports { plugins: [ new GenerateSW({ clientsClaim: true, skipWaiting: true, runtimeCaching: [ { urlPattern: /\.(?:js|css|woff2?)$/, handler: StaleWhileRevalidate, options: { cacheName: static-resources, expiration: { maxEntries: 60, maxAgeSeconds: 30 * 24 * 60 * 60, }, }, }, { urlPattern: /^https:\/\/api\./, handler: NetworkFirst, options: { cacheName: api-cache, networkTimeoutSeconds: 3, }, }, ], }), ], };StaleWhileRevalidate策略是“先用缓存同时后台更新”适合静态资源NetworkFirst是“先请求网络失败再用缓存”适合 API。networkTimeoutSeconds: 3表示 3 秒没响应就走缓存避免弱网下用户干等。6.3 缓存策略的选择矩阵不同资源类型要用不同策略我整理了一张表资源类型推荐策略理由HTMLNetworkFirst必须拿最新版本JS/CSSStaleWhileRevalidate有 hash更新不频繁图片/字体CacheFirst几乎不变优先本地API 数据NetworkFirst数据新鲜度优先埋点上报NetworkOnly不需要缓存选错策略的后果很严重。我见过把 HTML 设成 CacheFirst 的结果用户永远看到旧版本发版等于没发。6.4 更新机制与用户提示Service Worker 更新是个麻烦事。新版本安装后处于 waiting 状态要等所有旧页面关闭才激活。用户可能一直开着旧页面永远拿不到新版本。解决办法是监听updatefound提示用户刷新navigator.serviceWorker.register(/sw.js).then((reg) { reg.onupdatefound () { const installing reg.installing; installing.onstatechange () { if (installing.state installed navigator.serviceWorker.controller) { // 提示用户有新版本 showUpdateToast(); } }; }; });配合skipWaiting: true和clientsClaim: true新 SW 安装后立即接管。但这样可能导致页面资源版本不一致所以更稳妥的做法是提示用户手动刷新。实操心得PWA 的调试很痛苦因为 Service Worker 缓存顽固。Chrome DevTools 的 Application 面板可以手动 unregister 和清缓存调试时记得常清。另外localhost和https才能启用 SW本地开发注意协议。7. 常见问题与排查技巧实录7.1 Preload 相关问题控制台警告 resource was preloaded but not used原因Preload 的资源在几秒内没被使用。排查方向检查include配置是否包含了不需要的 chunk确认 Preload 的资源确实是首屏关键路径上的。问题Preload 后 LCP 反而变慢原因Preload 优先级太高抢占了真正关键资源的带宽。解决减少 Preload 数量只保留最关键的 2-3 个或者改用 Prefetch。7.2 Network Cache 相关问题发版后用户还是旧版本排查顺序先看 index.html 的缓存头确认没被强缓存再看 CDN 是否缓存了 html最后检查 Service Worker 是否缓存了旧 HTML。问题contenthash 频繁变化缓存命中率低原因runtime 没抽离或 moduleIds 没设成 deterministic。解决配runtimeChunk: single和moduleIds: deterministic。7.3 Core-js 相关问题Babel 警告 core-js version not specified解决在 preset-env 里显式加corejs: 3。问题polyfill 没生效老浏览器报错排查确认 targets 包含了目标浏览器检查useBuiltIns是否设成了usage看构建产物里有没有对应的 polyfill 代码。7.4 PWA 相关问题Service Worker 注册失败排查确认是 https 或 localhost检查 sw.js 路径是否正确看 DevTools Application 面板的报错信息。问题缓存不更新解决改 cacheName 版本号或在 activate 事件里清理旧缓存self.addEventListener(activate, (event) { event.waitUntil( caches.keys().then((names) { return Promise.all( names.filter((name) name ! CURRENT_CACHE) .map((name) caches.delete(name)) ); }) ); });7.5 综合排查速查表现象可能原因快速验证首屏慢Preload 缺失或错配Network 面板看瀑布图二次访问慢强缓存未命中看响应头 Cache-Control老浏览器白屏polyfill 缺失看控制台报错断网不可用SW 未注册或策略错Application 面板看 SW 状态发版不生效HTML 被缓存curl 看响应头8. 我在实际项目中的组合拳打法聊完四个手段最后说说我实际项目里怎么组合使用。这套配置不是标准答案但经过几个中大型项目验证效果稳定。第一步先把代码分割做扎实。用splitChunks把 vendor、common、runtime 分开这是所有运行时优化的前提。没有分割Preload 和缓存都无从谈起。第二步配好 contenthash 和 runtimeChunk。这一步做完缓存命中率基本能到 85% 以上是投入产出比最高的一环。第三步按需加 Preload。只给首屏关键路由的异步 chunk 加数量控制在 3 个以内。加完用 Lighthouse 验证LCP 没改善就撤掉。第四步Core-js 用 usage 模式targets 排除 IE。这一步主要是控制体积对性能影响是间接的。第五步PWA 作为兜底。如果产品面向 C 端、弱网用户占比高就上 Workbox如果是内部系统可以跳过。这套组合下来我经手的一个项目首屏 LCP 从 3.2s 降到 1.8s二次访问加载时间从 1.5s 降到 0.3s弱网下的可用性也有明显提升。当然具体数字因项目而异但方向是对的。有个细节值得单独提这些优化要持续监控不是配一次就完事。我一般会在 CI 里加 Lighthouse CI每次发版跑一遍指标跌了就报警。因为业务代码一直在变今天合理的 Preload 配置下个版本可能就过时了。另外别迷信工具给的默认配置。Workbox 的GenerateSW默认策略不一定适合你的项目runtimeCaching一定要根据实际资源类型定制。我见过直接抄默认配置结果 API 缓存了敏感数据的这种坑踩一次就够记一辈子。最后分享一个我常用的验证方法把 DevTools 的 Network 设成 Slow 3G Disable cache然后硬刷新看首屏渲染时间。再切到 Offline 模式看页面是否还能正常展示。这两个场景过了基本就稳了。