☰
前置资源与缓存:前端性能优化的核心组合策略
2026/10/3 4:05:22 网站建设 项目流程

做前端优化,绕不开一个字“快”。但“快”从来不是单靠压缩几个文件、删几行冗余代码就能实现的,更多时候是让资源在正确的时间出现在正确的位置,同时让已经加载过的东西不再重复加载。这就是“前置资源”(Preloading)和“缓存”(Caching)这对组合拳的威力所在。

这篇内容面向的是有几年前端经验、开始往架构师方向走的同学。日常开发中你可能已经用了preload、prefetch,也设置过Cache-Control,但很少有人系统讲清楚:前置资源和缓存在整个架构里各自扮演什么角色,边界在哪里,怎么组合才能把性能压榨到极致。本文就按我实际踩坑后的理解,把这条链路从头到尾捋一遍。

1. 前置资源:让浏览器在“空闲”时把未来需要的资源先搬回来

1.1 前置资源不是“提前加载”,而是“提前准备好”

很多人会把preload和prefetch当成优化小技巧,但架构师视角里,它们是浏览器加载流程的“时间管理”。

浏览器加载页面是有明确顺序的:解析 HTML、发现资源、下载、解析执行。默认情况下,浏览器只能在解析到<script>或<link>标签时才知道要去拿什么资源,这就导致关键资源(比如首屏要用的 JS、字体、大图)往往在 HTML 解析到一半时才开始下载,白白浪费了等待时间。

前置资源就是把这个“发现”的过程提前:通过<link rel="preload">告诉浏览器,这个资源现在就开始下载,哪怕它还没有出现在 HTML 里。举个例子:

<!-- 首屏使用的关键脚本,提前加载 --> <link rel="preload" as="script" href="/js/app.js"> <!-- 首屏渲染需要的字体,提前加载 --> <link rel="preload" as="font" type="font/woff2" href="/fonts/main.woff2" crossorigin> <!-- 下个路由要用的分包,空闲时预取 --> <link rel="prefetch" as="script" href="/js/detail.js"> <!-- 提前建立连接,减少 DNS 和 TLS 握手时间 --> <link rel="preconnect" href="https://cdn.example.com"> <link rel="dns-prefetch" href="//cdn.example.com">

这里的核心区别是优先级。preload是“当前页面必须用,立刻下载”,优先级高;prefetch是“未来可能用,等浏览器空闲再下载”,优先级低。还有一个modulepreload,专门用于 ES Module 场景,按模块依赖图加载,行为更像preload,适用于大型前端工程的按需加载链路。

1.2 架构师怎么决定哪些资源该前置?

