尧图精选

浏览器资源加载与缓存机制全解:从强缓存到CDN策略

🕒 发布时间:2026/11/1 0:54:07 📁 来源:尧图网络
几年前我在整理一套前端进阶系列的时候02-05正好是原理篇里的资源加载与缓存机制。当时有朋友说这类内容网上到处都是没什么可讲的。结果没过多久他就跑来问我为什么我改了CSS用户那边死活不更新为什么Network面板里全是304性能报告却说缓存命中率低其实这些问题的答案全都在资源加载与缓存机制这两个词背后。这篇就是那一节的扩充版适合刚接触性能优化的前端开发者也适合后端同学了解一下浏览器和CDN到底是怎么帮你省流量的。我会从浏览器加载一条资源的完整链路讲起把强缓存、协商缓存、内存缓存、磁盘缓存这些概念串成一条线再落到工程里的缓存策略配置和几类高频故障的排查方法上。核心目标很明确让资源加载和缓存机制从两个抽象术语变成你看得见、可实验、能调试的具体知识。1. 资源加载一条请求的前半生1.1 资源加载到底在加载什么前端性能优化说来说去最终面对的都是资源。我习惯把资源分成几类入口文档HTML、样式CSS、脚本JS、图片png/jpg/webp/avif这些、字体woff2/ttf、音视频再加上接口返回的数据。浏览器加载资源的过程并不是一次性把所有东西全拉下来而是边解析边加载。HTML就像一张购物清单浏览器先拿到清单然后对着清单去采购其他资源。拿到CSS之后会构建CSSOM拿到JS后会执行JS逻辑中间图片字体这些资源则是在解析到对应标签时触发请求。这里有个初学者最容易忽略的点资源加载的顺序会受到阻塞机制影响而这个顺序直接影响首屏速度。CSS拿到后要先生成CSSOM没有CSSOM浏览器不知道该用什么样式绘制页面所以CSS默认会阻塞渲染。普通JS脚本更直接解析到script标签时浏览器会暂停DOM解析等脚本下载并执行完才继续往下走这就是解析器阻塞。带async的JS下载不阻塞但下载完立即执行执行时可能阻塞带defer的JS下载也不阻塞但要等DOM解析完成后才按顺序执行。我用一个日常类比帮人理解这事解析HTML好比装修队进场水电工CSS没把线路插好油漆工渲染就没法开工而一个不设防的JS脚本相当于有个工人进场把走廊堵死了其他工种全得等他干完。一个页面到底加载多少资源、每类资源怎么调度最终都会反映成用户感知的白屏时间、可交互时间这些指标。1.2 加载链路中的关键节点资源从URL到呈现在页面上大致要过下面这些节点先查浏览器缓存内存缓存和磁盘缓存命中就直接用不发起网络请求如果没有缓存才进入DNS解析环节把域名解析成IP浏览器自己有DNS缓存但没有了就要走一次系统解析然后建立连接HTTP/1.1需要TCP握手HTTPS还要加TLS握手HTTP/2和HTTP/3在连接效率上做了进一步优化接着发起请求请求头里和缓存最相关的就是Cache-Control、If-Modified-Since、If-None-Match服务器处理后返回响应头和响应体最后浏览器按规则决定是否把资源写入缓存、如何渲染。这条链路里的每个节点都对应着优化手段。DNS环节可以加link reldns-prefetch href//static.example.com提前解析连接环节有HTTP/2多路复用响应环节有压缩和缓存策略渲染环节可以用preload和prefetch提前通知浏览器哪些资源重要。preload和prefetch算是资源加载里戏份最多、也最容易用错的两个工具。preload是告诉浏览器这个资源当前页面马上就要用请尽快下载但别阻塞渲染。典型场景是首屏字体、首屏大图或者某些需要被JS用来动态插入的关键CSS。写法上要注意as和type必须写对比如link relpreload href/font.woff2 asfont typefont/woff2 crossorigin否则资源可能下载了却用不上。prefetch则是告诉浏览器用户下一个页面可能会用到这个资源等浏览器空闲了你再去拿所以它适合用来准备路由懒加载的JS或者下一屏的图片。这里有个我踩过的坑preload滥用。如果对一个其实没那么重要的资源加了preload它会抢占主流程的带宽反而拖慢真正的关键资源。我现在的原则很朴素preload只给最重要的1到3个资源用其余资环保持默认加载顺序。1.3 关键渲染路径与资源加载的关系很多人把资源加载和渲染分开理解实际上两者紧扣。关键渲染路径大致是解析HTML生成DOM树解析CSS生成CSSOM合并成渲染树计算布局绘制最后合成。资源加载在其中的作用非常具体DOM树构建过程中每遇到外链资源就发起请求CSSOM必须在渲染树之前准备好所以CSS加载会直接延后首次渲染首屏如果全是同步JS浏览器会一直卡在等JS这个环节白屏时间被无限拉长图片和字体这类资源不阻塞DOM解析但会影响布局和绘制比如图片没有宽高时加载完成后会导致页面布局跳动也就是常说的CLS问题。对资源加载的理解如果停留在发出请求拿到文件就会漏掉一个关键事实加载策略基本是在每一步优化渲染质量。把CSS拆成首屏内联加其余异步是最古老的优化手段到今天依然有效因为它砍掉的是CSSOM构建前的等待时间。2. 缓存机制让请求次数大幅下降的关键2.1 强缓存浏览器自己说了算当浏览器第一次请求一个资源时服务器可以在响应头里告诉浏览器这个资源你多久之内不要再来问我。这个头就是Cache-Control常用的是max-age。举个例子Cache-Control: max-age3600表示一小时内浏览器可以直接用本地缓存完全不发请求。你在DevTools的Network里会看到请求状态是200 (from disk cache)或者200 (from memory cache)前者说明用的是磁盘缓存后者是内存缓存。不管哪种浏览器都没有真正发出网络请求这就是强缓存命中。HTTP/1.0时代用的是Expires一个绝对过期时间比如Expires: Wed, 21 Oct 2024 07:28:00 GMT。但它有个硬伤用户本机时间和服务器时间不一致时缓存判断就会出错。所以现在主流是HTTP/1.1的Cache-Control优先级高于Expires两者同时出现时浏览器以Cache-Control为准。Cache-Control里还有几个指令很多人经常搞混。no-cache并不是不缓存它的意思是可以用缓存但每次用之前必须去服务器确认一下有没有更新也就是说它强制走协商缓存。no-store才是真正禁止缓存请求和响应都不允许存到任何地方。public表示中间代理、CDN也可以缓存private表示只能浏览器缓存中间代理不能缓存适合带用户私密信息的响应。s-maxage专门给CDN和共享缓存用优先级高于max-age。immutable则告诉浏览器这个资源内容永不变可以直接用不要再带着条件头去问服务器。强缓存和协商缓存的区别我常用一个日常类比强缓存是你图书馆的一次性借书证借到的书放家里下次直接用不问图书馆还有没有新版协商缓存则是你每次看完都回去问管理员这本书更新了吗管理员说没有你就继续用旧书如果更新了就给你换新书。区别在于问这个动作省不了但能省掉把整本书搬回来传输的流量。2.2 协商缓存让服务器来做裁判当强缓存过了有效期或者服务器返回了no-cache浏览器就必须带着凭证去服务器确认资源是否变化这个确认请求就是协商。目前有两对组合第一对是Last-Modified和If-Modified-Since第一次响应时服务器返回资源最后修改时间之后请求带上If-Modified-Since服务器比对时间第二对是ETag和If-None-Match服务器为资源生成一个唯一标识一般基于内容算哈希之后请求带上If-None-Match服务器比对标识。资源没变化服务器返回304响应体为空浏览器继续用本地缓存资源变化了服务器返回200和完整的新资源。为什么有了Last-Modified还要ETag三个原因。第一Last-Modified精度是秒级同一秒内文件被修改多次它感知不到。第二有些服务器的Last-Modified只是部署时间文件内容没变但部署时间变了导致没必要的重新下载。第三分布式部署时不同机器对同一文件的修改时间可能不一致容易产生判断偏差。ETag就是更严谨的内容指纹缺点是服务器需要额外计算但对静态文件来说这个开销可以忽略。实践里我会让服务器两个头都返回浏览器自己会按规则优先使用ETag。这里有个我经常跟同事强调的观点304的本质是省流量不是省请求。304依然是一个完整的HTTP往返只是返回体很小。所以如果你的性能指标是请求数量那304请求也会被算进去真正想减少请求数量要依靠强缓存而不是协商缓存。2.3 内存缓存与磁盘缓存强缓存命中后资源存在哪儿又分了两层。内存缓存速度最快但它活在浏览器进程里标签页一关基本就没了生命周期非常短。一般HTML、较小的脚本、当前会话内重复用到的资源比较容易进内存缓存。你在Network面板看到的就是(from memory cache)。磁盘缓存是持久化的即使关闭浏览器只要没有清理数据下次启动还能命中。大一点的图片、JS、CSS更倾向于进磁盘缓存Network面板里对应(from disk cache)。浏览器到底把哪些资源放内存、哪些放磁盘没有公开的硬性规则。我经验里的规律是内存缓存更适合短时间内反复访问的资源磁盘缓存适合跨会话的长期资源。查缓存顺序通常是内存优先内存没命中再查磁盘两个都没有才真正走网络。2.4 容易被忽略的启发式缓存还有一个冷门但很关键的知识点启发式缓存。如果服务器响应里只有Last-Modified没有Cache-Control也没有Expires浏览器不会干脆不缓存而是会根据经验自己猜一个缓存时间。RFC文档里建议的计算方式是取当前时间和Last-Modified之间的差值的10%作为缓存时长。翻译成人话如果这文件好像很久没改了浏览器就会觉得那我也多缓存一阵也没事。很多线上诡异问题比如明明没设置缓存为什么改了不生效根源往往是启发式缓存。所以我的习惯是凡是涉及HTML入口这种必须实时更新的资源主动设置Cache-Control: no-cache或者max-age0明确表达意图不让浏览器自行发挥。3. 工程中的缓存策略设计从原理到落地3.1 指纹化命名解决改了不生效的根本问题前面讲了缓存原理工程里的难题是我们希望静态资源能缓存一年但又希望代码发布后用户能立刻拿到新版本。这两个目标看起来矛盾其实解决思路非常清晰文件名带内容指纹文件内容变了文件名就变浏览器把这当成全新资源请求。前端构建工具webpack、Vite、Rollup都能生成这类文件名比如app.6f8e9c.js只要文件内容变了contenthash就会变输出文件名也变。发布后HTML引用的是新文件名对浏览器来说就是一个从没见过的URL自然重新下载。关键在这里只有不带hash的HTML入口文件需要频繁地验证更新带hash的静态资源则应该给最长的缓存。我推荐的配置组合是HTML入口用Cache-Control: no-cache每次请求都协商改了就返回新HTML没改就304带hash的JS、CSS、图片、字体则用Cache-Control: max-age31536000, immutable一年都不必更新。immutable的作用是告诉浏览器这个URL永远不变连带着If-None-Match条件头去问服务器都不需要省掉的是协商过程本身。有些团队为了偷懒所有文件都不带hash统一设max-age86400。结果每次发版用户都得等一天甚至更久才能看到新版本或者干脆强制让前端在URL里拼时间戳。时间戳方案能解决立刻更新但会把缓存的收益全部抹掉每次发版所有静态资源全部重新下载。指纹化才是正解因为它把变与不变精确到单个文件。3.2 index.html 与 CDN 的缓存联动工程里静态资源一般会套一层CDN缓存因此变成三层浏览器缓存、CDN边缘节点缓存、源站缓存。多一层缓存就多一层失效问题。CDN本身有自己的缓存规则源站返回Cache-Control: s-maxage86400时CDN会缓存一天但s-maxage对浏览器不生效浏览器只认max-age。这套机制可以让浏览器和CDN采用不同缓存时长非常实用。举个例子你的JS带hash浏览器缓存一年没问题但如果CDN也缓存一年期间你想对某个文件做紧急回退就得等一年。此时可以给CDN设短一些的s-maxage或者单独刷新。HTML入口文件通常不建议给CDN缓存太久。如果CDN把HTML缓存了一小时你发布新版本后这一小时内用户拿到的都是旧HTML旧HTML里引用的是旧JS文件名换汤不换药。生产环境的做法一般是HTML走no-cache或者设置很短的max-age同时用CDN的刷新功能在发布后主动清理相关URL。还有一个容易被忽略的细节CDN是否缓存、缓存多长时间会受请求头里的Cookie、Authorization影响。如果响应带privateCDN更是直接不缓存。排查CDN不命中时先确认源站返回的Cache-Control是不是public。3.3 不同类型资源的缓存策略速查我整理了平时最常用的一套缓存配置可以直接抄资源类型推荐Cache-Control原因HTML入口文件no-cache保证新版本能及时被拿到带hash的JS/CSSmax-age31536000, immutable内容指纹保证不变性静态图片logo、背景图max-age31536000基本不变动态图片用户头像、商品图max-age3600 或 no-cache内容会变化希望复用近期资源字体文件 woff2max-age31536000, immutable体积大且通常不更新接口数据幂等GET按业务可短缓存max-age60降低重复请求压力敏感或个性化接口no-store防止数据串号这里解释一下动态图片不建议给长缓存。以用户头像为例如果缓存一年用户改了头像别人打开页面看到的还是旧头像。给一小时或走协商虽然不是实时但至少不会一年不更新。而静态logo这种几乎不变的资源长缓存没有任何风险。3.4 Service Worker应用层缓存是另一个维度严格来说浏览器还有一层由Service Worker控制的应用层缓存。Service Worker相当于浏览器里的代理它可以拦截页面发出的所有请求自己决定是走网络、返回缓存还是两者配合。常见方案是precache加runtime caching适合做离线可用、网状失败时的兜底。SW缓存和HTTP缓存的区别在于HTTP缓存由响应头驱动浏览器统一处理开发者控制力弱SW缓存由代码驱动存在Cache Storage里缓存什么、什么时候缓存、缓存多久全在JS里写。SW可以做network-first或cache-first也可以做stale-while-revalidate也就是先返回缓存再异步更新在体验和新鲜度之间取得平衡。SW有它的坑更新策略没写对页面永远停在旧版本。典型流程是install下载新SWactivate接管页面但页面第一次加载时旧的SW还在控制必须刷新一次新版才生效。所以别在SW里用太激进的永远走缓存要给好更新时机和版本管理。这块属于原理篇的延伸理解真正落地时值得单独开一篇长文。4. 实操用 DevTools 和 Node 重新认识一次请求4.1 Network 面板先学会看三列打开Chrome DevTools切到Network刷新页面最需要关注的三列是Name、Status、Size。Status出现200 (from disk cache)或200 (from memory cache)就是强缓存命中出现304就是协商缓存命中走了网络但省了传输。Size列也很直观正常网络请求会显示具体字节数命中缓存的会直接写from cache字样。一个实用技巧Disable cache复选框勾上后刷新等价于每次强制新的网络请求但它只在DevTools打开时生效不会影响真实用户体验。某些情况想彻底模拟完全无缓存的冷启动可以勾选Disable cache再组合Clear browser cache。这一步对排查是不是缓存坑了我非常有用。4.2 用Node写一个小服务亲手复现强缓存原理讲再多不如动手验证一次。我用Node的内置http模块就可以搭一个极简服务完全不用框架const http require(http) const server http.createServer((req, res) { res.setHeader(Cache-Control, max-age60) res.setHeader(Content-Type, text/plain; charsetutf-8) res.end(hello, cache test) }) server.listen(3000, () { console.log(server on http://localhost:3000) })启动后第一次访问http://localhost:3000Network里能看到正常200和具体的字节大小第二次刷新Status会变成200 (from disk cache)或者200 (from memory cache)。这时候把服务关掉再刷新页面依然能显示出hello, cache test因为资源已经在本地强缓存里根本不依赖网络。这个实验做完强缓存的效果比背书强十倍。4.3 用curl验证协商缓存协商缓存可以用curl模拟得更清楚。先跑一个静态服务或者用前面的Node服务补一个ETag输出然后执行curl -I http://localhost:3000/看响应头里的ETag和Last-Modified。第二次请求时手动带上If-None-Matchcurl -I -H If-None-Match: 上面的ETag值 http://localhost:3000/如果服务器正确实现了协商逻辑会返回304而不是200。开发联调的时候这招能用来确认测试环境缓存配置是不是真的生效了。很多后端同事习惯只设置Last-Modified通过这个命令一眼就能看出服务器有没有按预期处理条件请求。5. 常见问题与排查技巧实录5.1 明明改代码了线上还是旧版本这是问得最多的问题。通常原因有四个按排查优先级来第一HTML入口被缓存了这是最典型的情况浏览器或CDN把HTML缓存了导致解析到旧资源名。第二静态文件名没变用了构建工具但hash规则没配好或者变量漏了。第三CDN边缘节点缓存没刷新发布后立刻刷新旧URLCDN会继续返回旧缓存内容。第四中间链路的代理缓存包括企业内部网关、运营商层做的一层缓存。排查方式打开Network看HTML请求的响应头看Cache-Control是什么再看Status是不是from cache。如果HTML是from cache优先解决HTML缓存问题。如果HTML走了网络但返回的新HTML里引用到的JS却还是旧文件名那就是构建或发布问题跟缓存没关系。这个区分很重要能把排查范围缩掉一半。5.2 304请求变多性能报告还是说缓存命中率低304多意味着资源没有更新却每次都来问了一遍服务器。可能原因资源设置了no-cache或max-age0或者资源虽然带了长max-age但页面每次被强刷比如用户习惯性按F5。这里要分清命中率低和304多是两个不同的问题命中率低说明强缓存生效范围不足304多说明协商缓存阶段产生了大量验证请求。修复方向一个是拉长强缓存一个是让静态资源走immutable。另外很多前端监控平台把请求数作为性能指标如果这个指标里包含大量304会在统计上看着很吓人。处理办法是监控侧把304请求单列或者前端尽量让静态资源命中强缓存把304数量降下去。5.3 用户头像改了其他人看到的还是旧图这是典型的动态资源误用长缓存。解法有几种给动态图片换URL在头像URL里带上用户的版本号比如/avatar/9527?v3或者缩短Cache-Control给头像这种资源设max-age3600最多反映一小时延迟如果实时性要求极高且资源量不大干脆no-store配合后端及时响应。注意用URL带版本号的方式时前端请求的拼接逻辑和后端接口要保持一致不然扩展性很痛苦。我在带团队时统一封装过一个图片URL工具所有动态图都走同一套规则才把这类问题压下去。5.4 字体加载导致文字先空白再闪现字体文件是体积大、加载慢、又影响视觉体验的典型资源。FOITFlash of Invisible Text问题是浏览器在字体加载完成前默认隐藏文字导致的。解决靠CSS的font-displayfont-face { font-family: MyFont; src: url(/font.woff2) format(woff2); font-display: swap; }同时字体文件设置长缓存避免每次打开页面都重新下载字体。如果再配合preload提前加载关键字体首屏文字基本能避免闪烁。5.5 登录状态下的接口数据被缓存出现串号接口数据要不要缓存原则很简单如果响应内容是当前登录用户的隐私数据或者强实时数据一律no-store如果想缓存公共数据也要确认响应里没有个性化字段同时保证CDN那层也没有缓存private数据。有一个我实际见过的案例接口网关返回了200和Cache-Control: max-age3600但没有区分用户导致A用户登录后看到了B用户的部分数据这属于严重的线上事故。敏感接口宁可慢一点也不能给缓存。5.6 问题速查小抄症状最可能原因优先动作代码更新后线上仍是旧版HTML被缓存/CDN未刷新看HTML响应头设no-cache刷新CDN改CSS不生效文件名没带hash或hash没变检查构建配置contenthash图片不更新动态图长缓存动态图缩短缓存或URL带版本大量304资源强缓存时间过短静态资源上immutable长缓存首屏文字闪烁字体阻塞渲染font-display: swap加字体长缓存用户数据串号个性化接口被缓存敏感接口no-store最后分享一个我自己的习惯只要一个系统开始感知到缓存带来的新旧版本焦虑我就会在项目里固定维护一份缓存策略清单放在仓库docs目录下包含所有资源类型的缓存头、CDN刷新流程、发布后的验证命令。看起来有点重但它会在每次故障复盘时救急。缓存机制学完不是终点能把每次异常都归因到某一层缓存上你才真正把这套原理变成了肌肉记忆。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →