开头直接进入主题有一次排查线上问题用户反馈页面更新了但样式还是旧的我打开DevTools一看一堆请求从memory cache加载状态码200但文件内容还是上一版。从那次之后我对资源加载与缓存机制有了更深的敬畏。这套机制做得好性能能省一大半做得不好就是用户骂、测试崩、发布背锅。这篇就把资源加载这条链路和缓存机制的原理拆开讲透适合前端工程师、全栈开发者以及做性能优化的同学参考。1. 资源加载的完整链路从URL到渲染1.1 资源加载到底在加载什么很多人一提到资源加载脑子里浮现的是浏览器里那个Network面板一堆js、css、图片请求。但资源加载的本质是浏览器作为一个客户端通过HTTP协议向服务器请求文件收到响应后经过解析、执行或解码最后成为页面的一部分。这里面的“资源”不只是前端代码产物还包括图片、字体、视频、接口数据甚至是通过Service Worker预取的内容。但实际性能优化中最关键、也最容易出问题的是静态资源CSS、JS、图片、字体。因为接口数据还有可预期的动态性而静态资源完全可以通过一套合理的缓存策略让浏览器在第二次访问时“秒开”。我在实际项目里见过首屏加载60多个静态资源没有做任何缓存策略每次进入页面都在重新下载白屏时间两秒多。后来加了缓存和CDN白屏降到几百毫秒。差距就出在这套机制上。1.2 一次页面加载的时间都花在哪要理解缓存为什么重要先把一次页面加载的时间账单拆开。从用户在地址栏输入URL到页面可交互大体经历DNS解析域名转成IP通常几十到几百毫秒TCP连接三次握手RTT一次以上的网络往返TLS握手如果HTTPS额外1-2次往返服务器处理与响应后端处理时间 网络传输时间浏览器解析HTML并发现依赖资源边解析边发起子资源请求下载CSS、JS、图片等每个文件一次HTTP请求样式计算、布局、绘制、合成渲染时间注意子资源请求是阻塞渲染的关键。CSS会阻塞渲染JS脚本会阻塞解析。所以“资源加载”这件事直接影响首屏速度。缓存机制能优化的是哪里主要是本地已有资源时跳过DNS、TCP、TLS、服务器处理、网络传输这些步骤直接从浏览器本地读取。这就是为什么缓存是性能优化里性价比最高的一环。2. 缓存机制的核心原理为什么能省这么多时间2.1 强制缓存与协商缓存两种缓存怎么配合HTTP缓存分为两大类强制缓存本地直接使用不发请求和协商缓存需要向服务器确认是否可用。强制缓存对应响应头里的Cache-Control和Expires。如果命中了强制缓存浏览器直接使用本地副本网络请求根本不发出。你在DevTools里看到的from disk cache或者from memory cache就是强制缓存生效了。协商缓存对应Last-Modified/If-Modified-Since和ETag/If-None-Match。浏览器会把这个资源的某个标记发给服务器服务器判断资源有没有变化没变化返回304浏览器使用本地副本有变化返回200和新文件。实际场景经常是这样一个JS文件设置了Cache-Control: max-age3600那么一个小时内的访问都是强制缓存一小时后请求重新发出带有If-None-Match头服务器比对ETag决定返回304还是200。这里有个容易混淆的点很多人以为304就是“缓存命中”其实304是协商缓存命中它仍然发生了一次网络请求只是响应体没下载。而真正的零网络开销是强制缓存命中。2.2 HTTP缓存头逐字段拆解一个一个说。Expires是HTTP/1.0时代的字段指一个绝对过期时间。因为它依赖客户端时间而用户本地时间可能不准所以现在基本被Cache-Control替代。Cache-Control是HTTP/1.1定义的指令组合里面能出现的关键字max-age秒资源被缓存后多少秒内有效s-maxage秒针对共享缓存如CDN代理缓存的有效期如果设置了CDN优先用它public允许任何缓存保存响应包括CDN、代理private只允许浏览器保存不允许CDN等共享缓存保存no-store不许缓存每次都要去服务器拉no-cache字面意思容易误导它其实是可以缓存但每次使用前必须去服务器验证走协商缓存must-revalidate缓存过期后必须重新验证不能擅自使用过期资源ETag是资源内容的唯一标识一般由文件内容哈希、修改时间、版本号等生成。服务器返回资源时带上这个字段浏览器后面再请求时带上If-None-Match。如果内容一致返回304。ETag比Last-Modified更准确因为修改时间相同不代表内容相同。Last-Modified是资源的最后修改时间。服务器返回浏览器再请求时带上If-Modified-Since。它的问题在于只能精确到秒而且有些服务器会主动修改文件时间但内容没变导致不必要的重新下载。我在配置缓存时自己遵循的原则是能用ETag就用ETag能设置长max-age就设置长max-age但长max-age只配给带指纹哈希的文件。2.3 缓存优先级与浏览器决策流程浏览器处理缓存时优先级大致是检查Cache-Control的no-store如果是直接请求服务器检查Cache-Control的no-cache或者资源已过期走协商缓存发请求带验证头检查有效期内直接使用本地缓存如果本地有但标签页是新开的可能从磁盘缓存读取如果同一页面反复访问可能从内存缓存读取这里有个细节memory cache和disk cache的差异。内存缓存速度快但生命周期短关闭标签页就没了磁盘缓存容量更大跨会话存在。浏览器自己决定把资源放内存还是磁盘开发者基本控制不了也不用太纠结。还有一个容易忽略的字段Vary。它告诉缓存代理同一个URL可能因为某些请求头不同返回不同内容。比如后端根据Accept-Encoding返回gzip或br版本CDN在缓存时需要把Vary: Accept-Encoding考虑进去。如果忽略很可能CDN把gzip内容发给不支持gzip的客户端页面直接乱码或者解析失败。2.4 缓存失效的另一个维度浏览器刷新操作你有没有发现F5刷新和CtrlF5强制刷新表现不一样。普通刷新时如果资源没到max-age过期时间还是会走强制缓存但也会对当前页面HTML发一个带Cache-Control: max-age0的请求所以HTML通常是重新验证的。强制刷新CtrlF5则是把所有资源都忽略本地缓存全部重新发送请求并带上Cache-Control: no-cache。这也就是为什么老后端被吵醒时总是让用户按CtrlF5试试——实际上这就是绕过了所有缓存。理解了这一点再回顾开头那个线上事故用户抱怨“样式没更新”往往是因为HTML被缓存了页面里的CSS引用地址还是旧文件名或者虽然文件名变了但HTML没拿到最新版本。3. 资源加载的常用优化手段与缓存配置实操3.1 打包工具中如何生成带哈希的文件名静态资源文件名带哈希是“长缓存”策略的基石。因为文件内容变了哈希名就变浏览器把它当成全新资源自然绕过旧缓存文件内容没变哈希名不变浏览器可以放心使用长期缓存。以Webpack为例输出配置里常见的是output: { filename: [name].[contenthash:8].js, chunkFilename: [name].[contenthash:8].chunk.js }注意是contenthash不是hash。hash是每次构建全局生成一个哈希只要任何文件变了所有文件名都变缓存全部失效。contenthash是针对每个文件内容生成的哈希内容不变哈希就不变才能实现精准缓存。Vite里也有类似机制构建产物默认就是assets/index-xxxxxx.js中间那段就是内容哈希。而且Vite对动态import的chunk也做了哈希命名天然适合长缓存。但这里有一个隐藏坑Webpack的runtime和manifest如果被打包进主chunk那么主chunk的contenthash可能在你仅仅改一个异步模块时也变化因为runtime里的模块映射表更新了。解决办法是用optimization.runtimeChunk: single把runtime单独拆出来或者把第三方库用splitChunks拆成独立文件。否则长缓存效果会打折扣。3.2 Nginx静态资源缓存配置参考打包产物部署到服务器Nginx配置是决定客户端缓存行为的关键一环。我常用的配置是location /static/ { alias /var/www/site/static/; # 文件名带哈希允许长缓存 expires 1y; add_header Cache-Control public, immutable; add_header ETag ; } location / { # 入口HTML不缓存或者短缓存 add_header Cache-Control no-cache, must-revalidate; try_files $uri /index.html; }要点拆解/static/下面的资源都带内容哈希所以我可以放心写immutable。这个指令告诉浏览器资源内容不会变不用再向服务器发请求最长可以一年不验证。ETag 是在有contenthash文件名后没必要再用ETag做二次校验因为哈希本身就是内容标识。注意这里我清空了ETag避免出现“过期后重新验证ETag返回304”的多余请求。HTML必须走no-cache而不是no-store。no-cache意味着浏览器每次都会请求服务器验证但如果服务器返回304浏览器会用本地文件这比完全下载快。no-store则完全不允许缓存每次都必须重新下载HTML性能更差也没必要。实际部署时我遇到过明明配置了expires 1y但浏览器始终不走缓存的情况。后来发现是Nginx的gzip模块动态生成了不同响应而响应头里没有正确处理Vary: Accept-Encoding导致中间代理或浏览器认为不能缓存。所以配置缓存后一定要用curl检查响应头。3.3 Service Worker与HTTP缓存的联动Service Worker是更激进也是更可控的缓存手段。它可以拦截所有请求自己决定走网络还是走缓存甚至可以在空闲时预取关键资源。我在项目里用的策略是“网络优先 缓存兜底”self.addEventListener(fetch, (event) { const request event.request; if (request.mode navigate) { event.respondWith( fetch(request) .then((response) { caches.open(pages-v1).then((cache) cache.put(request, response.clone())); return response; }) .catch(() caches.match(request)) ); return; } // 静态资源缓存优先失败后回源 event.respondWith( caches.match(request).then((cached) { if (cached) return cached; return fetch(request).then((response) { if (response.ok) { caches.open(assets-v1).then((cache) cache.put(request, response.clone())); } return response; }); }) ); });这里有一个关键点Service Worker里面的缓存和浏览器HTTP缓存是两层不同的缓存体系。即使你设置了Cache-Control: no-storeService Worker照样能缓存只要你代码里写了缓存逻辑。所以Service Worker适合对动态接口做离线缓存也适合对静态资源做更精细的失效控制。当然Service Worker也有版本管理的坑。升级Service Worker时如果skipWaiting和clients.claim控制不好用户可能长时间停留在旧版本。我的经验是版本号写在SW文件名里比如sw-v2.js在HTML里通过解析时间戳或构建版本动态注册更新逻辑相对干净。3.4 CDN缓存资源加载的加速器CDN本质上是一个分布式的共享缓存。用户请求静态资源时被调度到最近的CDN节点如果节点上有缓存直接返回不需要回源服务器。这层缓存受HTTP缓存响应头控制但和浏览器缓存不一样的是CDN节点之间存在缓存一致性的问题。一个典型问题是你更新了源站的index.html但CDN节点还在服务旧版本。解决思路是HTML设置短缓存比如Cache-Control: max-age60, s-maxage60这样CDN最多滞后一分钟带哈希的静态资源设置Cache-Control: max-age31536000, immutableCDN可以放心缓存一年CDN控制台一般提供缓存刷新或缓存预热的API发版后主动刷新涉及的文件这里要提醒一点CDN缓存穿透指恶意或异常的请求绕过了CDN缓存频繁打到源站。比如某个资源URL带了随机查询参数?v123而CDN忽略查询参数策略没配置好每个不同参数都当成新资源回源导致源站压力骤增。解决办法是CDN配置时静态资源URL不要带无意义的动态参数或者让CDN忽略特定查询参数。4. 缓存命中率排查与常见问题实录4.1 如何用DevTools查看缓存命中状态打开Chrome DevTools的Network面板点一个资源看Headers。重点关注Request Headers里有Cache-Control: no-cache说明浏览器准备走协商缓存Response Headers里有Cache-Control: max-age31536000说明强制缓存生效中Size列显示from disk cache或from memory cache说明强制缓存直接命中Status Code显示304 Not Modified说明协商缓存生效减少了响应体传输我习惯把这几个状态记成表格现象级别说明200 from memory cache最好完全没发网络请求200 from disk cache很好命中强制缓存从磁盘读取304 Not Modified一般发了一个请求但只交换了头信息200 OK较差完整下载可能是首次访问或缓存失效排查时如果发现本应走强缓存的资源频繁出现200 OK先看响应头有没有Cache-Control再看有没有max-age最后看URL里是不是带了影响缓存的参数。我经常见到的情况是开发环境加了Cache-Control: no-store测试人员拿这套代码测生产误以为缓存配置没生效。4.2 常见的坑缓存了不该缓存的资源、更新不生效、CDN缓存穿透坑一把HTML也设置了max-age86400这是最烦的。HTML没有哈希名它引用的资源路径写在里面。如果HTML被缓存了一天发版后用户看到的一整天都是旧入口。解决HTML用no-cache或者max-age0, must-revalidate。这样浏览器每次会去请求服务器验证虽然多了一个请求但对动态入口来说值得。坑二接口数据被缓存有些团队为了让GET接口也享受缓存给接口设置了Cache-Control: public, max-age60。但GET接口经常带用户权限信息响应内容可能因用户而不同。如果被CDN或浏览器错误缓存会出现A用户看到B用户数据的严重事故。除非接口真正是公共的无状态数据否则不要给动态接口设置共享缓存最多用private, no-store这类头。坑三哈希文件名与CDN刷新配合失误我踩过一次比较深的本地构建产出的CSS文件名是index.abc123.css但部署时因为脚本问题覆盖了同名文件CDN上还是旧内容。虽然文件名一样但内容已经变了CDN却不知道一直发旧版本。后来我给构建加了一步每次部署前先在CDN控制台刷新对应目录或者改用带版本号的文件名路径比如assets/v20250205/才彻底解决。坑四ETag和Last-Modified同时存在导致的偏差某些框架默认同时输出ETag和Last-Modified。正常情况下没问题但如果你手动改了文件时间或者内容生成逻辑不稳定ETag会比 Last-Modified更准确。我在Nginx里推荐两者选一个优先ETag而且响应头的ETag不要随便关掉除非所有资源都有了内容哈希。4.3 问题速查表症状常见原因处理手段改代码后页面不更新HTML被强制缓存检查HTML响应头改为no-cacheJS/CSS更新了但文件名相同构建哈希未生效或部署覆盖使用contenthash、检查构建产物首次访问很慢第二次正常缺少预加载或关键资源被瀑布阻塞加preload、拆关键路径CDN偶尔返回旧版本CDN缓存未刷新发版后主动刷新/预热相关资源带参数URL全部回源CDN忽略查询参数配置不当配置CDN忽略无用的query参数浏览器不走缓存响应头缺失或no-store检查Cache-Control、ETag请求一堆接口却缓存命中差动态接口大量请求且不可缓存针对稳定数据加短期缓存或SW缓存查问题时我建议用curl直接模拟请求看响应头别总盯着DevTools。一条命令能快速定位是不是服务端头的问题curl -I https://example.com/static/index.abc123.css看返回的cache-control和etag如果头没有问题再怀疑浏览器或中间代理。5. 从一次线上事故来看缓存配置的完整落地去年我们做过一次性能优化目标是首屏时间从2.8秒降到1.5秒以内。我先把所有资源的响应头抓了一遍发现80%的静态资源没有Cache-Control全部走默认协商缓存每次刷新都重复下载。具体动作是构建侧统一改成contenthash命名拆出vendor和runtimeNginx针对/assets/目录配置Cache-Control: public, max-age31536000, immutableHTML改为no-cacheCDN上配置同源策略关闭针对静态资源的压缩穿透问题给首屏关键字体和图片加了preload上线后二次访问的强缓存命中率从不到10%跳到95%以上大部分用户在第二次打开时几乎零请求。这个结果完全依赖于对资源加载与缓存机制的理解而不是单纯加CDN或者换服务器。其中有一步最有代表性调整runtime拆分。之前Webpack构建改一个组件入口JS的哈希也会跟着变导致整个vendor文件失去缓存。拆出runtime之后哪怕业务代码变更频繁只有涉及变更的文件哈希变化稳定文件能维持一年有效期。这里有个额外心得缓存机制不是一次配完就完事它是一个和发布流程强相关的系统工程。每改一个资源路径、每新增一个CDN规则都要重新审视一遍整体策略。6. 实测有效的一段Nginx构建完整配置参考最后给一份可以直接抄作业的组合这是我目前项目里验证过比较稳的配置。构建侧Webpack关键配置module.exports { output: { filename: assets/[name].[contenthash:8].js, chunkFilename: assets/[name].[contenthash:8].chunk.js }, optimization: { runtimeChunk: single, splitChunks: { cacheGroups: { vendor: { test: /[\\/]node_modules[\\/]/, name: vendor, chunks: all } } } } };Nginx对应server { listen 80; server_name example.com; root /var/www/site; location /assets/ { expires 1y; add_header Cache-Control public, immutable; } location / { try_files $uri /index.html; add_header Cache-Control no-cache, must-revalidate; } gzip_static on; brotli_static on; }注意两个点gzip_static on指Nginx直接使用预压缩的.gz文件避免每次请求动态压缩CPU压力和响应时间都更低。前提是构建产物里有对应的.gz版本。add_header部分注意不要覆盖原有的ETag、Last-Modified我上面只加了缓存的指令。expires会自动生成Expires和Cache-Control: max-age但和手动add_header Cache-Control同时存在时会产生重复头所以我在/assets/里没有用expires而是直接写add_header。实际测试中没有问题但不同版本Nginx行为略有差异需要实测。配完后用curl检查两个关键路径curl -I https://example.com/ curl -I https://example.com/assets/index.abc123.css入口HTML应该看到Cache-Control: no-cache, must-revalidate静态资源应该看到Cache-Control: public, immutable。我把这套配置落地到项目后开发反馈测试环境经常拿旧代码问题也少了。因为测试环境我单独用了Cache-Control: no-store避免开发反复切版本时的缓存干扰生产严格按照长缓存策略。这里也提醒环境和缓存策略要分开不能一套配置走天下。资源加载与缓存机制本质上是在“网络耗时”和“数据新鲜度”之间做平衡。带哈希的资源可以放心长缓存入口HTML需要每次验证动态接口要根据业务性质单独设置。每一层缓存浏览器、CDN、Service Worker都有自己的生命周期和失效方式配合好了加载速度会有质的提升。我个人实际动手排查过很多缓存类问题最大的一个体会是不要只盯着前端代码改先抓响应头再梳理整个链路。很多“缓存不生效”的问题根子都在服务端或者CDN配置上和代码关系不大。希望这篇能帮你少走一些弯路也欢迎在评论区分享你踩过的缓存坑。