【浏览器】浏览器缓存(Cache-Control 和 Expries 详解)
一、 起源与演进:从绝对时间到相对策略
缓存控制的演进,本质上反映了Web从静态文档网络向复杂动态应用平台发展的过程。
Expires:HTTP/1.0的朴素方案- 诞生背景:在早期Web中,资源主要是静态的HTML、图片。为减少重复下载,
Expires应运而生。它是一个绝对时间戳,告诉浏览器:“在这个时间点之前,你可以放心使用缓存的副本,不用问我。” - 核心缺陷:
- 时钟同步问题:其有效性完全依赖于客户端(用户电脑)和服务器时钟的精确同步。如果用户的电脑时间快了或慢了,缓存行为就会混乱——要么过早失效,要么长期不更新。
- 僵化不灵活:一旦设定一个未来的过期时间(如
Expires: Thu, 31 Dec 2099 23:59:59 GMT),在该时间点之前,浏览器将完全不会与服务器通信。这极不适用于需要定期更新或内容多变的资源。
- 历史地位:它是缓存意识的启蒙,但因其设计上的硬伤,在动态Web时代已基本被淘汰,仅作为向后兼容的备选方案。
- 诞生背景:在早期Web中,资源主要是静态的HTML、图片。为减少重复下载,
HTTP/1.0 200 OK
Expires: Fri, 30 Dec 2022 23:59:59 GMT
Content-Type: text/html
// Node.js Express 示例
const express = require('express');
const app = express();
// 设置静态资源的 Expires 头
app.use('/static', express.static('public', {
setHeaders: (res, path) => {
// 为图片设置 30 天过期
if (path.match(/\.(jpg|jpeg|png|gif)$/)) {
const expiresDate = new Date();
expiresDate.setDate(expiresDate.getDate() + 30);
res.setHeader('Expires', expiresDate.toUTCString());
}
}
}));
Expires 的致命缺陷代码详解:
// 服务器时间:2023-01-01 10:00:00
// 客户端时间:2023-01-01 11:00:00(快1小时)
// 服务器设置
res.setHeader('Expires', 'Sun, 01 Jan 2023 12:00:00 GMT');
// 客户端视角:
// 当前时间(11:00) > 过期时间(12:00) ✗
// 实际应该:还有1小时缓存,但客户端认为已过期
Cache-Control:HTTP/1.1的现代化声明- 设计哲学:为了解决
Expires的弊端,Cache-Control采用了一种声明式、策略化的思路。它不再指定一个固定的“死亡时间”,而是定义一套关于资源如何被缓存、存储、验证的规则。 - 核心优势:
- 相对时间:使用
max-age=指令,以秒为单位定义资源从被请求开始计算的“新鲜寿命”。这完全规避了时钟同步问题。 - 精细控制:它提供了丰富的指令,可以精确控制谁能存(
public/private)、是否允许存(no-store)、使用时是否要问(no-cache/must-revalidate)、过期后如何处理(stale-while-revalidate)等一系列复杂场景。
- 相对时间:使用
- 演进关系:
Cache-Control是Expires的超集和替代者。当两者同时出现在响应头中时,Cache-Control的max-age指令优先级更高,Expires头会被忽略。
- 设计哲学:为了解决
二、 技术原理与浏览器实现内幕
浏览器的缓存系统是一个复杂的决策引擎,其工作原理远不止简单的“过期就删”。
1. 缓存存储的层次结构:
浏览器缓存并非单一存储,而是一个分层体系:
- 内存缓存(Memory Cache):存储当前会话中高频使用的小资源(如CSS、脚本、图片)。其特点是读取极快,但生命周期与标签页绑定,关闭即失。
- 磁盘缓存(Disk Cache):持久化存储,容量大,用于存放从
Cache-Control和Expires推导出可长期保留的资源。 - Service Worker Cache:由前端JavaScript(Service Worker脚本)完全控制的、独立于浏览器HTTP缓存的存储层。开发者可以编写自定义的缓存策略(如“缓存优先”、“网络优先”)。
- HTTP缓存决策逻辑:这是贯穿上述存储层的“大脑”,它解析
Cache-Control、Expires等头部,并决定资源应该进入哪一层、何时失效、何时需要重新验证。
2. 缓存决策的核心算法:
当浏览器发起一个请求时,其决策流程可以用以下伪代码逻辑概括:
function 检查缓存(请求URL, 请求头):
1. 在缓存中查找该URL对应的条目。
2. 如果没找到 -> 直接向服务器发起网络请求。
3. 如果找到缓存条目:
a. 检查 `Cache-Control: no-store` -> 如有,立即**丢弃缓存**,发起网络请求。
b. 计算缓存条目的“年龄”(current_age = 现在时间 - 响应日期)。
c. 计算其“新鲜寿命”(freshness_lifetime):
* 优先级1: 取 `Cache-Control: max-age=N` 的 N 秒。
* 优先级2: 若无,则计算 `Expires - Date` 的值。
* 优先级3: 若以上皆无,浏览器会采用一种“启发式算法”,例如根据 `Last-Modified` 时间,取(当前时间 - 最后修改时间)的 10% 作为寿命。
d. 比较:如果 `年龄 <= 新鲜寿命` -> 缓存**新鲜**,直接使用,**无网络请求**。
e. 如果缓存**过期**(不新鲜):
* 检查 `Cache-Control: no-cache` 或 `must-revalidate` -> 必须向服务器**验证**。
* 检查 `Cache-Control: stale-while-revalidate=N` -> 如果过期时间在 N 秒内,可**先使用旧缓存**,同时在后台验证。
* 否则,如果允许(如无特殊指令),浏览器可能直接使用过期缓存,但通常还是会发起验证。
function 向服务器验证(请求, 缓存条目):
1. 构建一个“条件请求”:
* 如果缓存有 ETag: 添加请求头 `If-None-Match: "etag_value"`
* 如果缓存有 Last-Modified: 添加请求头 `If-Modified-Since: last_modified_date`
2. 发送条件请求到服务器。
3. 服务器响应:
* 304 Not Modified (ETag/时间未变) -> 缓存**有效**,更新其新鲜度,使用它。
* 200 OK (携带新内容) -> 缓存**无效**,使用新响应,并更新缓存。
浏览器缓存决策层伪代码实现
// 浏览器缓存决策伪代码实现
class BrowserCacheSystem {
constructor() {
this.memoryCache = new Map(); // 内存缓存(快,但容量小)
this.diskCache = new DiskCache(); // 磁盘缓存(慢,但容量大)
this.cacheRules = new Map(); // 缓存规则映射
}
async processRequest(request) {
// 1. 检查请求是否可缓存
if (this.shouldSkipCache(request)) {
return await this.fetchFromNetwork(request);
}
// 2. 查找缓存
const cachedResponse = await this.lookupCache(request);
if (!cachedResponse) {
// 缓存未命中
return await this.fetchAndCache(request);
}
// 3. 检查缓存是否新鲜
const cacheStatus = this.validateCacheFreshness(cachedResponse);
switch (cacheStatus) {
case 'FRESH':
// 缓存新鲜,直接使用
return cachedResponse;
case 'STALE_WHILE_REVALIDATE':
// 先返回过期缓存,后台重新验证
this.backgroundRevalidate(request, cachedResponse);
return cachedResponse;
case 'NEEDS_REVALIDATION':
// 需要向服务器验证
return await this.revalidate(request, cachedResponse);
case 'EXPIRED':
// 缓存过期,重新获取
return await this.fetchAndCache(request);
}
}
validateCacheFreshness(cachedResponse) {
const headers = cachedResponse.headers;
const now = Date.now();
// 检查 no-store
if (headers.get('Cache-Control')?.includes('no-store')) {
return 'EXPIRED';
}
// 计算新鲜度生命周期
const maxAge = this.parseMaxAge(headers.get('Cache-Control'));
const expires = this.parseExpires(headers.get('Expires'));
const date = this.parseDate(headers.get('Date'));
// 优先使用 Cache-Control
let freshnessLifetime = maxAge;
if (freshnessLifetime === null) {
freshnessLifetime = expires ? expires - date : null;
}
if (freshnessLifetime === null) {
// 使用启发式算法
const lastModified = this.parseDate(headers.get('Last-Modified'));
if (lastModified) {
freshnessLifetime = Math.max(0, (now - lastModified) * 0.1); // 10%规则
}
}
// 计算当前年龄
const currentAge = this.calculateCurrentAge(cachedResponse, now);
if (currentAge <= freshnessLifetime) {
return 'FRESH';
}
// 检查 stale-while-revalidate
const staleWhileRevalidate = this.parseStaleWhileRevalidate(
headers.get('Cache-Control')
);
if (staleWhileRevalidate &&
(currentAge - freshnessLifetime) <= staleWhileRevalidate) {
return 'STALE_WHILE_REVALIDATE';
}
// 检查 must-revalidate
if (headers.get('Cache-Control')?.includes('must-revalidate')) {
return 'NEEDS_REVALIDATION';
}
return 'EXPIRED';
}
}
条件请求机制
当缓存需要验证时,浏览器会发送"条件请求"。这是 HTTP 缓存机制中最精妙的部分:
// 条件请求处理流程
class ConditionalRequestHandler {
async revalidate(request, cachedResponse) {
const headers = cachedResponse.headers;
const validationHeaders = {};
// 1. 准备验证头
const etag = headers.get('ETag');
if (etag) {
validationHeaders['If-None-Match'] = etag;
}
const lastModified = headers.get('Last-Modified');
if (lastModified) {
validationHeaders['If-Modified-Since'] = lastModified;
}
// 2. 发送条件请求
const validationRequest = new Request(request, {
headers: { ...request.headers, ...validationHeaders }
});
try {
const response = await fetch(validationRequest);
if (response.status === 304) {
// 3. 304 Not Modified - 缓存仍然有效
this.updateCacheMetadata(cachedResponse, response.headers);
return cachedResponse;
} else if (response.ok) {
// 4. 200 OK - 有更新内容
await this.storeInCache(request, response);
return response;
} else {
// 5. 错误处理
throw new Error(`Validation failed: ${response.status}`);
}
} catch (error) {
// 网络错误,检查是否可以使用过期缓存
if (this.canUseStaleOnError(cachedResponse)) {
return cachedResponse;
}
throw error;
}
}
canUseStaleOnError(cachedResponse) {
const headers = cachedResponse.headers;
const cacheControl = headers.get('Cache-Control') || '';
// 检查 stale-if-error
const staleIfErrorMatch = cacheControl.match(/stale-if-error=(\d+)/);
if (staleIfErrorMatch) {
const staleIfError = parseInt(staleIfErrorMatch[1], 10);
const age = this.calculateAge(cachedResponse);
const maxAge = this.parseMaxAge(cacheControl) || 0;
if (age <= maxAge + staleIfError) {
return true;
}
}
return false;
}
}
这个决策树解释了为何一个配置了 max-age=3600 但带有 ETag 的资源,在1小时内请求完全无网络流量,而在1小时后请求会产生一个很小的304或200响应。
3. 源码层面的印证:
以Chromium项目为例,其网络栈(net/ 目录)中包含了完整的缓存实现。关键文件如 http_cache_transaction.cc 中的 ValidateCacheEntry 函数,正是上述决策逻辑的代码体现。它会逐条检查 Cache-Control 的各个指令(no-store, max-age, must-revalidate 等),并计算新鲜度。disk_cache/ 目录下的代码则管理着缓存条目在磁盘上的存储、查找和淘汰(如LRU算法)。阅读这些源码,能最深刻地理解规范是如何被严格转化为浏览器行为的。
三、 应用场景与配置哲学
理解原理是为了更好的应用。缓存配置不应是随意的,而应基于资源特性形成战略。
-
静态资源(长期不变):
- 特征:带有哈希指纹的文件(如
main.a1b2c3d4.js,styles.8e9f0g1h.css)。文件内容变,哈希值必变,URL也就变了。 - 策略:激进缓存。这是缓存收益最高的地方。
- 配置示例:
Cache-Control: public, max-age=31536000, immutable - 解读:
public允许所有中间缓存(CDN)存储;max-age=一年设定极长新鲜期;immutable是“承诺”,告诉浏览器在一年内此资源永不改变,即使用户刷新页面,也无需发送条件请求验证。这彻底消除了不必要的304请求。
- 特征:带有哈希指纹的文件(如
-
动态内容(用户相关、实时性高):
- 特征:HTML页面主体、API接口返回的个性化数据。
- 策略:禁用或谨慎缓存。
- 配置示例(API):
Cache-Control: no-cache或Cache-Control: private, max-age=0, must-revalidate - 解读:
no-cache并非不缓存,而是“缓存但要每次验证”。private确保包含用户信息的响应不会被共享代理缓存。max-age=0表示立即过期,强制验证。这保证了数据的实时性和隐私性。
-
可缓存的动态内容(通用、更新有规律):
- 特征:新闻列表、商品目录、非实时的API数据。
- 策略:短期缓存 + 后台更新。
- 配置示例:
Cache-Control: public, max-age=60, stale-while-revalidate=300 - 解读:
max-age=60保证一分钟内的用户获得极快响应。在60秒至360秒(5分钟)期间,用户请求会立即获得已过期的(stale)缓存,同时浏览器在后台默默发起重新验证并更新缓存。这实现了速度与新鲜度的绝佳平衡,是“渐进式缓存”的典范。
四、 安全、隐私与性能的平衡
缓存配置是双刃剑,用得好极大提升性能,用不好会导致安全漏洞和用户体验问题。
-
安全与隐私考量:
- 信息泄露:错误的将包含用户个人信息的响应头设为
public,可能导致该信息被缓存在代理服务器或CDN上,被其他用户访问到。 - 缓存投毒:攻击者可能通过操纵缓存键或利用缓存机制漏洞,将恶意内容注入缓存,影响其他用户。
- 最佳实践:
- 对个性化内容始终使用
private。 - 配合
Vary头(如Vary: Cookie, Authorization),确保根据请求头的不同(如登录状态)缓存不同的版本。 - 对绝对不可泄露的内容使用
no-store,它指令浏览器和所有中间缓存都不保留任何副本。
- 对个性化内容始终使用
- 信息泄露:错误的将包含用户个人信息的响应头设为
-
性能与一致性:
- “缓存破坏”技术:为了享受长期缓存的好处,又需要更新资源,必须改变资源的URL。这就是前端构建工具(如Webpack、Vite)为文件名添加哈希摘要的原因。内容变,哈希变,URL变,浏览器就会视其为全新资源去下载。
- 版本控制:对于API,可以通过在URL或请求头中嵌入版本号来管理缓存。
五、 总结:从指令到心智模型
掌握 Cache-Control 和 Expires,关键在于建立起一套正确的 “缓存心智模型”:
- 摒弃“缓存开关”思维:缓存不是简单的“开”或“关”,而是一套关于 新鲜度、验证、存储和共享 的丰富规则。
- 策略化配置:根据资源的变化频率、个性化程度和重要性来分类,并施以不同的缓存策略。形成如“版本化静态资源永缓存、HTML不缓存、API短缓存加验证”的层次化策略。
- 理解验证流程:认识到
no-cache、ETag、Last-Modified、304状态码这一套“条件请求”机制,是平衡性能与数据一致性的核心手段。 - 善用现代指令:积极采用
stale-while-revalidate、immutable等现代指令,它们能显著提升感知性能和用户体验。 - 始终考虑安全边界:在追求性能的同时,时刻警惕缓存可能带来的隐私泄露和安全风险。
本文来自博客园,作者:NeoLshu,转载请注明原文链接:https://www.cnblogs.com/neolshu/p/19513651

浙公网安备 33010602011771号