不是所有资源都值得前置,滥用preload反而会挤占首屏带宽。我在实际项目里一般按三等分:

  • 第一等:首屏渲染关键路径上的资源。比如主 JS、首屏 CSS、首屏背景图、Web Font 的 woff2 子集。这类资源必须preload,因为它们直接影响 LCP(Largest Contentful Paint)和 FCP(First 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不前置体积大,容易挤占带宽,用运行时加载
非首屏图片不前置,加 loading=lazy降低带宽占用

实际落地时还要考虑网络环境。移动端弱网下,prefetch可能造成无意义的流量消耗,所以我会在 Release 版本里通过配置开关控制哪些路由启用prefetch,而不是全站统一开。这部分没有标准答案,架构师的价值就是根据业务模型做取舍。

2. 缓存:从“每次都下载”到“按需复用”的完整链路

2.1 HTTP 缓存的基础,值得重新梳理一遍

前置资源解决的是“第一次加载”的体验,缓存解决的是“第二次、第三次”的体验。两者缺一不可。

HTTP 缓存最简单的分法是强缓存和协商缓存。强缓存是浏览器直接使用本地副本,根本不会发请求到服务器;协商缓存是缓存在本地,但每次还是要和服务器确认一下“我这个版本还能用吗”,服务器返回 304 就继续用本地副本,返回 200 就换新版本。

平时写Cache-Control头,比较推荐的骨架是:

# 长期不变的静态资源:哈希指纹,一年强缓存 Cache-Control: public, max-age=31536000, immutable # HTML 文档:必须每次确认最新版本 Cache-Control: no-cache # API 响应:短时间缓存 + 后台更新 Cache-Control: private, max-age=60, stale-while-revalidate=600

这里面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 或 MPA,HTML 也可能被 CDN 缓存。如果 CDN 上的 HTML 也带max-age=86400,用户加载时拿到的还是旧 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-age=3600就够了;但“商品详情页的静态模板”,可能一天之后就有活动改动,需要更短的缓存;而“灰度环境里的新功能页面”,干脆不缓存或加no-store,否则测试人员永远看不到新版本。

所以架构师的价值在于:把缓存策略做成一套配置化、可动态调整的规则,而不是散落在每个 CDN 和后端代码里。我见过一个项目,所有静态资源都统一max-age=31536000,结果运营想换活动页背景图时,只能被迫改文件名。如果文件名加了语义化标识,CDN 刷新也能更精准,运营的响应速度就完全不一样。

4.2 缓存一致性和“缓存穿透”、“缓存击穿”问题

很多后端同学习惯把“缓存穿透/击穿/雪崩”挂在嘴边,前端其实也有类似的模型:

  • 缓存穿透:请求的资源在业务上不存在,本地缓存没有,CDN 也没有,每次都要回源。比如恶意请求一个随机的图片路径,会持续打爆源站。应对思路是对 404 响应做短时间缓存,同时做鉴权请求拦截。
  • 缓存击穿:某个热点资源(比如秒杀页的 CSS/JS)突然失效,一瞬间所有请求全部回源,源站被打爆。应对思路是给这个热点资源单独设置较长的max-age,或在 SW 里做“只允许一个回源请求,其他等待”的合并 fetch。
  • 缓存雪崩:大量资源在同一时刻过期,形成回源风暴。常见诱因是全站一次性发布,所有文件名都变了,浏览器/CDN 同时失效。应对思路是错开版本发布,或者对 CDN 做预热,让资源在上线前就存在缓存里。

前端架构师要意识到,你写的缓存头不只是影响一个浏览器,而是影响海量用户和设备。回源流量控制不住,运维就会找你约谈。

4.3 监控和治理:缓存好不好,得用数据说话

缓存优化的效果不能靠“感觉”。我的习惯是在核心页面埋点,统计以下指标:

指标说明优化方向
缓存命中率静态资源请求中,命中的比例提高合理资源的 max-age
资源加载耗时首屏关键资源平均加载时间是否该用 preload,CDN 是否有效
回源请求数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 前端缓存排查的“三步走”

当用户反馈“我改了代码,页面还是旧的”时,我的排查顺序永远是:

  1. 打开 DevTools Network,确认资源状态是200、304还是from cache。
    • from disk cache/from memory cache:强缓存命中,说明缓存头正常,但可能是旧版本。
    • 304:协商缓存命中,说明可能 ETag/Last-Modified 一致,但源站和 CDN 不同步。
    • 200:看响应头里的Cache-Control和age,判断是 CDN 还是源站返回的。
  2. 检查 HTML 文档是不是旧版本。因为 HTML 是入口,如果 HTML 没更新,后面资源全是旧的。
  3. 检查 Service Worker:在 Application 面板里看 Cache Storage 和 Controller 状态,必要时unregister后刷新测试。

这套流程我称之为“先浏览器、后 CDN、再 SW”。它覆盖了大多数缓存不更新的场景。

5.2 细节坑:preload 与 font、跨域请求

举一个真实例子:某个项目用preload加载字体,但字体请求总是发起两遍。后来发现preload标签漏写了crossorigin属性。字体资源走 CORS 请求,preload请求和真实字体请求的请求模式不一致,浏览器就会认为它们是两个资源,导致重复下载。所以preload字体时一定要写:

<link rel="preload" as="font" type="font/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 层还是源头代码层的问题。希望你也能把这一整套思路带到自己的项目里,真正把“快”落到每一个请求上。

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

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

立即咨询