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 Cache,Preload 每次都在重复下载;少了 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 是“下一个页面可能会用”。
| 特性 | Preload | Prefetch |
|---|---|---|
| 优先级 | 高 | 低(空闲时加载) |
| 使用时机 | 当前导航关键资源 | 未来导航可能资源 |
| 浏览器支持 | 较广泛 | 较广泛 |
| 误用后果 | 抢占带宽,拖慢真正关键资源 | 浪费流量 |
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-plugin(React 项目可以用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.js(200KB)、vendor.js(300KB)、critical.css(50KB)三个资源。默认情况下,浏览器解析 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-age=31536000和Expires头。浏览器在有效期内直接用本地副本,连请求都不发。这是性能最好的状态。
协商缓存靠ETag和Last-Modified。浏览器发请求带上If-None-Match,服务端比对后返回 304,虽然省了 body 传输,但请求往返的延迟还在。
对静态资源,我们的目标就是全部命中强缓存。做法是文件名带 content hash,内容变了 hash 就变,URL 就变,浏览器自然认为是新资源;内容没变 hash 不变,URL 不变,强缓存一直有效。
4.2 Webpack 的 hash 策略选择
Webpack 提供三种 hash:hash、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 的静态资源设一年强缓存加 immutable,immutable告诉浏览器即使用户刷新也不要发协商请求;index.html 绝对不能缓存,否则用户永远拿不到新的资源引用。
踩坑记录:有一次线上发版后用户反馈“页面还是旧的”,排查半天发现是 CDN 把 index.html 也缓存了。后来在 CDN 配置里单独给 html 文件设了不缓存规则才解决。这个坑很常见,务必检查。
5. Core-js 按需注入:兼容与体积的平衡术
5.1 为什么不能全量引入 core-js
core-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。如果产品明确不需要 IE,targets 里直接排除,能省下大量 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-plugin:
const { 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 缓存策略的选择矩阵
不同资源类型要用不同策略,我整理了一张表:
| 资源类型 | 推荐策略 | 理由 |
|---|---|---|
| HTML | NetworkFirst | 必须拿最新版本 |
| JS/CSS | StaleWhileRevalidate | 有 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" 模式,看页面是否还能正常展示。这两个场景过了,基本就稳了。