页面卡在白屏不动,打开控制台一看,一堆资源在排队下载,接口响应挺快,但页面就是迟迟不渲染。这种问题排查到最后,几乎都会落到两个词上:资源加载和缓存机制。尤其是系统性的前端项目或大型 Web 应用,资源加载管的是“东西怎么运过来”,缓存管的是“东西到了之后怎么存、怎么复用”,这两件事配合不好,性能优化就是空谈。我在最开始做前端的时候,对缓存的理解停留在“加个 Cache-Control 就行”的层面,直到被线上事故反复教育,才把这套机制彻底搞清楚。这篇就以我自己的踩坑经验为主线,把资源加载的完整链路、浏览器 HTTP 缓存的判定逻辑、以及一套可以落地的缓存策略设计方案梳理出来,原理和实操都有,适合处于进阶期的前端开发,也适合做客户端、小程序或性能优化的同学参考。1. 先搞清楚:资源加载到底在加载什么跳出代码细节,先建立一个整体认知。一个普通页面打开,浏览器要拉取的资源远比你想象的多:HTML 文档、CSS、JavaScript、图片、字体、音视频、接口数据,甚至还有 Service Worker 脚本。每一种资源都有不同的体积、不同的加载时机、不同的更新频率,这就决定了它们不能共用一套缓存策略。1.1 资源类型决定握手方式举个例子,一个典型的电商活动页:HTML 负责骨架,体积不大,但一变化用户就该看到新版,所以不适合长缓存;打包后的 JS/CSS 往往会带上contenthash文件名,体积大、更新跟着版本走,适合长缓存;图片、字体属于静态资产,变更频次极低,可以放心用一年到十年的强缓存;接口数据实时性要求高,大多数情况下不要走本地缓存,而是交给 HTTP 协商或 SW 策略。换句话说,资源加载,本质上是对不同类型资产做分流管理。我见过不少团队将所有资源统一设置Cache-Control: no-cache,结果页面每次加载都重新拉全量资源,首屏性能直接被拖垮;也有团队把 HTML 设置了max-age86400,结果发布新版本后用户在一天之内都是旧页面,只能靠客服申诉去找人。1.2 加载机制不只是“发请求”从浏览器角度看,URL 输入到页面渲染之间有一个完整的流水线:判断缓存是否可用、DNS 解析、TCP/TLS 连接、HTTP 请求发送、响应接收、资源解析、DOM/CSSOM 构建、渲染进程绘制。前面几步的耗时往往比接口响应时间还要大一个量级,而缓存机制可以在源头上砍掉大量重复的“建立连接下载正文”的过程。理解这一点非常重要。你排查性能问题时,看到 Network 面板里某个 JS 文件下载耗时 80ms,但页面还是白屏 2 秒,问题大概率不在单文件下载,而在请求排队、连接创建或者缓存 miss 后的连锁反应。2. 打开页面时,浏览器背着我们做了哪些事这一节把整个资源加载链路拆开,让你知道“一条资源请求”背后到底走了多少步,每一步和缓存是什么关系。2.1 DNS 查询:最容易忽略的隐性耗时用户在地址栏输入域名,浏览器做的第一件事不是发 HTTP 请求,而是解析域名。正常情况下 DNS 解析会经过浏览器缓存、系统缓存、路由器缓存直到公共 DNS 服务器。虽然现代浏览器 DNS 缓存命中率很高,但首次访问或缓存过期时,一次完整 DNS 查询可能需要几十到几百毫秒。对于重依赖第三方域名(比如 CDN、埋点服务器、字体服务)的页面,这一步会被放大。常见优化是dns-prefetch:link reldns-prefetch href//static.example.com /这相当于提前告诉浏览器“这个域名马上要用,先去把 IP 查了”。实测对首次访问的提升比较明显,尤其是第三方资源多的时候。不过要注意,dns-prefetch 只解决“提前解析”,不解决“提前连接”,更激进的场景可以配合preconnect把 TCP/TLS 也一起提前。2.2 连接建立:TCP 握手、TLS 协商谁更伤HTTP 请求发出前,得先建立 TCP 连接,至少一次 SYN/SYNACK/ACK 三次握手。如果是 HTTPS,还要再加 TLS 1.2/1.3 的握手协商加密参数。一轮下来,在弱网环境下可能几百毫秒就没了。这也是为什么 HTTP/2 的多路复用和 HTTP/3 的 0-RTT 能带来明显体感提升——它们的目标都是最大限度复用已有的连接,减少连接建立次数。对于缓存机制来说,连接复用意味着即使一条请求没有命中缓存,也尽量不会触发新的握手,不至于让“缓存 miss”变成“加倍慢”。2.3 请求发送:带上“通行证”才是关键请求真正发出去的时候,浏览器已经完成了缓存查询。如果本地有缓存但不确定是否过期,请求头里会携带If-None-Match或If-Modified-Since,这就是协商缓存的“通行证”。服务器拿到这些条件字段,判断内容是否有变化,没变化就返回 304 不带 body,有变化就返回 200 带新内容。还有一个经常被忽略的点:Accept-Encoding。浏览器告诉服务器自己支持 gzip/br 压缩,服务端返回压缩后的内容。如果你在做加载优化时发现资源体积异常大,先确认是不是服务器没有开启压缩,这比纠结缓存优先级更直接。2.4 解析与渲染:拿到资源后真正的瓶颈请求返回只是第一步。HTML 下载完成后,浏览器开始边解析边构建 DOM 树,遇到 CSS 会阻塞渲染,遇到普通script会阻塞 HTML 解析。这里有两条潜规则:CSS 是渲染阻塞资源,不加载完不首次绘制;普通 JS 是解析阻塞资源,不下载执行完不继续解析其后方的 HTML。所以你会发现,Chrome DevTools 里 Performance 面板的 LCP 时间往往由 CSS 和首屏 JS 共同决定,而不是由 HTML 本身的下载时间决定。资源加载优化到后期,本质上是在优化“哪些资源先到、哪些资源后到、哪些资源干脆别挡路”的顺序问题。3. HTTP 缓存:强缓存与协商缓存的完整判定逻辑进入正题。HTTP 缓存是浏览器缓存体系里最核心、也最容易被误解的一部分。它分为强缓存和协商缓存两层,浏览器判定顺序是“先强缓存,再协商缓存,两者都没命中才走网络”。3.1 强缓存:浏览器说不问服务器就不问强缓存由响应头里的Cache-Control主导,命中后浏览器直接从本地缓存读资源,不发任何网络请求。你在 Network 面板里看到200 (from disk cache)或200 (from memory cache),就是强缓存命中了。Cache-Control常见取值:指令含义注意点max-age3600资源在 3600 秒内可直接复用相对时间,从缓存建立时刻算起s-maxage3600仅对共享缓存(如 CDN)生效优先级高于max-agepublic允许浏览器和中间代理缓存适合静态资源private只允许浏览器缓存,禁止代理缓存适合接口数据或带用户信息的内容no-cache每次使用缓存前必须向服务器验证不是“不缓存”,是“缓存但每次验证”no-store禁止任何缓存适合支付信息、敏感数据immutable在 max-age 内绝不发验证请求搭配 hash 文件名效果极好有一个坑几乎每个开发都会遇到:no-cache和max-age0很容易被误认为是不缓存。其实两者都会“每次请求都回源验证”,只是验证后如果资源没变,依然会复用缓存 body,并不会完整重新下载。真正禁用缓存的是no-store。另外Expires属于 HTTP/1.0 时代的字段,当Cache-Control存在时以Cache-Control为准。现在很多框架还在兼容输出Expires,这没问题,但你要清楚优先级。3.2 协商缓存:带上条件请求回服务器确认强缓存过期不代表资源真的变了,浏览器会发一个带条件的请求,让服务器来判定。核心是两个头:If-Modified-Since对应响应头里的Last-Modified,是文件修改时间;If-None-Match对应响应头里的ETag,是资源内容指纹。服务器收到条件请求后,如果认为资源没有变化,返回304 Not Modified,浏览器继续使用本地缓存;如果变化了,返回200 OK和最新内容。ETag和Last-Modified同时存在时,ETag 优先级更高,因为它能解决 Last-Modified 的两个痛点:时间精度只有秒级,一秒内多次修改无法区分;有些服务器返回的 Last-Modified 是打包机最后写入的时间,不是内容真实版本时间。我也踩过 ETag 的坑:多台负载均衡服务器的 ETag 生成逻辑不一致,导致用户每次请求都拿到不同的 ETag,304 永远不生效,所有静态资源全部重新下载。解决方式是在网关层统一关闭 ETag 或改成自定义哈希算法。3.3 一次请求的缓存命中时序图用文字把完整流程画一遍,假设资源已经缓存过:用户导航到页面,浏览器处理到某个 JS 文件;先查内存缓存,命中则直接读,结束;未命中则查磁盘缓存,命中且max-age未过期,直接用,状态显示200 (from disk cache),结束;磁盘缓存过期,浏览器带上条件请求头发给服务器;服务器返回 304,浏览器更新缓存新鲜度,继续用旧 body,结束;若服务器返回 200,则替换为新的 body,结束。这就是为什么缓存策略设计得好,资源加载几乎可以“零成本”完成。4. 超越 HTTP:现代应用还得用好额外两层缓存HTTP 缓存虽然强大,但存在局限:它只能按 URL 粒度缓存 GET 请求,无法实现复杂逻辑(比如“先返回旧数据再在后台更新”)。所以成熟项目一般还会引入本地存储和Service Worker。4.1 本地存储:localStorage、IndexedDB 怎么选前端本地存储最常见的两组选择是 localStorage/sessionStorage 和 IndexedDB。localStorage:适合存少量 JSON,比如用户偏好、Token、布局配置,但读写是同步的,存大对象会阻塞主线程;sessionStorage:会话级缓存,关闭标签页就没了,适合表单草稿、页面间传递临时数据;IndexedDB:异步 API,适合存大量结构化数据,比如离线数据包、大列表接口缓存、用户操作日志。接口级缓存我建议优先考虑 IndexedDB,而不是 localStorage。原因很直接:localStorage 在格式化和解析大 JSON 的时候会明显卡顿,尤其在低端安卓机上,数据一达到数 MB,同步读取可能让页面掉帧。IndexedDB 没有这个限制,而且它属于异步操作,不会阻塞渲染。4.2 Service Worker:把缓存控制权握在自己手里Service Worker 本质是一个运行在独立线程里的 JavaScript 代理,它可以拦截页面发出的所有请求,并按你定义的策略决定“走缓存还是走网络”。这让资源加载具备很强的灵活性。常见的缓存策略有四种:策略行为适用场景Cache First有缓存直接返回,没缓存走网络并写入缓存版本化静态资源Network First先走网络,失败再退回缓存接口数据、页面文档Stale While Revalidate先返回缓存,后台更新缓存列表数据、资讯内容Cache Only只读缓存,不联网离线 App Shell举个例子,一个资讯 App 的首页列表想要“秒开”,可以这样写:self.addEventListener(fetch, (event) { if (event.request.url.includes(/api/article/list)) { event.respondWith( caches.open(api-cache).then(async (cache) { const cachedResponse await cache.match(event.request); const networkPromise fetch(event.request).then((response) { if (response.ok) cache.put(event.request, response.clone()); return response; }); return cachedResponse || networkPromise; }) ); } });这里的关键点在于,cache.put之前要response.clone(),因为响应体只能被消费一次。另外,Service Worker 默认在新的 SW 安装完成后不会立即接管页面,必须调用self.skipWaiting()和clients.claim()才能强制生效,否则线上新版本可能要等所有旧页面关闭后才更新。4.3 CDN 缓存:边缘节点替你挡掉源站压力CDN 本质是部署在机房里的一层共享缓存。用户请求到达边缘节点时,如果节点有可用的缓存的副本,就直接返回;没有缓存或缓存过期,才回源站拉取。CDN 缓存和浏览器缓存的关注点不同。对于静态资源,CDN 上建议设置较长的s-maxage;对于 HTML 这类可能频繁变化的资源,可以设置较短的缓存时间,或者通过刷新 API 主动清理。关于age响应头,CDN 在命中缓存时通常会附加这个头,头部数值单位是秒,可以通过它判断“这份缓存已经存在了多久”。注意一个常见问题:源站设置了Cache-Control: private,CDN 就不会缓存这条响应。如果你想让 CDN 和浏览器分别使用不同的缓存时长,可以同时使用max-age和s-maxage,前者管浏览器,后者管 CDN。5. 实操:一套相对完备的资源缓存落地方案原理讲得再多,最终都要落到工程里。下面是我现在在新项目中比较推荐的一套落地方案,按照资源类型和更新策略区分处理。5.1 静态资源:长缓存 文件名指纹打包工具(Webpack、Vite、Rollup)都会给文件加哈希指纹。利用这个特性,可以为 JS/CSS 图片等静态资源设置一年乃至更久的长缓存:location ~* \.(js|css|png|jpg|jpeg|gif|woff2)$ { expires 365d; add_header Cache-Control public, max-age31536000, immutable; }因为文件名里带contenthash,只要文件内容变了,文件名就变了,浏览器会把它当成一个新资源重新下载;文件名没变,说明内容也没变,长缓存直接复用。这两者的配合,让静态资源的加载性能几乎达到理论上限。不过这个方案的命门是HTML 不能长缓存。如果 HTML 被缓存了 10 分钟,那么在这 10 分钟内用户拿到的 HTML 还是旧版本,里面的 JS 文件哈希引用也不会更新,新版本完全无法生效。5.2 HTML 页面:短缓存或每次验证HTML 是应用的“入口清单”,必须保持足够的实时性。我习惯的做法是:location / { add_header Cache-Control no-cache, must-revalidate; }no-cache意味着每次导航都要回源验证。服务端配合 ETag,如果 HTML 没变就返回 304,几乎不影响性能;HTML 更新了就直接返回 200 和新的文档。这一步能保证“发布即生效”,同时不会白白浪费带宽。5.3 接口数据:分层处理接口缓存没有统一答案,要看实时性需求。我的分法是这样:用户维度的敏感数据(Token、余额、订单状态):Cache-Control: no-store,同时业务层做防重放加载;实时性较强的列表/搜索:不用浏览器缓存,但可以借助状态管理做内存缓存,配合 SW 的 Network First;基础配置类接口(地区列表、字典表、活动配置):可以本地存储到 IndexedDB,设置一个合理 TTL,过期后重新拉取;需要“秒开旧内容”的场景:SW 的 Stale While Revalidate 最合适,先渲染旧数据,网络数据到达后自动更新界面。要记住一条原则:接口数据缓存最大的风险不是“旧”,而是“跨用户串数据”。凡是缓存在本地的接口数据,最好都加上用户标识作为存储 key 的一部分,否则多账号切换时很容易读到上一个用户的列表。5.4 资源加载优先级:preload、prefetch 与 script 的 async/defer缓存解决的是“第二次访问快不快”,加载优先级解决的是“第一次访问别太慢”。两者配合才完整。首屏关键 CSS/字体可以用link relpreload,让浏览器提前加载;后续路由或详情页需要的资源用link relprefetch,利用空闲带宽提前拉取;普通script会影响首屏,使用async或defer让它不阻塞解析。async和defer的区别:defer 保证按顺序执行,在 DOM 解析完成后触发;async 下载完就执行,先下载完先执行,顺序不保证。业务里有依赖关系的脚本优先选择 defer,独立统计类脚本可以用 async。6. 实战问题速查:缓存失效、缓存污染与加载瓶颈排查最后把那些坑集中列一下,这些是我和团队在真实项目里遇到过的典型状况,第一次遇到时都很懵,现在整理成速查表。现象可能原因排查方向明明设置了 Cache-Control,请求还是每次都走网络响应中多个 Cache-Control 指令冲突;no-store被误加看响应头,确认是否被某种规则叠加了所有资源都显示200并带完整 download 时间缓存命中率极低,大概率是缓存策略未生效确认磁盘缓存是否被禁用、请求是否有 cookie 导致缓存 key 不同资源显示200 (from disk cache),但页面样式还是旧版刷新操作或爬虫环境下强缓存行为与正常导航不同重新导航访问,区分“硬刷新”和“普通刷新”部分用户拿到的是旧版本,无法通过刷新解决CDN 节点缓存未失效在源站配置合理s-maxage,或主动刷新 CDN 节点缓存接口数据在 A 用户手机上出现了 B 用户的信息缓存 key 未包含用户身份检查本地缓存 key 是否按 userId 做隔离304 永远不出现,总是返回 200Nginx 多节点 ETag 生成不一致关闭 ETag 或改成自定义哈希6.1 资源加载耗时过长,怎么定位我排查的时候一般遵循“四板斧”:打开 Network 面板,按耗时排序,找到瀑布流顶部那条长请求;对照 Performance 面板的 Timing,确认耗时卡在 DNS、连接、TTFB 还是下载阶段;使用performance.getEntriesByType(resource),看每个资源的transferSize和decodedBodySize,如果两者差异明显,说明内容被压缩;如果transferSize为 0,大概率是缓存命中;Lighthouse 跑一轮,重点看 LCP 和 resource summary。顺带分享一个实用小技巧:在 Console 里统计所有资源的缓存命中率。performance.getEntriesByType(resource).filter( (r) r.decodedBodySize 0 r.transferSize 0 ).length;再用它除以总资源数,你就能快速算出一个粗略的强缓存命中率。6.2 我踩过的最“隐蔽”的缓存事故有一次线上页面出现“用户已退出,但接口返回了别人的数据”。查了半天,发现是 Service Worker 里做接口缓存时,cache.put(event.request, response)把带 Cookie 的响应也缓存了下来。后续请求虽然带了新用户的 Cookie,但浏览器在匹配缓存时直接返回了旧响应。修复方式是在缓存 key 中显式加入用户 id:const url new URL(event.request.url); url.searchParams.set(uid, getCurrentUserId());这件事给我的教训是:缓存一定要以“用户 资源地址”为单位设计,不能只按 URL 判重。7. 从事故中总结的几句话如果说我对资源加载与缓存机制有什么最深的理解,那一定是“缓存是个双向的武器”——设计得好,它是性能系统里的最高收益项;设计得不好,它会让版本更新失效、用户数据串号,甚至引发各种难以复现的线上事故。严格来说,我没有见过哪个性能优化项目可以绕开这两块内容,它们就像房屋的地基,决定了上层页面体验的上限。按我个人的经验,落地任何一套缓存体系前,先做一次资源资产盘点,把页面里的每类资源按照“体积、变更频率、实时性要求”三个维度列出来,再逐个匹配缓存策略,而不是套一个统一模板。上线前务必验证三个场景:首次访问、二次访问、新版本发布后的访问。这三关过了,缓存体系基本就是稳的。