ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

前置资源与缓存:前端性能优化的核心组合策略

前置资源与缓存:前端性能优化的核心组合策略 做前端优化绕不开一个字“快”。但“快”从来不是单靠压缩几个文件、删几行冗余代码就能实现的更多时候是让资源在正确的时间出现在正确的位置同时让已经加载过的东西不再重复加载。这就是“前置资源”Preloading和“缓存”Caching这对组合拳的威力所在。这篇内容面向的是有几年前端经验、开始往架构师方向走的同学。日常开发中你可能已经用了preload、prefetch也设置过Cache-Control但很少有人系统讲清楚前置资源和缓存在整个架构里各自扮演什么角色边界在哪里怎么组合才能把性能压榨到极致。本文就按我实际踩坑后的理解把这条链路从头到尾捋一遍。1. 前置资源让浏览器在“空闲”时把未来需要的资源先搬回来1.1 前置资源不是“提前加载”而是“提前准备好”很多人会把preload和prefetch当成优化小技巧但架构师视角里它们是浏览器加载流程的“时间管理”。浏览器加载页面是有明确顺序的解析 HTML、发现资源、下载、解析执行。默认情况下浏览器只能在解析到script或link标签时才知道要去拿什么资源这就导致关键资源比如首屏要用的 JS、字体、大图往往在 HTML 解析到一半时才开始下载白白浪费了等待时间。前置资源就是把这个“发现”的过程提前通过link relpreload告诉浏览器这个资源现在就开始下载哪怕它还没有出现在 HTML 里。举个例子!-- 首屏使用的关键脚本提前加载 -- link relpreload asscript href/js/app.js !-- 首屏渲染需要的字体提前加载 -- link relpreload asfont typefont/woff2 href/fonts/main.woff2 crossorigin !-- 下个路由要用的分包空闲时预取 -- link relprefetch asscript href/js/detail.js !-- 提前建立连接减少 DNS 和 TLS 握手时间 -- link relpreconnect hrefhttps://cdn.example.com link reldns-prefetch href//cdn.example.com这里的核心区别是优先级。preload是“当前页面必须用立刻下载”优先级高prefetch是“未来可能用等浏览器空闲再下载”优先级低。还有一个modulepreload专门用于 ES Module 场景按模块依赖图加载行为更像preload适用于大型前端工程的按需加载链路。1.2 架构师怎么决定哪些资源该前置不是所有资源都值得前置滥用preload反而会挤占首屏带宽。我在实际项目里一般按三等分第一等首屏渲染关键路径上的资源。比如主 JS、首屏 CSS、首屏背景图、Web Font 的 woff2 子集。这类资源必须preload因为它们直接影响 LCPLargest Contentful Paint和 FCPFirst Contentful Paint。第二等当前页面确定会用到但不阻塞首屏的资源。比如折叠区域内的图片、弹窗组件、埋点脚本。这类不要preload否则会跟一等资源抢带宽可以用prefetch或干脆等运行时再加载。第三等高概率用户下一步会访问的资源。比如电商详情页里“你可能喜欢”的商品图、Spa 项目里下一路由的组件分包。这类用prefetch网络空闲时下载用户点击时基本秒开。有个容易被忽略的点preload是“一次性”的同一个资源如果 HTML 里还会被正常引用只要 URL 一致浏览器会复用已下载的内容不会重复拉取。我见过一些团队从后端接口动态生成preload标签结果 URL 带上了时间戳参数导致浏览器认为这是两个资源既 preload 了又正常加载了双重下载性能反而更差。这个问题排查起来很隐蔽DevTools 的 Network 面板里如果看到同一个 JS 出现两条记录其中一条是Preload请求就要留意了。1.3 从“知道了”到“用对了”匹配业务场景才是关键前置资源最怕“一刀切”。比如新闻类站点和在线协作类站点用户行为模型完全不一样前者用户可能随机点很多链接prefetch命中率低太多反而浪费流量后者用户大概率会在几个固定 Tab 之间切换把每个 Tab 的组件做prefetch效果立竿见影。我习惯在项目里建立一个“资源分级表”让团队成员照着填资源类型策略理由首屏主 JSpreload阻塞渲染越快越好首屏 CSSpreload样式影响 FCP需要提前字体子集preload crossorigin字体文件需要 CORS 属性路由级分包prefetch用户下步可能访问大数据量 JSON不前置体积大容易挤占带宽用运行时加载非首屏图片不前置加 loadinglazy降低带宽占用实际落地时还要考虑网络环境。移动端弱网下prefetch可能造成无意义的流量消耗所以我会在 Release 版本里通过配置开关控制哪些路由启用prefetch而不是全站统一开。这部分没有标准答案架构师的价值就是根据业务模型做取舍。2. 缓存从“每次都下载”到“按需复用”的完整链路2.1 HTTP 缓存的基础值得重新梳理一遍前置资源解决的是“第一次加载”的体验缓存解决的是“第二次、第三次”的体验。两者缺一不可。HTTP 缓存最简单的分法是强缓存和协商缓存。强缓存是浏览器直接使用本地副本根本不会发请求到服务器协商缓存是缓存在本地但每次还是要和服务器确认一下“我这个版本还能用吗”服务器返回 304 就继续用本地副本返回 200 就换新版本。平时写Cache-Control头比较推荐的骨架是# 长期不变的静态资源哈希指纹一年强缓存 Cache-Control: public, max-age31536000, immutable # HTML 文档必须每次确认最新版本 Cache-Control: no-cache # API 响应短时间缓存 后台更新 Cache-Control: private, max-age60, stale-while-revalidate600这里面immutable是比较容易忽略的。它告诉浏览器这个资源带了哈希指纹只要 URL 不变内容就一定没变不需要重新验证。没有immutable的时候即使用户刷新页面浏览器也可能对超期资源发起条件请求加了immutable后刷新也不会重复请求。但前提是你的文件名必须确实带内容哈希否则会陷入“缓存永久不更新”的坑。stale-while-revalidate是我最近特别推崇的策略。它允许浏览器先用旧的缓存内容撑住页面同时后台去拿新版本等新版本到了再替换。对用户来说页面不会因为缓存过期而白屏体验上非常顺滑。但要注意这个策略只适合不要求绝对实时一致性的数据比如新闻列表、商品列表不适合库存、余额这种敏感数据。2.2 缓存分层浏览器只是最后一公里架构师视角的缓存不可能只盯浏览器。一条完整的前端资源请求通常要经过层层缓存我习惯把它分四层浏览器缓存Service Worker 和 HTTP Cache。最靠近用户命中后延迟几乎为零。CDN 缓存主要缓存静态资源和部分可缓存 API。CDN 的命中率直接决定回源流量和跨地域访问速度。服务端缓存比如 Node 层用 Redis 缓存模板片段或接口响应减少下游数据库压力。数据层缓存数据库查询缓存、内存缓存等这层主要对后端友好但前端要考虑接口返回数据的时效性。前端工程师最容易忽略的是 CDN 这一层。很多时候你改了前端代码重新发布然后发现用户打开还是旧页面。排查链路一般是先看浏览器 Network 面板里资源返回的是200 (from disk cache)还是200 (from memory cache)、还是304再看能不能从 CDN 的响应头里找到X-Cache: HIT或Miss最后才去查源站。我遇到过多次“明明刷新了 CDN 没反应”的情况最后发现是 HTML 文档被设置了很长的max-age浏览器压根没去访问 CDN。2.3 缓存更新策略让版本管理替你做决定前端缓存更新最经典的做法是“内容寻址”构建工具Webpack/Vite/Rollup给每个文件内容生成哈希文件名变成app-3f9a2b.js。只要内容不变文件名就不变可以放心设置一年强缓存只要内容变了URL 就变了用户自然会拿到新文件。这个方案下唯一需要保持短缓存的是 HTML 入口。因为 HTML 引用了带哈希的文件名浏览器每次加载 HTML 时都会拿到最新引用从而自然加载新版本资源。但要注意一个延伸问题如果你做的是 SSR 或 MPAHTML 也可能被 CDN 缓存。如果 CDN 上的 HTML 也带max-age86400用户加载时拿到的还是旧 HTML则旧 HTML 指向旧 JS 文件等于整套旧版本。所以对 HTML 文档我建议在源站设置Cache-Control: no-cache或must-revalidate。CDN 侧设置较小的缓存时间比如 60 秒同时支持主动刷新。如果页面中某个区块更新不频繁可以考虑把 HTML 切成模板片段各片段独立设置缓存。很多团队发布后忘记刷新 CDN 的 HTML 缓存于是修 bug 后用户要过几个小时才看到效果。现在一些 CI/CD 流程里会加一步“发布后自动预热 CDN 目录”本质就是为了解决这个问题。3. Service Worker 与本地存储把应用级缓存的主动权握在自己手里3.1 Service Worker 缓存策略不是“保存文件”是“控制加载逻辑”HTTP 缓存和 CDN 都依赖服务器的响应头Service Worker 则给了前端一个完全可控的中间层所有 fetch 请求都可以先经过fetch事件你可以决定是从网络返回、从缓存返回还是先用缓存停在后台更新。最常见的策略组合我总结了四类场景策略实现思路首屏核心资源Cache First命中后直接返回缓存更新时后台刷新页面导航请求Network First网络优先失败时回退缓存保证离线可用图片/静态资源Stale While Revalidate先返回缓存后台静默拉取新版本用户个性数据Network Only不走缓存保证数据一致这里的关键在于Service Worker 的注册和安装是异步的首次加载时它还没接管页面所以需要在install阶段做“预缓存”precache把首屏关键资源提前写入 Cache Storage。代码如下// sw.js const CACHE_NAME my-cache-v1; const PRECACHE_URLS [ ./, ./js/app.js, ./css/app.css, ./logo.png ]; self.addEventListener(install, (event) { event.waitUntil( caches.open(CACHE_NAME).then((cache) { // 预缓存失败不阻塞安装但最好能提前检查每个请求 return cache.addAll(PRECACHE_URLS); }).then(() self.skipWaiting()) ); }); self.addEventListener(activate, (event) { event.waitUntil( caches.keys().then((keys) Promise.all( keys.filter((key) key ! CACHE_NAME) .map((key) caches.delete(key)) ) ).then(() self.clients.claim()) ); }); self.addEventListener(fetch, (event) { const request event.request; // 只处理同源 GET 请求避免污染跨域资源 if (request.method ! GET || new URL(request.url).origin ! location.origin) { return; } event.respondWith( caches.match(request).then((cached) { // 缓存命中返回缓存 后台更新 const networkFetch fetch(request).then((response) { if (response response.status 200) { const clone response.clone(); caches.open(CACHE_NAME).then((cache) cache.put(request, clone)); } return response; }).catch(() cached); return cached || networkFetch; }) ); });这段代码不算长却解决了“离线可用”和“更新不闪断”两个核心问题。但注意策略选择很重要如果对每个请求都Cache First会导致上线后用户一直看到旧数据如果都Network First则弱网下体验下降。我会在fetch里按 URL 特征分别匹配不同策略相当于做一个小型路由表。3.2 SW 更新陷阱版本号和 skipWaiting 缺一不可Service Worker 最常见的坑是“改了半天页面始终用旧版本”。原因在于浏览器对 SW 脚本本身也是要检查更新的默认情况下用户再次访问页面时浏览器会去请求sw.js但只有当sw.js内容有变化时才会触发新 SW 安装。很多团队给sw.js加了长缓存导致浏览器永远不检查新版本。正确做法是对sw.js使用Cache-Control: no-cache并在每次发布时手动改变CACHE_NAME。同时要在新 SW 安装完成后调用self.skipWaiting()再把旧缓存删干净否则会出现新旧缓存混合加载的诡异问题。还有一个细节caches.open(CACHE_NAME)里的版本号如果只写在代码里开发者很容易忘记改。我习惯用构建流程把版本号注入进去比如在 package.json 的build脚本里用--define参数把BUILD_ID注入到sw.js模板中确保每次发布版本号必然变化。3.3 Web Storage / IndexedDB缓存数据但别滥用除了 HTTP 缓存和 Service Worker前端还有一种数据级缓存localStorage、sessionStorage和IndexedDB。它们适合存业务数据比如用户偏好、表单草稿、上次浏览记录。这里要提醒几个“坑”localStorage是同步 API读写大量数据会阻塞主线程。一次写入超过 5MB 会直接抛异常很多项目存 JSON 越来越卡就是因为没做分层。IndexedDB是异步 API适合存结构化大数据但它的事件模型比较啰嗦建议封装成库如idb-keyval再使用。隐私模式下localStorage和 IndexedDB 可能不可用代码里要有 try/catch 兜底。另外别把 token、用户个人信息等敏感数据放在容易被 XSS 拿到的localStorage里。虽然可以利用 HttpOnly Cookie 提升安全性但前端本地存储的边界要明确它应该只存“非敏感的、可重建的”缓存数据。4. 架构师视角的缓存治理从“能用”到“可控可运维”4.1 缓存策略和业务场景必须匹配不能一套通吃做前端架构的老手不会一上来就写Cache-Control头而是先问三个问题这个资源多久变一次用户对这个资源的一致性要求有多高资源过期后重新获取的成本有多大比如“首页 banner 图”可能每周换一次设置max-age3600就够了但“商品详情页的静态模板”可能一天之后就有活动改动需要更短的缓存而“灰度环境里的新功能页面”干脆不缓存或加no-store否则测试人员永远看不到新版本。所以架构师的价值在于把缓存策略做成一套配置化、可动态调整的规则而不是散落在每个 CDN 和后端代码里。我见过一个项目所有静态资源都统一max-age31536000结果运营想换活动页背景图时只能被迫改文件名。如果文件名加了语义化标识CDN 刷新也能更精准运营的响应速度就完全不一样。4.2 缓存一致性和“缓存穿透”、“缓存击穿”问题很多后端同学习惯把“缓存穿透/击穿/雪崩”挂在嘴边前端其实也有类似的模型缓存穿透请求的资源在业务上不存在本地缓存没有CDN 也没有每次都要回源。比如恶意请求一个随机的图片路径会持续打爆源站。应对思路是对 404 响应做短时间缓存同时做鉴权请求拦截。缓存击穿某个热点资源比如秒杀页的 CSS/JS突然失效一瞬间所有请求全部回源源站被打爆。应对思路是给这个热点资源单独设置较长的max-age或在 SW 里做“只允许一个回源请求其他等待”的合并 fetch。缓存雪崩大量资源在同一时刻过期形成回源风暴。常见诱因是全站一次性发布所有文件名都变了浏览器/CDN 同时失效。应对思路是错开版本发布或者对 CDN 做预热让资源在上线前就存在缓存里。前端架构师要意识到你写的缓存头不只是影响一个浏览器而是影响海量用户和设备。回源流量控制不住运维就会找你约谈。4.3 监控和治理缓存好不好得用数据说话缓存优化的效果不能靠“感觉”。我的习惯是在核心页面埋点统计以下指标指标说明优化方向缓存命中率静态资源请求中命中的比例提高合理资源的 max-age资源加载耗时首屏关键资源平均加载时间是否该用 preloadCDN 是否有效回源请求数CDN 到源站的请求比例缓存策略、预热、防穿透旧版本资源占比线上是否存在非当前版本资源的访问排查 HTML 缓存和 SW 更新问题很多团队上线后只看 PV 和首屏时间却不知道 30% 的静态资源请求根本没有命中缓存。这不是能力问题是缺少监控意识。生产环境里用 Performance API 手动记录资源加载配合navigator.storage.estimate()查看 SW 缓存占用都能快速定位问题。4.4 架构协同CDN、发布系统与缓存刷新前端资源和缓存不是孤立的它和发布系统强相关。发布一个新的 JS 版本不只是把文件传到 CDN 那么简单还要考虑旧版本资源是否还在被用户访问如果立即从 CDN 删除反而会导致部分用户加载失败更稳妥的是保留一段时间的旧版本资源称为“版本宽限期”。CDN 刷新是整目录刷新还是单 URL 刷新整目录刷新会清掉命中率高峰建议单 URL 或正则刷新。灰度发布时如何让灰度用户与普通用户使用不同版本的缓存可以在 HTML 里注入不同的资源域名或通过 Cookie/Header 区分。需要注意CDN 和浏览器缓存都可能让灰度规则失效。我最近参与的项目里团队做了一套简单的“版本资源服务”每次构建后生成一个asset-manifest.json把资源文件名和版本号映射关系存在配置中心。发布时自动根据版本号刷新 CDN 目录并预热水资源同时通过Cache-Control头控制用户更新节奏。这套思路不复杂但架构上讲清了“文件版本生命周期”和“缓存生命周期”的关系线上问题少了很多。5. 常见问题速查与排查实录5.1 前端缓存排查的“三步走”当用户反馈“我改了代码页面还是旧的”时我的排查顺序永远是打开 DevTools Network确认资源状态是200、304还是from cache。from disk cache/from memory cache强缓存命中说明缓存头正常但可能是旧版本。304协商缓存命中说明可能 ETag/Last-Modified 一致但源站和 CDN 不同步。200看响应头里的Cache-Control和age判断是 CDN 还是源站返回的。检查 HTML 文档是不是旧版本。因为 HTML 是入口如果 HTML 没更新后面资源全是旧的。检查 Service Worker在 Application 面板里看 Cache Storage 和 Controller 状态必要时unregister后刷新测试。这套流程我称之为“先浏览器、后 CDN、再 SW”。它覆盖了大多数缓存不更新的场景。5.2 细节坑preload 与 font、跨域请求举一个真实例子某个项目用preload加载字体但字体请求总是发起两遍。后来发现preload标签漏写了crossorigin属性。字体资源走 CORS 请求preload请求和真实字体请求的请求模式不一致浏览器就会认为它们是两个资源导致重复下载。所以preload字体时一定要写link relpreload asfont typefont/woff2 href/font.woff2 crossorigin还有一类问题是 SW 缓存了 POST 请求的响应导致表单提交结果永远不变。因此在 SW 的fetch事件里必须过滤掉非 GET 请求尤其是涉及用户数据的请求更应该Network Only。这不是技术难的问题是架构边界没划清楚。5.3 静态资源体积与缓存容量的平衡很多人以为缓存就是“越多越好”其实浏览器的存储空间有限尤其是 Safari 和隐私模式。如果 Service Worker 缓存了海量图片且没有清理策略用户就可能遇到QuotaExceededError。我会在 SW 里定期清理超过一定数量和体积的旧缓存比如async function cleanCache(keepNum 80) { const keys await caches.keys(); for (let key of keys) { const cache await caches.open(key); const requests await cache.keys(); if (requests.length keepNum) { // 删除最老的请求 await Promise.all(requests.slice(0, requests.length - keepNum) .map((request) cache.delete(request))); } } }这个函数每次activate或定时触发防止缓存无限膨胀。很多中小团队没有这一步结果线上用户手机越用越卡。前端缓存不是“存起来不管”而是要有生命周期的管理意识。5.4 我的排查工具箱最后分享一套我常用的排查组合都是浏览器自带的能力不依赖额外工具DevTools Network 面板看资源加载瀑布图、状态码、响应头。DevTools Application 面板看 Cache Storage、IndexedDB、Service Worker 状态。DevTools Performance 面板分析哪些资源延迟了首屏哪些请求阻塞了渲染。curl 命令直接从终端查看 CDN 返回头确认s-maxage和x-cache是否生效。Lighthouse / Performance Insights快速生成性能报告定位 LCP 和 FCP 被什么拖慢。这些工具都不复杂但很多人遇到问题第一反应是“清缓存”、“强制刷新”而不是分析现象背后的缓存层级。真正想进阶到架构师要培养的正是这种“分而治之、层层判断”的能力。我个人在实际操作中的体会是前置资源和缓存从来不是一次性配置就完事而是要跟着业务迭代持续调整。每上线一个功能我都会回看一下资源分级表确认新增的资源是应该preload还是prefetch缓存时长定多少CDN 是否需要预热。这些看起来都是小决策但积累到千万级流量下差距就是眨眼之间和卡顿半天的区别。最后再分享一个小技巧处理缓存问题时可以先在浏览器禁用缓存勾选 Network 面板的Disable cache确认源站响应是否正常然后再正常刷新对比两次响应的差异。很多“清不清缓存都一个样”的怪问题用这个对比法几秒钟就能锁定是浏览器层、CDN 层还是源头代码层的问题。希望你也能把这一整套思路带到自己的项目里真正把“快”落到每一个请求上。
返回列表