☰
Webpack5运行时优化:Preload、缓存、Core-js与PWA实战
2026/10/9 17:59:14 网站建设 项目流程

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,结果配置复杂度上去了,收益几乎为零。

判断标准很简单,问自己三个问题:

  1. 用户主要在什么网络环境下访问?弱网占比高不高?
  2. 首屏关键资源有多少?有没有明显的加载优先级错配?
  3. 目标浏览器范围是什么?需不需要兼容老设备?

这三个问题的答案,直接决定你该重点投入哪一块。下面我逐个展开讲。

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-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 11

not 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 缓存策略的选择矩阵

不同资源类型要用不同策略,我整理了一张表:

资源类型推荐策略理由
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" 模式,看页面是否还能正常展示。这两个场景过了,基本就稳了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询