做了这么多年Web性能优化,我越来越觉得“资源加载与缓存机制”才是整个前端性能体系的真正命门。很多朋友把性能优化等同于压缩图片、开启Gzip,但真正影响首屏体验的,往往是浏览器怎么“取”资源、怎么“存”资源、怎么“判断”资源该不该重新下载这一整套链路。这一篇我把这套机制从头到尾捋一遍,包括我自己踩过的坑、排查过的线上事故,内容主要针对Web开发场景,前端工程师、后端工程师和做性能治理的朋友都适合读。
1. 资源加载:浏览器“取货”的全流程
先想一个问题:用户在地铁里打开你的H5页面,从按下手指到页面首屏出现,这一两百毫秒里,浏览器到底干了多少活?很多人只知道“发了请求、拿到HTML、解析渲染”,但实际上,每一次资源加载都像一个完整的供应链,环节远比想象中多。
1.1 从URL输入到首字节,中间发生了什么
把“输入URL到页面可交互”这一整段拆开看,资源加载其实可以分为五个大阶段。
第一个阶段是DNS解析。浏览器不知道www.example.com在哪台服务器上,必须先向DNS服务器问路,拿到对应的IP地址。这个环节平时看着快,但公共DNS被污染、本地DNS缓存失效、用了非常规端口的时候,耗时可能飙到几百毫秒甚至超时。
第二个阶段是TCP连接。拿到IP之后,浏览器要和服务器建立TCP连接,通常需要三次握手。别小看这三次握手——每一轮都要跨越大半个互联网,RTT(往返时延)在移动网络下常常要50到100毫秒。如果页面使用了十几个域名,光建立连接就是一笔不小的开销。
第三个阶段是发送HTTP请求与等待响应。浏览器把请求头发出去,服务器处理并返回响应。这个阶段除了网络传输本身,还会被服务器的处理能力、反向代理的转发效率、数据库查询时间影响。用户感知到的“白屏时间”,很大一部分就花在这里。
第四个阶段是解析HTML并加载子资源。HTML到手之后,浏览器开始逐行解析,遇到<link>、<script>、<img>这类标签,就会继续发起新的资源请求。注意,这可不是解析完HTML才开始,而是边解析边加载,所以页面里子资源的数量和顺序,直接影响加载完成的时间。
第五个阶段是渲染与合成。CSS和JavaScript都到位之后,浏览器构建DOM树、CSSOM树,合并成渲染树,最后绘制像素到屏幕上。
这五个阶段环环相扣,任何一个环节出问题,用户感知到的都是“卡”和“慢”。但有意思的是,绝大多数性能问题都不在第五阶段,而出在前四个阶段——尤其是重复的资源请求。
1.2 为什么说“加载”是性能的关键路径
我早年做性能优化时也犯过“过度关注渲染”的毛病,把大量精力花在优化CSS选择器、减少重排重绘上,结果首屏速度提升极其有限。后来用Performance面板一测才发现,真正拖后腿的是一堆重复加载的资源——同一个用户在一个会话里,反复请求同一张图片、同一个脚本,每次都完整走一遍DNS、TCP、HTTP,白白浪费了一半以上的加载时间。
也就是说,资源加载优化的本质就两件事:让该加载的尽快加载,让不该重复加载的完全不加载。前者靠并发、预加载、优先级调度,后者靠缓存机制。理解了这一点,再去学什么Cache-Control、ETag、Service Worker,就知道每一样工具到底在解决哪个环节的问题了。
2. 缓存机制的地基:强缓存与协商缓存
聊到缓存,很多人第一反应是“浏览器缓存”,但严格来说,HTTP缓存机制分两层——强缓存和协商缓存。这两者执行时机不同、判断逻辑不同,搞混了就会出现“明明改了代码,用户还是旧页面”的经典事故。
2.1 强缓存:浏览器说了算,根本不问服务器
强缓存的逻辑是:浏览器直接根据响应头里的Cache-Control或Expires判断资源有没有过期,如果没过期,直接用自己的缓存副本,连请求都不发。
我在项目里最常用的就是Cache-Control的max-age指令。比如给一张图片设置Cache-Control: max-age=31536000,意思是从第一次拿到这个资源起的一年内,浏览器都不会再向服务器发请求。这个机制对静态资源简直是救命稻草——同一张Logo图,用户一周访问你十次,理论上只需要下载一次。
Expires是Cache-Control出现之前的旧方案,指定一个绝对过期时间,比如Expires: Fri, 21 Dec 2026 15:00:00 GMT。但它有个硬伤:服务器时间和用户本地时间不一致就会出错。所以现在主流方案是Cache-Control优先,Expires只作为兼容降级。
这里有一个我反复跟团队强调的坑:强缓存一旦生效,完全没有请求到服务器。如果你给某个接口设了max-age=3600,那这一个小时里,用户那边不管数据怎么变,拿到的都是旧的。动态接口不要随便加强缓存,否则排查问题的时候你会被“明明改了代码不生效”坑到怀疑人生。
2.2 协商缓存:带着凭证去问服务器
强缓存的时间过期了怎么办?这时候进入协商缓存流程。浏览器带上缓存的“凭证”去问服务器:这个资源我手头有一个旧版本,变没变?没变就返回304,我继续用旧的;变了就返回200和新内容。
协商缓存的核心是两对头信息。一对是基于时间的:请求头里的If-Modified-Since对应响应头里的Last-Modified,服务器看文件最后修改时间判断变没变;另一对是基于内容的:请求头里的If-None-Match对应响应头里的ETag,服务器根据文件内容算出一个唯一标识,内容变了标识就变。
我强烈建议所有静态资源优先用ETag而不是Last-Modified,因为时间戳判断有两个致命问题:一是有些服务器返回的时间精度只到秒,同一秒内改了文件判断不出来;二是文件内容没变但时间变了,会引发无谓的重新下载。ETag是对内容做哈希,准确度高得多。
打个比方,强缓存就像你办了张年卡,一年内进游乐园直接刷脸,闸机都不带响的;协商缓存就像每次进园都要给保安看一眼你的门票上的防伪码,保安拿机器扫一下,真的就直接放行,假的他才让你重新买票。一个是“完全信任”,一个是“验证信任”。
2.3 三级缓存位置:从内存到硬盘再到网络
除了强缓存和协商缓存,浏览器本身还维护着多个层次的内容存储。我在DevTools的Network面板里经常看到资源来源标注为memory cache或disk cache,很多人不知道这俩有什么区别。
memory cache就是内存缓存,读取速度最快,几乎不消耗磁盘IO,但它的生命周期很短,标签页关了、浏览器进程重启了,内存缓存就没了。通常JS、CSS这类解析过的资源更容易进内存缓存,因为浏览器觉得它们随时可能被再次用到。disk cache是硬盘缓存,容量大、持久性强,但读取速度比内存慢一个数量级。
值得注意的是,缓存的存放位置优先顺序大致是这样的:先看内存缓存,再看硬盘缓存,最后才是走网络请求。所以你在Network面板看到的资源,可能40%以上都被这两个层级拦截了。这也就是为什么我常跟测试同学说,验证一个页面性能,一定要开隐身窗口或者勾选Disable cache,否则测的全程都是缓存命中,数值根本没参考价值。
3. 缓存不是万能药:什么该缓存、什么不该缓存
我见过太多团队,上线上出事故之后二话不说把Cache-Control全删了,结果性能反而更差。缓存策略不是一个“开了就完事”的开关,而是一套需要按资源类型精细化设计的规则。
3.1 静态资源怎么配才安全
静态资源——JS、CSS、图片、字体——是缓存收益最大的对象,但前提是文件名必须带指纹。我说的指纹,就是构建工具生成的那串哈希,比如app.a3f9d2.js。这类资源的特点是内容变了,文件名就变;文件名不变,内容一定没变。
对这类资源,我的标准配置是Cache-Control: max-age=31536000, immutable。immutable是告诉浏览器这个资源终生不变,连协商缓存都不用发。为什么敢这么配?因为文件名带指纹之后,内容一旦更新,构建产物会生成新的文件名,用户请求的是新URL,旧URL的缓存永远不会被访问到。这样既保证了极致缓存命中,又不会出现“代码改了不生效”。
这里有个团队新人常犯的错误:发版之后发现用户还是旧资源,就以为是CDN没刷新,其实是因为忘了改文件名或者忘了处理HTML里的引用。文件还是那个文件,缓存当然还在。
3.2 HTML文件:反其道而行之
和静态资源的“长期缓存”相反,HTML文件我建议采用Cache-Control: no-cache。注意,这不是“不缓存”,而是“每次使用前先验证一下”。也就是说,浏览器每次都会发起协商缓存请求,但绝大多数情况下服务器返回304,耗一次网络往返,保证拿到的是最新HTML,又不至于重新下载整个页面。
为什么HTML要这么特殊?因为HTML是整个应用的“入口清单”,里面引用了所有JS、CSS的URL。如果HTML被强缓存了,用户拿着旧HTML,就永远引不到新版本的JS和CSS,哪怕你的静态资源已经更新到天荒地老。这个搭配方案是我在实践中验证过无数次的组合:
- HTML:
no-cache,保证入口文件始终新鲜。 - 带指纹的JS/CSS:
max-age=31536000, immutable,享受永久缓存。 - 图片等媒体资源:
max-age=31536000,不带immutable也行,反正几乎没有变更场景。
3.3 动态接口:缓存的灰色地带
动态接口能不能缓存?能,但要分场景。如果是用户维度的个性化数据——比如购物车列表、订单状态——我绝不建议加HTTP缓存,因为每个人的内容都不一样,任何一个用户的数据变了,其他用户的缓存也没法精准失效。这种情况下,更合适的是用前端业务层缓存:比如React Query、Vue Query这类数据请求库,可以设置staleTime,在一段时间内复用同一份数据,避免反复请求,同时又能手动失效。
如果是非个性化、全用户一致的数据,比如配置项、版本号、公告内容,那就可以大胆设置Cache-Control: max-age=60甚至更久。实际项目中我还会配合CDN层的缓存策略,在源站和边缘节点之间做分级缓存,这样即便源站压力再大,CDN边缘节点也能直接兜住大部分请求。
4. 实操中的配置与验证方法
缓存机制的原理并不难,真正难的是把配置落到服务器、CDN和前端代码的各个角落。这一节我分享一套可以直接复用的配置方案和验证流程。
4.1 Nginx与CDN层的缓存配置模板
如果你用的是Nginx作为Web服务器,静态资源的缓存配置可以参考我这里的一套写法:
location /static/ { expires 1y; add_header Cache-Control "public, max-age=31536000, immutable"; access_log off; } location / { add_header Cache-Control "no-cache"; expires -1; try_files $uri $uri/ /index.html; }第一段把所有/static/路径下的资源设置为一年强缓存加immutable;第二段给HTML设置no-cache,保证每次都要协商验证。这套配置我已经在多条业务线里跑过,没有再出现“发版用户看不到新功能”的投诉。
如果你用了CDN,还需要注意CDN节点本身也有缓存。源站的Cache-Control默认会被CDN透传,但很多CDN厂商支持在控制台单独配置节点缓存策略。我一般会做两层:源站给HTML设no-cache,但CDN层对HTML节点设一个很短的缓存(比如60秒),这样既保证用户拿到新鲜内容,又能减轻源站带宽压力。这个60秒的窗口会带来最多一分钟的发布延迟,业务可接受,但性能提升非常明显。
4.2 用DevTools精确判断命中情况
配置写完之后,开发阶段我建议每一位前端同学都养成打开DevTools看Network面板的习惯,重点看两列:Size和Time。
当资源显示为(from memory cache)或(from disk cache)时,说明命中了强缓存,没有任何网络请求,Size列会直接标出来,Time几乎为0。当资源显示304 Not Modified时,说明走了协商缓存,服务器确认资源未变化,传输体积很小,但仍有一次网络往返。这两者的区别非常大,我之前带团队的时候,要求大家只要看到第二次刷新页面还有大量200请求,就要立刻排查缓存配置是不是被谁给改了。
另外,DevTools的Application面板里可以直接看到每一类存储的占用情况,包括Cache Storage(Service Worker用的缓存)和本地缓存的明细。排查问题的时候,可以直接在面板里找到对应的缓存条目,右键删除,避免整个站点缓存被清空影响其他同事的联调。
4.3 Service Worker:把缓存主动权握在自己手中
HTTP缓存能做到这个程度,按理说已经很好了,但它有个天然局限:控制权在浏览器和服务器手里,前端代码做不了精细的拦截。这时候就需要Service Worker登场了。
Service Worker本质上是浏览器和网络之间的一个“代理脚本”,它可以在fetch事件里完全接管资源请求逻辑。我在做PWA和弱网优化时,会在Service Worker里实现“先缓存后网络”的策略:
self.addEventListener('fetch', (event) => { event.respondWith( caches.match(event.request).then((cached) => { if (cached) { return cached; } return fetch(event.request).then((response) => { const clone = response.clone(); caches.open('my-cache-v1').then((cache) => { cache.put(event.request, clone); }); return response; }); }) ); });这个例子虽然简单,但非常实用。弱网环境下,命中缓存的资源几乎秒开,不依赖服务器响应。需要注意的是,Service Worker缓存的版本管理很容易被忽略,上线新版本时一定要先caches.delete旧缓存,否则你的SW会把旧资源一直喂给用户,那可比HTTP缓存事故难排查多了。
5. 加载性能的进阶玩法:预加载与优先级调度
缓存策略解决的是“重复加载”的问题,而“首次加载”的快慢,还需要另外一套手段来优化。这里我分享几个在项目中实测有效的进阶技巧。
5.1 提前告诉浏览器:预加载与预连接
预加载的核心思想是把决策提前。正常情况下,浏览器要解析到<link>或<script>标签才知道去加载哪些资源,这是串行且被动的。使用<link rel="preload">可以主动告诉浏览器:这个资源很重要,现在就下载,别等我解析到。
<link rel="preload" href="/assets/home-hero.woff2" as="font" type="font/woff2" crossorigin>比如首页有个全屏Banner图,而且用到了自定义字体,正常流程要等CSS解析完才知道有字体要加载,但通过preload,字体请求可以提前几百毫秒发出,首屏文字渲染就不容易出现FOUT(无样式文字闪烁)。
preconnect则是提前建立连接。它告诉浏览器:这个域名你马上就可能要发请求,先把DNS、TCP、TLS握手做完。对于跨域CDN资源非常有效。
5.2 动态资源的懒加载与on-demand加载
跟“提前”相反的思路是“延后”。对于首屏不可见的内容——比如长列表下方的图片、折叠区里的组件,我建议全部走懒加载。
现代浏览器原生支持<img loading="lazy">,这个API一开,浏览器会自动判断图片是否进入视口,再决定是否下载。但它的坑在于,首屏低于视口高度一半的图片可能会被提前加载,精度不如JavaScript方案高。如果追求精准控制,可以继续用IntersectionObserver:
const observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { const img = entry.target; img.src = img.dataset.src; observer.unobserve(img); } }); }); observer.observe(lazyImage);代码本身没什么高深的,但工程上要注意:懒加载不能用在首屏关键图上,否则首屏图片会晚一个帧周期才会出现,反而影响LCP指标。
5.3 合理的并发策略
浏览器对同一域名的并发连接数是有限制的,HTTP/1.1下一般是6个。如果你的页面几十个资源全部堆在同一个域名下,后面的只能排队等着。这就是为什么大厂普遍采用CDN域名和主站域名分离——把静态资源放在static.example.com,接口请求放在api.example.com,等于把排队通道翻了一倍。
但HTTP/2普及之后,多域名反而可能有性能损耗,因为连接的多路复用被域名拆散了。现在的最佳实践是单域名 + HTTP/2,配合资源合并。我在新项目里已经全面切到了这种模式,连接数不再是瓶颈,真正影响时间的是每个请求本身的传输与解析。
6. 常见问题与排查经验实录
最后分享几个我在真实项目里碰到过的问题和排查过程。缓存机制的事故有个共同特点:不是立即爆发,而是悄悄累积,等发现的时候已经有很多用户中招了。
6.1 改完代码用户看不到更新
这个问题出现频率最高。排查流程我基本固定成三板斧:
第一步,用DevTools的Network面板看HTML请求的响应头里Cache-Control是什么。如果是max-age且数值很大,那就说明HTML被强缓存了,直接去改Nginx或服务端配置。
第二步,看JS/CSS文件是不是带指纹。没带指纹的话,即使Cache-Control配置没问题,发版后文件名不变,旧缓存一样生效。解决方式是给构建配置加上contenthash。
第三步,检查Service Worker。现在很多项目都注册了SW,旧的SW里缓存了旧版HTML,即使服务端更新了,SW也会优先返回自己的缓存。在Application面板里看Service Workers标签,点击Unregister测试一下,通常就能定位。
6.2 请求都200,但Size显示disk cache
有朋友问过:为什么Network里都是200状态码,但资源还是走了缓存?这是因为浏览器在某种情况下会拉长max-age的判定。比如用户点击前进后退按钮、页面被bfcache(往返缓存)恢复时,浏览器可能直接用缓存而不发请求,状态码就是200但来源标记为缓存。
这种情况不是事故,不需要紧张。如果你的页面从列表页跳到详情页再返回列表页时,列表数据是旧的,那说明数据级别的缓存策略有问题,应该考虑用pageshow事件重新拉取关键接口。
6.3 304请求太多,拖慢整体加载
协商缓存虽然比全量下载好,但304也是要发一次请求的,如果页面有100个静态资源都走协商缓存,那同样会产生100个请求,只是响应体变小了而已。我要指出,强缓存才是零请求,协商缓存只是“轻请求”。
要减少304的数量,唯一有效的方案就是:给所有静态资源文件名加指纹,并设置足够长的max-age,让浏览器完全进入强缓存状态。不要心疼那点CDN流量,缓存命中率提升之后,源站带宽成本下降会远比这多。
写到这里,我把资源加载的链路和缓存机制的落地方案、排查方法都过了一遍。我个人体会最深的一点是:缓存机制不是“配完就一劳永逸”的东西,它需要根据业务形态、发布频率、用户使用场景持续调整。比如我们曾经为了追求极致的强缓存,给所有API都加了很长的max-age,结果运营改个活动配置,用户端整整两天看不到变化,最后灰溜溜地又把缓存时间缩短。缓存的设计本质上是在“性能”和“新鲜度”之间做权衡,没有银弹,只有理解了原理之后,才能针对每一种资源做出最合理的决策。这套方法我用了很多年,也带着团队踩了不少坑才总结出来,希望你看完之后能少走几步弯路。