1. 从一次线上事故说起:缓存不是随便加几个响应头那么简单
记得去年年中,我负责的一个电商后台项目上线了一个紧急修复,改的是某个公共模块的JavaScript逻辑。测试环境一切正常,QA验证通过,然后很自信地点了发布。结果上线半小时内,客服那边炸了,反馈用户在浏览器上看到的白屏和错乱的页面样式。第一反应是回滚,但回滚之后用户那边还是有问题。后来排查发现,问题根本不在服务端代码,而是静态资源的缓存策略太激进,导致线上用户继续用着旧版本脚本,和新版页面接口对不上。
那次事故之后,我花了整整一周把整个项目的前端缓存策略全面梳理了一遍,也把HTTP缓存、浏览器缓存、CDN缓存、Service Worker这些链路彻底吃透了。今天这篇博文,我就把这一整套实战经验完整地分享出来,包含各种配置模板、踩坑记录和排查思路,希望帮你少走弯路。
先说一个重要结论:前端缓存绝对不是"给静态资源加个Cache-Control头"这么简单。真正的缓存策略是一个体系,涉及浏览器、服务器、CDN三层协同,每一层都有各自的职责和坑。如果只是一知半解地乱配,轻则资源更新不及时,重则线上事故。这些内容也是很多前端面试题里高频考察的点,搞清楚底层原理,面试和实战都能稳很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 缓存体系全景图:先分清谁在缓存你的页面
很多人一提到前端缓存,第一反应就是浏览器缓存。但实际上,当一个用户访问你的网站时,静态资源会经过至少三层的"储存与转发":浏览器缓存、CDN节点缓存、源站服务器。这三层各自独立,又互相配合,每一层失效都会导致下一层被请求,所以性能瓶颈往往出在"你只配好了其中一层"。
2.1 浏览器缓存:离用户最近的第一道防线
浏览器缓存存储在网络请求的响应层,说白了就是浏览器替你把之前请求过的资源(HTML、CSS、JS、图片、字体等)存在本地磁盘或内存中。当再次发起相同请求时,浏览器会根据缓存策略决定"直接使用本地副本""发起条件请求验证"还是"重新下载"。
浏览器缓存最大的价值在于:它让"重复访问"完全绕开了网络传输。对同一个用户来说,第二次打开你网站的加载速度,理论上可以快到接近0毫秒(从磁盘读)。但它的局限也很明显——它只对"同一个浏览器"生效,换个设备、换个人,缓存就完全没用了。也就是说,浏览器缓存解决的是"老用户回访"的体验问题。
2.2 CDN缓存:用户和源站之间的中转站
CDN缓存解决的是"不同用户、不同地区"的加速问题。源站服务器通常部署在某个机房,用户如果在全国乃至全球各地,直接访问源站延迟会很高。CDN会把你网站的静态资源分发到各个边缘节点,用户请求时,CDN会从最近的节点直接返回资源,源站的负载也大大降低。
但这里面有一个常见误区:很多人以为CDN只是"加速",其实它本质上是"分布式缓存"。CDN节点同样遵循HTTP缓存协议,它需要根据你设置的Cache-Control、Expires等响应头来决定缓存多长时间。如果你在源站配置了错误的缓存头,CDN也会跟着错,甚至比浏览器缓存更坑——因为CDN缓存的资源是"所有用户共享"的,一旦某节点缓存了错误版本,影响的就是一批用户。
2.3 源站服务器:缓存策略的"立法者"
服务器是缓存策略的源头,它通过HTTP响应头告诉浏览器和CDN"这个资源可以缓存多久、能不能缓存、需要什么条件下才能复用"。常见的响应头包括Cache-Control、Expires、Last-Modified、ETag等。
这里有个很重要的认知:缓存策略不是写在前端代码里的,而是由服务器通过响应头"下发"的。前端的性能优化,很多时候其实是在和后端、运维协作,把响应头配置好。这也是前端开发skills中经常被忽略、但在实际工作中极其实用的能力。
2.4 各层缓存的职责划分
| 缓存层级 | 作用范围 | 主要目标 | 关键控制手段 |
|---|---|---|---|
| 浏览器缓存 | 单个用户,单个浏览器 | 减少重复访问的加载时间 | Cache-Control、Expires、ETag、Last-Modified |
| CDN缓存 | 某个区域的全体用户 | 降低源站压力,加速跨地域访问 | CDN控制台配置、源站响应头 |
| 源站服务器 | 全部用户 | 生成正确的响应头,保证资源正确性 | Nginx/中间件配置、构建工具产物 |
理解了这个分层模型之后,我们再去谈具体的配置,就不会像无头苍蝇一样乱试了。接下来重点讲前端性能优化中最核心、也最容易被配错的HTTP缓存机制。
3. HTTP缓存深度拆解:强缓存与协商缓存的底层逻辑
HTTP缓存是整个前端缓存体系的基石。它的核心思路是:通过一组响应头,让浏览器和CDN判断"这个资源是否还能继续用"。HTTP缓存分两种模式:强缓存(也叫本地缓存)和协商缓存(也叫对比缓存)。两者最大的区别是:强缓存完全不需要发请求,协商缓存则需要发一个"问一问"的请求。
3.1 强缓存:不发请求直接用的底气
强缓存的意思是,浏览器判断缓存还在有效期内,就直接从本地读取,网络请求都不会发出。控制强缓存的响应头有两个:Cache-Control(HTTP/1.1)和Expires(HTTP/1.0)。
Cache-Control的常用取值如下:
max-age=31536000:资源被缓存,且在指定秒数内直接复用,不发起请求。31536000秒等于1年,这是静态资源最常用的配置。public:允许浏览器和CDN节点缓存,适合公开的静态资源。private:只允许浏览器缓存,CDN不允许缓存,适合包含用户私密信息的响应。no-cache:这个名字很容易误导人,它的意思不是不缓存,而是"使用缓存前必须先问源站验证一下"——也就是说,它会把缓存强制降级为协商缓存模式。no-store:完全不缓存,每次都必须完整下载,适合包含敏感信息的页面。
Expires是HTTP/1.0时代的老方案,它指定一个绝对过期时间(比如Expires: Wed, 21 Oct 2026 07:28:00 GMT)。问题在于:它是服务器时间,如果客户端本地时间和服务器不一致,缓存判断就可能出错。所以现代项目基本都推荐用Cache-Control的max-age(相对时间),并且要把Expires和Cache-Control同时设置时,Cache-Control的优先级更高。
3.2 协商缓存:发一个轻量请求,确认还能不能用
当强缓存过期了,或者响应头是no-cache,浏览器会带着上次缓存的标识(ETag或Last-Modified)向服务器发一个验证请求。服务器判断资源没变,就返回304状态码(Not Modified),响应体为空,浏览器继续使用本地缓存;如果变了,就返回200和新资源。
协商缓存也有两个控制头:
- Last-Modified / If-Modified-Since:服务器返回资源时的最后修改时间,浏览器下次请求时带上这个时间,服务器判断"在这个时间之后有没有改过"。它的精度只有秒级,如果文件在1秒内被修改了多次,就可能判断失误。
- ETag / If-None-Match:ETag是服务器为资源生成的唯一标识(通常是文件内容的哈希值),内容变了标识就变。浏览器下次请求时带上这个标识,服务器对比后决定返回304还是200。
在实际项目中,ETag比Last-Modified更可靠,因为哈希对比能精确到内容级别,而时间戳有精度限制。但ETag的计算有一定成本,如果一个集群里的多台服务器ETag生成规则不一致,还可能出现"同一资源在不同服务器上ETag不同"的诡异问题。所以在高并发场景下,很多团队会同时使用两个头,用Last-Modified作为兜底。
3.3 强缓存与协商缓存的协作流程
一个典型的HTTP缓存生命周期是这样的:
- 浏览器首次请求某个JS文件,服务器返回200,响应头带上
Cache-Control: max-age=31536000和ETag: "abc123"。 - 浏览器在一年内的后续请求中,直接走强缓存,完全不发请求,加载速度极快。
- 一年后,浏览器带
If-None-Match: "abc123"发起协商缓存请求。 - 服务器对比ETag,发现资源没变,返回304,浏览器继续用本地副本。
- 如果资源变了,服务器返回200和新资源,浏览器更新缓存。
这个流程看似简单,但在真实项目中,很多问题就出在"资源更新了,但浏览器还在用强缓存里的旧版本"。这正是我在开头讲的线上事故的根源。解决这个问题的关键,就是下一节要讲的缓存更新策略。
4. 缓存更新策略与版本管理:解决"更新了但用户看到的还是旧的"
我做过一个小调研,很多前端开发者在刚接触缓存时,习惯性做法是给所有静态资源统一设置Cache-Control: max-age=31536000,然后发现线上更新后,用户怎么刷新都是旧版本,于是又改成no-cache,性能又被拖垮了。这其实是没搞懂"缓存更新"到底怎么设计。
4.1 文件名指纹哈希:最优雅的缓存失效方案
正确的做法是:让文件名和内容绑定。在构建阶段,工具会把文件内容计算成一个哈希值拼在文件名中。内容变了,文件名就变了,URL就变了,浏览器就会把它当成一个全新资源去请求;内容没变,文件名不变,就能长期使用强缓存。
举个例子,构建前的文件名是app.js,构建后变成app.8d3f2a9e.js。第一次发布这个版本,浏览器缓存了app.8d3f2a9e.js,一年有效。后来你改了代码,重新构建,新文件名变成app.6e1b4c7d.js,HTML里引用的是新文件,浏览器发现这个URL从来没请求过,自然不会命中旧缓存。
这种"哈希指纹+长缓存"的组合,是现阶段最主流、也最稳妥的静态资源缓存策略。主流的构建工具(比如Vite、Webpack)默认就会在产物文件名中加上内容哈希。需要注意区分三种哈希模式:
hash:基于整个项目的构建生成,只要任何一个文件变了,所有文件名的hash都会变。适合文件名数量不多的小项目,但不利于最大程度利用缓存。chunkhash:基于代码块内容生成,同一个chunk内的文件共享一个hash,所以如果你改了入口文件的内容,入口文件及其依赖的chunkhash会变,但不受影响的独立chunk不会变。contenthash:基于单个文件内容生成,只有该文件内容变了,它的hash才会变。这是最推荐的方案,CSS和JS可以各自独立更新。
4.2 HTML文件的缓存策略:永远不要长缓存
HTML文件是整个缓存策略的"入口",它负责引用其他带哈希的资源。如果HTML被缓存了,那就坏事了:即使你的新资源已经部署上线,用户拿到的还是旧HTML,里面引用的还是旧资源。
所以,HTML文件通常要设置Cache-Control: no-cache,也就是每次都要向服务器协商验证一下。因为HTML通常体积很小,协商验证的代价可以忽略,但能保证用户永远拿到最新的HTML,从而引用最新的静态资源。
这里有个很容易犯的错误:有些人把HTML也设置了max-age=86400(1天)。这意味着用户在1天内看到的始终是旧页面,而且旧页面上引用的旧资源可能已经被强缓存了很长时间。如果你一边更新版本号一边又给HTML配了长缓存,最终效果就是"新旧混杂",各种奇怪的前端问题就来了。
4.3 Nginx环境下的缓存配置实例
假设你有一个Vite或Webpack构建的纯前端项目,部署在Nginx上。一个推荐的配置模板如下:
nginx复制# 带contenthash的静态资源:一年强缓存
location ~* \.(js|css|png|jpg|jpeg|gif|svg|webp|woff2?)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# HTML文件:协商缓存
location ~* \.html$ {
expires -1;
add_header Cache-Control "no-cache";
}
这里的immutable是一个额外指令,告诉浏览器"这个资源在过期前绝对不变"(但它不能单独使用,必须搭配max-age)。加了immutable之后,即使用户手动刷新页面,浏览器也不会对这类资源发协商请求,进一步减少了请求量。
不过要提醒一句,这些配置需要运维配合,如果你们公司Nginx不在你手里,就把这篇文章转给运维同学看看,把配置和原因讲清楚。
4.4 接口请求的缓存策略:默认不缓存,按需定制
对于数据接口(XHR/Fetch请求),建议默认设置为no-store或no-cache,尤其是涉及登录态、订单信息等动态数据的接口,绝对不要做强缓存。
有一种情况可以例外:内容基本固定的字典类数据、配置类数据,如果接口响应大、变化频率低,可以在代码层面加上Cache-Control: max-age=60之类较短时间的缓存,或者用ETag做协商缓存。但这类接口一定要设计好版本号,避免上线后出现数据不一致。
5. 从构建配置到CDN分发:一套可落地的方案
很多前端性能优化文章讲到HTTP缓存就停了,但真实生产环境里,CDN的配置同样举足轻重。如果CDN节点缓存了错误的资源,或者缓存了不该缓存的资源,问题往往比浏览器缓存更严重。这一节我梳理一套从构建到CDN落地执行的完整方案。
5.1 构建工具的缓存相关配置
在使用Vite构建项目时,默认配置其实已经做得很好了。它的产物文件名默认带contenthash,CSS和JS也能自动拆分。但有一些配置需要你手动确认:
js复制// vite.config.js
export default {
build: {
// 开启CSS代码分割,确保CSS也能独立更新缓存
cssCodeSplit: true,
// 指定chunk大小警告阈值,帮助分析是否需要手动分包
chunkSizeWarningLimit: 1000,
rollupOptions: {
output: {
// 手动分包示例:把体积大且不常变的第三方库拆成独立chunk
manualChunks: {
vendor: ['react', 'react-dom'],
echarts: ['echarts']
}
}
}
}
}
手动分包的目的在于:第三方库(如echarts)通常体积大,但更新频率极低。如果把它和业务代码混在同一个chunk里,你每次改业务代码,这个chunk的hash都会变,CDN和浏览器缓存白费了。拆出来之后,echarts的chunk可以长期缓存,用户再次访问时只需要下载很小的业务代码增量。
5.2 CDN缓存配置和回源策略
CDN的配置主要有三个维度:缓存过期时间、缓存键(Cache Key)、回源策略。
- 缓存过期时间:对带哈希的静态资源,CDN可以设置一个较长的过期时间,比如7天、30天。有些CDN平台还支持遵循源站的Cache-Control头,如果源站配置了
max-age=31536000,CDN就跟着用这个时间。 - 缓存键:CDN缓存以URL为维度。如果你的CDN配置了"忽略查询参数",那么
app.js?version=1和app.js?version=2会被当成同一个资源。这其实有利有弊:好处是缓存命中率更高,坏处是如果你依赖URL参数做版本更新,就会踩坑。最稳妥的方式是让版本信息体现在文件名中,而不是URL参数中,这样CDN缓存键不需要额外配置也能正确区分。 - 回源策略:当CDN节点没有缓存或缓存过期时,它会向源站发起请求获取资源,这个过程叫回源。如果你的源站响应头设置了
Cache-Control: no-store,CDN会尊重这个指令,不做缓存,每次都会回源。对于静态资源来说,这显然不理想,所以要让源站对静态资源返回可缓存的响应头。
5.3 静态资源的版本回退问题
用CDN + 哈希文件名方案之后,还有一个容易被忽视的问题:旧版本资源的清理与回退。
假设你某个图片资源被缓存了一年,但该图片在版本v2中已不再被HTML引用。如果CDN一直缓存着这个图片,问题不大,顶多占点存储;但如果你的站点设计得比较激进,让CDN在资源过期后回源到源站,而源站已经把旧文件删掉了,那么用户在访问一个"历史页面"(比如某个老文章页)时,图片就会404。
解决方案通常有两种:一是源站上保留最近几个版本的静态资源,不要急着清理;二是在业务层面做好静态资源目录的版本号管理,比如按版本号分目录存放,部署新版本时保留上一版本目录一段时间的访问能力。
5.4 前端缓存策略与后端接口设计的配合
缓存的收益不止在静态资源,接口层面也有优化空间。
我在实际项目里发现,很多接口响应里含有大量不常变化的数据,比如省市区列表、商品分类树、全局配置项。这类接口每次请求都返回几百KB,用户每次打开页面都要重新拉取,既浪费带宽又拖慢渲染。针对这类接口,可以尝试:
- 给接口设计一个
version字段,前端在请求时带上,后端根据版本判断是否需要返回新数据。 - 在接口响应头设置
Cache-Control: private, max-age=300,允许浏览器缓存5分钟。 - 用ETag做协商缓存,数据没变化时返回304,响应体为空,大幅减少流量。
这样操作的前提是:接口不涉及用户个性化数据。只要涉及用户身份、权限、订单等,一律不做强缓存,最多用协商缓存。
6. 本地存储与Service Worker:缓存策略的进阶玩法
HTTP缓存能解决大部分静态资源的缓存问题,但如果你想做"离线可用""秒开页面"这类更进一步的体验,就必须把localStorage、IndexedDB和Service Worker纳入考虑范围。这一节讲的方案,更适合中大型项目。前端性能优化做到这个层级,已经能带来肉眼可见的体验差异。
6.1 localStorage与sessionStorage:轻量数据的本地副本
localStorage和sessionStorage的定位是存储结构化的小数据。它们不是HTTP缓存的替代品,但可以作为"接口数据层"的补充。
比如用户首次访问页面拉取省市区列表,可以把它序列化后存到localStorage里,设置一个过期时间。下次再进页面时,先读localStorage,命中就直接用,没命中或过期了再发请求。这种策略能有效减少接口请求次数和等待时间。
但有几个非常重要的坑:
- localStorage的存储配额一般在5MB左右,存太多数据会撑爆,写入时报QuotaExceededError。
- localStorage的读写是同步操作,如果你把大对象频繁地序列化/反序列化,会阻塞主线程,反而降低性能。
- 存储内容无法被CDN缓存,也不能跨域共享。如果站点部署了多个子域名,需要设计好数据隔离方案。
6.2 IndexedDB:大数据的本地数据库
当数据量超过localStorage的承受能力时,IndexedDB是更合理的选择。它适合存储大量结构化数据(比如离线缓存文章列表、地图瓦片、音视频资源等),异步API不会阻塞主线程。
IndexedDB的使用门槛比localStorage高很多,需要处理事务、对象仓库、索引等概念。不过现在有很多封装库(如Dexie.js)可以大幅降低使用成本。我的建议是:用IndexedDB缓存业务数据时,一定要做版本管理和数据淘汰机制,避免数据无限膨胀导致用户磁盘被塞满。
6.3 Service Worker:可控的"浏览器中间层"
Service Worker是一个独立于页面线程运行的脚本,它可以在网络请求发出之前拦截请求,并从缓存中返回资源。它的能力比HTTP强缓存更灵活,能够实现离线访问、自定义缓存策略等高级功能。
典型的缓存策略有:
- Cache First:优先读缓存,没有缓存再请求网络。适合不常变动的静态资源。
- Network First:先请求网络,失败时回退到缓存。适合需要实时性、但也要保障可访问性的页面(比如资讯类页面)。
- Stale While Revalidate:先返回缓存,同时在后台请求新版本并更新缓存。这是目前最推荐的一种策略,兼顾了速度和新鲜度。PWA中常见。
下面是一个简化的Service Worker缓存示例,实现了Stale While Revalidate策略:
javascript复制const CACHE_NAME = 'my-app-v1';
const PRECACHE_URLS = ['/', '/index.html'];
// 安装阶段预缓存核心资源
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => cache.addAll(PRECACHE_URLS))
);
});
// 激活阶段清理旧缓存
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys()
.then(keys => Promise.all(keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key))))
);
});
// 拦截请求:返回缓存,同时后台更新
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(cachedResponse => {
const fetchPromise = fetch(event.request).then(networkResponse => {
// 只缓存同源且成功的响应
if (networkResponse.ok && event.request.url.startsWith(self.location.origin)) {
caches.open(CACHE_NAME).then(cache => cache.put(event.request, networkResponse.clone()));
}
return networkResponse;
}).catch(() => cachedResponse);
return cachedResponse || fetchPromise;
})
);
});
Service Worker的坑也不少,最典型的两个:
- 文件更新陷阱:Service Worker本身也有缓存问题。如果你更新了sw.js内容,浏览器会在下次访问时在后台检测并安装新版本,但旧版本直到所有页面关闭后才会完全退出。这个"双生命周期"机制经常会让人觉得"我明明更新了,怎么不生效"。
- 缓存爆炸:如果缓存策略设定不当,Service Worker会无限制地缓存所有请求,包括很多带有临时数据的请求,导致Cache Storage占用越来越大。
6.4 性能监控与缓存命中率分析
配置做得再漂亮,没有数据支撑也白搭。建议至少在项目里接入基础的性能监控,关注以下指标:
- DOMContentLoaded、Load时间:宏观感知页面加载速度。
- 资源加载数量与体积:页面加载了哪些资源、走了缓存还是网络请求。
- 缓存命中率:静态资源的强缓存命中率、协商缓存命中率。
- LCP(Largest Contentful Paint):最重要的核心性能指标之一,直接反映用户看到主要内容的速度。
浏览器DevTools的Network面板能看到每个请求的缓存状态。在Info列能看到"disk cache"或"memory cache"表示走了强缓存,状态码304表示走了协商缓存。借助这些信息,很容易定位缓存策略是否生效。
7. 我踩过的那些缓存坑:排查思路与解决实例
缓存问题最容易出问题的不是你完全不知道怎么配,而是你"感觉配置都对了",但真实情况却一塌糊涂。接下来我把几个印象最深的坑整理出来,每个都是真实项目里遇到过的,包含完整的排查链路。
7.1 强缓存生效但用户拿到旧资源的根因
现象:发布新版本后,我这边用无痕模式验证一切正常,但用户反馈还是旧页面。
排查链路:
- 我先在DevTools里禁用缓存、强制刷新,一切正常,说明新资源已在源站和CDN生效。
- 打开一个普通标签页(不带禁用缓存),刷新页面,Network面板显示HTML请求返回200,但响应体还是旧的。
- 检查HTML响应头,发现
Cache-Control: no-cache没写对,Nginx配置里被Cache-Control: public, max-age=86400覆盖了。 - 修复后,再刷新页面,HTML返回304,本地缓存被重新验证生效,新HTML被正确加载,问题解决。
结论:所有静态资源都可以设长缓存,唯独HTML必须设no-cache。这个配置要仔细检查实际响应头,而不要只看配置文件,因为Nginx的配置合并和覆写规则非常多。
7.2 加了contenthash,文件名没变,用户拿到的还是旧代码
现象:明明本地构建后文件名hash变了,但线上用户加载的还是旧hash的JS。
排查链路:
- 检查是不是部署流程没跑构建,直接把上一个版本的dist目录同步上去了。
- 检查是不是CDN缓存了HTML本身。很多CDN默认会对HTML做缓存,CDN节点上的HTML是旧的,里面引用的还是旧的JS路径,自然加载旧产物。
- 检查构建配置是否用了
hash而不是contenthash,导致整个项目只要有一个文件变化,所有文件都会被重构。虽然这个不会导致"旧引用",但会让你失去分包缓存的意义。
最终解决方案:在构建流程里固定使用contenthash,并确认CDN上给HTML设置了较短的缓存或不缓存。
7.3 CDN节点缓存导致的局部用户异常
现象:某地区用户反馈页面样式全乱了,其他地区正常。
排查链路:
- 打开DevTools排查,发现某一区域的CDN节点返回的CSS资源是旧版本,而这个CSS和当前HTML引用的新CSS文件名不同。
- 进一步检查发现,CDN平台配置了"忽略查询参数",而我之前为了做紧急修复,把CSS文件用URL参数加了版本号(比如
app.css?v=20250101),想强制刷新缓存。结果CDN把app.css?v=20250101和app.css?v=20240101当成同一个资源,某些节点还缓存着旧参数。 - 最后清掉了CDN缓存,并按规范把版本号全部放到文件名里,从根上避免这个问题。
结论:使用带哈希的文件名是唯一不容易踩坑的CDN缓存方案。URL参数做版本控制,在CDN层很容易出幺蛾子。
7.4 浏览器“强制刷新”和“普通刷新”对缓存的影响
很多非前端同学不理解:为什么用户说"我刷新了"但看到的还是旧页面?因为浏览器区分了几种刷新模式:
- 普通刷新:会重新发起HTML请求,但对静态资源仍会走强缓存(如果没过期)。
- 强制刷新(Ctrl+F5 / Cmd+Shift+R):会绕过强缓存,重新验证所有资源,理论上能拿到最新版本。
- 无痕模式:没有历史缓存,一切从零开始加载。
所以在排查"用户看到旧页面"的问题时,一定要先确认用户到底用的哪种刷新。这也是我把这些经验写进这篇缓存策略博客的主要原因——很多时候根本不是缓存策略的问题,而是测试方法不对。
8. 前端缓存策略的“万能配置模板”与落地检查清单
这一节我总结一份可以直接抄的配置模板,适合大多数中后台和内容型前端项目。根据项目类型,按需调整即可。
8.1 一套通用的部署模板
Nginx层:
nginx复制# 静态资源带contenthash:长缓存
location ~* \.(js|css|png|jpg|jpeg|gif|svg|webp|woff2?|ttf)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# HTML入口:协商缓存
location ~* \.html$ {
expires -1;
add_header Cache-Control "no-cache";
}
# 图片资源(无hash):中等缓存
# 注意:如果图片文件名无法带hash,建议设置较短max-age(如7天)并配合ETag
location ~* \.(png|jpg|jpeg|gif|svg|webp)$ {
expires 7d;
add_header Cache-Control "public, max-age=604800";
}
构建工具配置(Vite示例):
js复制export default {
build: {
cssCodeSplit: true,
chunkSizeWarningLimit: 1000,
rollupOptions: {
output: {
manualChunks: {
vendor: ['vue', 'vue-router', 'pinia'],
echarts: ['echarts'],
}
}
}
}
}
配置原则总结:
| 资源类型 | 缓存方式 | 说明 |
|---|---|---|
| 带contenthash的JS/CSS | 强缓存1年 + immutable | 文件名即版本号 |
| HTML | no-cache | 每次都协商验证 |
| 不常变的图片 | 强缓存7天 ~ 30天 + ETag | 兼顾新鲜度和命中率 |
| 动态接口 | no-store 或 no-cache | 绝对不强缓存包含用户态的数据 |
| 字典类接口 | private + 短max-age | 需确认不含用户私有数据 |
8.2 上线前检查清单
发布一个前端版本前,我建议按这个清单走一遍:
- [ ] 构建产物文件名是否包含contenthash,且变化合理?
- [ ] HTML是否设置了no-cache?
- [ ] 静态资源是否设置了max-age,且没有误加no-store?
- [ ] CDN上是否给HTML设置了短缓存(或遵循源站no-cache)?
- [ ] 第三方库是否做了手动分包,避免业务代码变更导致vendor缓存失效?
- [ ] 老版本静态资源是否保留了至少一个版本的冗余(避免历史页面404)?
- [ ] 是否在预发布环境验证过"新用户首次访问"和"老用户回访"两种场景?
- [ ] 接口数据是否杜绝了不必要的强缓存?
8.3 常见的“缓存误区”一览
做前端缓存策略这三年多,我发现很多团队反复踩坑的点,集中在下面几个误区:
- 误区一:
no-cache等同于不缓存。实际上它依然是缓存,只是每次用之前要发请求验证。 - 误区二:给HTML设置
max-age=86400。这会直接导致"新版本发布后用户一天内都看不到"。 - 误区三:static资源全部设置
no-store。这虽然不会出错,但会让性能优化无从谈起。 - 误区四:依赖URL参数做版本控制。在CDN面前,这个方案太脆弱。
- 误区五:忽略ETag的重要性。只用Last-Modified,在高频发布场景下很容易判断失误。
9. 几个值得尝试的进阶方向
基础缓存策略落地之后,如果想继续压榨性能,可以往这些方向试。它们不一定是每个项目的刚需,但对于部分业务场景能带来巨大体验提升。
一个是流式渲染与关键CSS内联。把首屏渲染必需的CSS内联进HTML,而不是作为外部资源引用,可以砍掉首屏CSS加载的网络往返时间。对于内容型网站,这个优化立竿见影。
另一个是抢占式预加载。通过<link rel="preload">和<link rel="prefetch">,你可以提前加载用户即将访问的页面资源。preload适合当前页面关键资源,prefetch适合下一个可能访问的页面。它的本质是在"用户还没点击前"就把缓存填好。
还有一个方向是基于用户行为的智能缓存。比如对"登录用户"和"未登录用户"的缓存策略做区分;根据用户网络情况(弱网/无网)动态调整Service Worker的缓存策略。这类定制方案虽然复杂一些,但能让缓存体系真正做到"因场景而异"。
我最近在尝试的一个方向是,把缓存策略和前端监控系统打通:每次发布后,自动对比不同地区、不同网络环境下资源的缓存命中率,用数据验证每一次缓存配置的调整。这一套做完之后,性能优化就不再是"凭感觉"了,而是有数据做支撑的工程化能力。
最后,我也想说,缓存策略是前端性能优化中的基本功,也是前端面试题里反复出现的高频考点。但面试只会问'怎么写'是没用的,只有真正在一线项目中踩过坑、排过障,把这些原理和实战经验内化成自己的技能,你才能在真实世界里把一个网站真正做飞起来。希望这篇文章能帮你少走一些我走过的弯路。
