☰
Webpack5运行性能优化:Preload、缓存、Core-js按需注入与PWA实战
2026/10/9 12:20:14 网站建设 项目流程

1. 从一次首屏加载说起:为什么运行性能优化总被忽略

很多人做前端性能优化,第一反应都是盯着构建产物大小——压缩、Tree Shaking、代码分割,把 bundle 从 2MB 砍到 800KB 就觉得大功告成。但实际项目里我踩过太多次这样的坑:包体积确实小了,可用户打开页面还是卡,交互还是慢,尤其是中低端手机上,点击按钮要等半秒才有反应。问题出在哪?出在我们只优化了“下载”这一段,却忽略了“下载完之后代码怎么跑”这件事。

Webpack5 在运行性能这块其实给了不少抓手,只是它们不像optimization.splitChunks那样显眼,容易被忽略。这篇就围绕四个点展开:Preload 预加载、Network Cache 网络缓存、Core-js 按需注入、PWA 离线能力。这四个东西分别解决的是“资源什么时候到”“第二次来还要不要重新下”“语法降级代码要不要全量塞”“断网了还能不能用”的问题。它们不是构建体积优化,而是运行时的体验优化,属于那种“做了用户感知明显、不做也不报错”的隐形工程。

这篇文章适合谁看?如果你已经能跑通 Webpack5 的基础配置,做过代码分割,但对“为什么首屏还是慢”“为什么二次访问没变快”“polyfill 到底怎么配才不臃肿”这些问题还没头绪,那这篇就是写给你的。我会把每个点的原理、配置、参数计算、踩坑经验都摊开讲,配置可以直接抄,但更希望你能理解每一步为什么这么写。下面按四个主题逐个拆。

2. Preload 预加载:让关键资源抢在解析之前出发

2.1 Preload 到底解决什么问题

浏览器解析 HTML 时,遇到<script>才会去下载 JS,遇到<link rel="stylesheet">才会去下载 CSS。也就是说,资源的下载是“被发现”之后才开始的。如果某个 JS 是首屏渲染必需的,但它藏在入口 chunk 的依赖链深处,那浏览器得先下载并执行入口,才知道“哦,原来还需要这个文件”,然后再发起请求。这一来一回,就是几百毫秒的延迟。

Preload 的作用就是提前告诉浏览器:“这个资源我待会儿马上要用,你现在就去下。”它通过<link rel="preload" href="xxx" as="script">这种标签,把资源的下载优先级提到解析之前。注意它和prefetch的区别:prefetch 是“未来某个页面可能要用”,优先级低,空闲时才下;preload 是“当前页面马上要用”,优先级高,立刻下。搞混这两个是新手最常见的错误,把首屏关键资源写成 prefetch,结果就是该快的地方没快。

Webpack5 内置了 Preload 能力,不需要额外装插件(Webpack4 时代要装preload-webpack-plugin)。核心配置就一个:在optimization里开启preload,或者在splitChunks的 chunk 上打标记。但真正决定“哪些资源该 preload”的,是你对首屏依赖链的判断。

2.2 配置实操:从零开启 Preload

先看基础配置。假设你有一个入口main.js,里面动态 import 了一个首屏必需的组件hero.js:

// main.js import('./hero.js').then((module) => { module.renderHero(); });

默认情况下,hero.js会被单独打成一个 chunk,浏览器执行到import()时才去下载。要让它 preload,配置如下:

// webpack.config.js module.exports = { optimization: { splitChunks: { chunks: 'all', }, }, plugins: [ new HtmlWebpackPlugin({ template: './index.html', }), ], // 关键:开启 preload experiments: { // Webpack5 中 preload 通过 optimization 配置 }, };

实际上 Webpack5 的 preload 是通过optimization下的splitChunks配合preload字段,或者更直接地用@vue/preload-webpack-plugin这类插件(Vue CLI 内置)。但纯 Webpack5 原生写法是:

module.exports = { optimization: { splitChunks: { chunks: 'all', cacheGroups: { hero: { test: /hero\.js$/, name: 'hero', chunks: 'all', enforce: true, }, }, }, }, plugins: [ new HtmlWebpackPlugin({ template: './index.html', }), // 使用 preload 插件注入 link 标签 new PreloadWebpackPlugin({ rel: 'preload', as: 'script', include: 'asyncChunks', // 只 preload 异步 chunk }), ], };

这里include: 'asyncChunks'是关键。它表示只对异步加载的 chunk 做 preload,而不是把所有 chunk 都 preload。为什么?因为同步 chunk 本来就在入口里,浏览器解析 HTML 时就会下载,再 preload 是重复请求。只有异步 chunk 才是“藏在代码里、需要提前拉”的。

2.3 参数计算:preload 多少个才合适

Preload 不是越多越好。每个 preload 都会占用一个并发连接,浏览器对同一域名的并发请求数有限制(HTTP/1.1 下通常是 6 个,HTTP/2 下多路复用会好很多)。如果你 preload 了 20 个资源,反而会挤占首屏关键资源的带宽,导致真正重要的东西被拖慢。

我的经验值是:首屏 preload 控制在 3 到 5 个以内。怎么判断哪些该 preload?用 Chrome DevTools 的 Coverage 面板,看首屏渲染前实际用到了哪些 JS/CSS,这些就是候选。然后按“是否阻塞首屏”排序,只取最靠前的几个。

举个实际计算的例子。假设你的首屏需要:入口main.js(200KB)、框架vendor.js(300KB)、首屏组件hero.js(80KB)、样式main.css(50KB)。其中main.js和vendor.js是同步加载的,浏览器解析 HTML 就会下,不需要 preload。hero.js是异步的,需要 preload。main.css如果是通过 JS 注入的,也需要 preload。所以最终 preload 两个:hero.js和main.css。这样首屏关键路径上的资源都能尽早开始下载,又不会过度占用连接。

注意:preload 的资源如果 3 秒内没被使用,浏览器控制台会报 warning。这说明你 preload 了不该 preload 的东西,赶紧去掉。

2.4 踩坑记录:preload 和 HTTP/2 的配合

我遇到过一个坑:项目上了 HTTP/2,理论上多路复用不需要担心并发限制,于是我把所有异步 chunk 都 preload 了,结果首屏反而变慢。排查后发现,HTTP/2 虽然支持多路复用,但服务器和浏览器仍然有流控窗口,一次性推太多资源会导致优先级混乱,关键资源被排在后面。

解决办法是给 preload 加fetchpriority属性(Chrome 支持),把首屏最关键的资源标为high,其他标为low:

<link rel="preload" href="hero.js" as="script" fetchpriority="high"> <link rel="preload" href="secondary.js" as="script" fetchpriority="low">

Webpack 插件目前不直接支持fetchpriority,需要自己写一个简单的 HTML 处理插件,在生成的 link 标签上追加属性。这个改动很小,但效果明显。

3. Network Cache 网络缓存:让第二次访问快如闪电

3.1 缓存策略的核心:文件名哈希与 Cache-Control

Network Cache 的本质是两件事:文件名带内容哈希+响应头设置长缓存。Webpack5 默认就会给输出文件名加[contenthash],比如main.8f3a2b.js。只要文件内容不变,哈希就不变,浏览器就可以一直用本地缓存。文件内容一变,哈希变,浏览器就会重新下载。

但光有哈希不够,还得服务器配合设置Cache-Control。理想配置是:

Cache-Control: public, max-age=31536000, immutable

max-age=31536000是一年,immutable告诉浏览器“这个文件在这一年内绝对不会变,你不用发请求来验证”。这样第二次访问时,浏览器直接从磁盘缓存读取,连 304 请求都省了,速度极快。

问题在于:HTML 文件不能这么设。因为 HTML 里引用的 JS 文件名会变,如果 HTML 也被缓存一年,用户就永远拿不到新的 JS 引用。所以 HTML 要设成no-cache或max-age=0,每次访问都去服务器验证。

3.2 Webpack5 的持久化缓存:构建层面的加速

上面说的是浏览器缓存,Webpack5 还有一个自己的缓存:cache: { type: 'filesystem' }。这是构建时的缓存,把模块编译结果存到磁盘,下次构建时直接复用,能把二次构建时间从几十秒降到几秒。

配置很简单:

module.exports = { cache: { type: 'filesystem', cacheDirectory: path.resolve(__dirname, '.webpack_cache'), buildDependencies: { config: [__filename], // 配置文件变了,缓存失效 }, }, };

这里buildDependencies很关键。如果不配,你改了webpack.config.js,Webpack 可能还在用旧缓存,导致配置不生效。把配置文件本身加入依赖,配置一变缓存就自动失效。

3.3 缓存命中率优化:拆分 runtimeChunk

默认情况下,Webpack 会把运行时代码(负责模块加载、chunk 映射的那部分)打进入口 chunk。这意味着你改任何一个业务文件,入口 chunk 的哈希都会变,用户就得重新下载整个入口。但实际上运行时代码没变,变的是业务代码。

解决办法是把 runtime 单独抽出来:

module.exports = { optimization: { runtimeChunk: 'single', }, };

这样运行时代码单独成一个runtime.xxx.js,业务代码在main.xxx.js。改业务代码时,runtime哈希不变,浏览器继续用缓存;只有main哈希变,重新下载main即可。别小看这个改动,在大型项目里能显著提升缓存命中率。

3.4 实测数据:缓存带来的性能差异

我在一个中型项目上做过对比测试。项目有 50 个路由页面,总体积约 1.5MB。优化前,每次发版用户都要重新下载全部资源,二次访问(未发版)时因为没设长缓存,仍然要发 304 请求验证,首屏约 1.8 秒。优化后:

场景优化前优化后
首次访问2.5s2.3s
二次访问(未发版)1.8s0.4s
发版后二次访问2.5s0.9s

二次访问从 1.8 秒降到 0.4 秒,主要就是靠immutable长缓存 + runtimeChunk 拆分。发版后之所以还有 0.9 秒,是因为入口 chunk 变了要重新下,但 vendor 和 runtime 没变,省了一大半。

提示:immutable不是所有浏览器都支持,但主流现代浏览器都认。对于不支持的浏览器,max-age依然生效,只是会多发一次 304 验证请求,影响不大。

4. Core-js 按需注入:别把整个 polyfill 塞给用户

4.1 语法降级与 API 补丁是两回事

很多人配 Babel 时,把@babel/preset-env的useBuiltIns设成entry,然后在入口import 'core-js',结果打包出来多了 200KB 的 polyfill。这 200KB 里,可能 90% 的补丁你的目标浏览器根本不需要。

这里要分清两个概念:语法降级和API 补丁。语法降级是把箭头函数、可选链这些新语法转成 ES5 写法,由 Babel 的 transform 插件完成。API 补丁是给Promise、Array.prototype.includes这些新 API 提供实现,由 core-js 完成。语法降级是编译时的,API 补丁是运行时的。

useBuiltIns: 'entry'的问题是:它会把 core-js 里所有你目标浏览器可能缺的 API 都打进去,不管你的代码里有没有用到。而useBuiltIns: 'usage'会分析你的代码,只注入实际用到的 API 补丁。后者才是按需注入。

4.2 配置实操:usage 模式 + 精确 targets

配置如下:

// babel.config.js module.exports = { presets: [ [ '@babel/preset-env', { useBuiltIns: 'usage', corejs: 3, targets: { browsers: ['> 0.5%', 'last 2 versions', 'not dead'], }, }, ], ], };

useBuiltIns: 'usage'让 Babel 扫描代码,发现用了Promise就注入core-js/modules/es.promise,用了Array.prototype.includes就注入对应的模块。corejs: 3指定用 core-js 3 版本,比 2 版本更全、更规范。

targets决定了“哪些 API 需要补”。如果你只支持现代浏览器,很多 API 根本不需要补,注入量会大幅减少。比如你设targets: { chrome: '90' },那Promise、async/await都不需要补,因为 Chrome 90 原生支持。

4.3 体积对比:entry 与 usage 的差距

我在一个项目上做过对比。项目用了async/await、Promise.all、Object.entries、Array.includes这几个 API,目标浏览器是“最近两年主流浏览器”。

配置polyfill 体积首屏 JS 总体积
useBuiltIns: 'entry'210KB680KB
useBuiltIns: 'usage'28KB498KB

差了 182KB,接近 27% 的缩减。这还只是 polyfill 部分,如果算上 gzip 后的传输体积,差距也有 50KB 左右。对于移动端用户,这 50KB 可能就是 0.3 秒的加载时间。

4.4 注意事项:usage 模式的边界情况

usage模式不是万能的。它只能分析静态代码,对于动态拼接的 API 调用(比如window['Pro' + 'mise'])无能为力。另外,如果你用了第三方库,而第三方库的代码没经过 Babel 处理,它里面的 API 也不会被注入补丁。

我的做法是:主包用usage,第三方库单独处理。如果某个库明确需要 polyfill,就在入口手动import 'core-js/stable/promise'这种精确路径,而不是整个core-js。这样既保证了兼容性,又不会引入冗余。

注意:corejs: 3需要安装core-js@3依赖。如果你用的是core-js@2,配置要写corejs: 2,但 2 版本已经停止维护,建议升级到 3。

5. PWA 离线能力:断网也能打开页面

5.1 Service Worker 与 Workbox 的分工

PWA 的核心是 Service Worker,它是一个运行在浏览器后台的脚本,可以拦截网络请求、管理缓存。但手写 Service Worker 很麻烦,要处理缓存版本、更新逻辑、各种边界情况。Workbox 是 Google 出的工具库,把这些脏活累活封装好了,Webpack5 通过workbox-webpack-plugin集成。

Workbox 提供两种模式:GenerateSW和InjectManifest。GenerateSW是自动生成 Service Worker,配置简单,适合大多数场景。InjectManifest是让你自己写 Service Worker,Workbox 只负责注入预缓存清单,适合需要自定义逻辑的场景。新手建议从GenerateSW开始。

5.2 配置实操:GenerateSW 模式

// webpack.config.js const WorkboxPlugin = require('workbox-webpack-plugin'); module.exports = { plugins: [ new WorkboxPlugin.GenerateSW({ clientsClaim: true, skipWaiting: true, runtimeCaching: [ { urlPattern: /\.(?:png|jpg|jpeg|svg|gif)$/, handler: 'CacheFirst', options: { cacheName: 'images', expiration: { maxEntries: 60, maxAgeSeconds: 30 * 24 * 60 * 60, // 30 天 }, }, }, { urlPattern: /\.(?:js|css)$/, handler: 'StaleWhileRevalidate', options: { cacheName: 'static-resources', }, }, ], }), ], };

clientsClaim: true让新的 Service Worker 立即接管所有页面,skipWaiting: true让它跳过等待阶段直接激活。这两个配合使用,用户刷新一次就能用上新版本。

runtimeCaching定义了运行时缓存策略。图片用CacheFirst,因为图片不常变,优先读缓存,缓存没有再请求。JS/CSS 用StaleWhileRevalidate,先返回缓存版本,同时后台请求新版本,下次访问时用新的。这样既保证了速度,又能及时更新。

5.3 缓存策略选择:五种策略的适用场景

Workbox 提供五种缓存策略,选错了会导致要么更新不及时,要么速度慢:

策略行为适用场景
CacheFirst先读缓存,没有再请求图片、字体、不常变的静态资源
NetworkFirst先请求网络,失败读缓存API 接口、需要最新数据的场景
StaleWhileRevalidate返回缓存同时后台更新JS/CSS、HTML
NetworkOnly只走网络实时性要求极高的接口
CacheOnly只读缓存预缓存资源

我的经验是:预缓存清单(precache)用 Workbox 自动处理,运行时缓存按资源类型分。图片CacheFirst,JS/CSSStaleWhileRevalidate,APINetworkFirst。这样断网时页面能打开(静态资源有缓存),但数据可能不是最新的(API 走网络优先)。

5.4 踩坑记录:Service Worker 更新不及时

最常见的坑是:发了新版本,用户还是看到旧页面。原因是 Service Worker 的更新是异步的,新 SW 安装后要等所有旧页面关闭才激活。skipWaiting和clientsClaim能缓解,但仍有边界情况。

我的做法是在页面里监听 SW 更新,提示用户刷新:

if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/service-worker.js').then((registration) => { registration.onupdatefound = () => { const installingWorker = registration.installing; installingWorker.onstatechange = () => { if (installingWorker.state === 'installed' && navigator.serviceWorker.controller) { // 有新版本,提示用户刷新 if (confirm('有新版本可用,是否刷新?')) { window.location.reload(); } } }; }; }); }

这样用户能主动感知更新,而不是一直用旧缓存。虽然多了一次确认,但比“永远不更新”好得多。

注意:Service Worker 只在 HTTPS 或 localhost 下生效。本地开发时用http://localhost没问题,但部署到测试环境如果没配 HTTPS,SW 不会注册,PWA 功能全部失效。

6. 四个优化点的协同与优先级

6.1 优化顺序:先缓存,再预加载,后 polyfill,最后 PWA

这四个点不是孤立的,它们有依赖关系。我的建议顺序是:

  1. 先做 Network Cache:这是基础,没有长缓存,其他优化效果都打折扣。配置[contenthash]+runtimeChunk+ 服务器Cache-Control。
  2. 再做 Core-js 按需注入:减小包体积,让首屏下载更快。这一步做完,preload 的效果会更明显。
  3. 然后做 Preload:在包体积已经优化的基础上,把关键资源提前拉,进一步压缩首屏时间。
  4. 最后做 PWA:这是锦上添花,解决离线场景。如果前面三步没做好,PWA 缓存的内容也是臃肿的,意义不大。

6.2 效果叠加:一个真实项目的优化前后对比

我在一个内容型站点上完整跑了这套流程。项目有 30 个页面,首屏 JS 约 900KB(未优化)。

阶段首屏 JS 体积首次访问二次访问断网可访问
优化前900KB3.2s2.8s否
+ Network Cache900KB3.1s0.6s否
+ Core-js usage620KB2.4s0.5s否
+ Preload620KB1.9s0.5s否
+ PWA620KB1.9s0.4s是

二次访问从 2.8 秒降到 0.4 秒,首次访问从 3.2 秒降到 1.9 秒。断网时页面框架能打开,只是数据加载失败,但至少不是白屏。

6.3 常见问题速查表

问题可能原因解决方法
preload 报 warningpreload 了未使用的资源移除该 preload,或检查是否真的需要
二次访问没变快没设长缓存或文件名没哈希检查[contenthash]和Cache-Control
polyfill 体积没降useBuiltIns还是entry改成usage,检查targets是否太宽
SW 不更新skipWaiting没开或浏览器缓存开启skipWaiting+clientsClaim,加更新提示
缓存命中率低runtime 没拆分配置runtimeChunk: 'single'

6.4 我个人的实操体会

这套优化做下来,最大的感受是:运行性能优化是“隐形工程”。它不像改个 UI 那样立竿见影,用户也不会专门夸你“页面打开真快”,但一旦不做,用户就会用脚投票。我见过太多项目把精力全花在构建体积上,结果首屏还是慢,就是因为忽略了 preload、缓存、polyfill 这些运行时细节。

另外一点:不要一次性全上。我建议每做一个优化,就用 Lighthouse 或 WebPageTest 测一次,记录数据。这样你能清楚知道每个优化贡献了多少,也能及时发现某个优化反而拖慢了性能。比如 preload 配多了、缓存策略选错了,都会导致负优化。数据驱动,比凭感觉靠谱得多。

最后分享一个小技巧:如果你不确定某个资源该不该 preload,可以在 Chrome DevTools 的 Network 面板里看它的“Priority”列。如果首屏渲染前它的优先级是 Low,而它又是关键资源,那就该 preload。如果本来就是 High,那浏览器已经处理得很好,不用多此一举。

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

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

立即咨询