Cache API 高级策略:Stale-while-revalidate 的手动实现
各位开发者朋友,大家好!今天我们来深入探讨一个在现代 Web 应用中非常实用但又常被忽视的缓存策略——Stale-while-Revalidate(过期后仍可验证)。它是一种“既保证性能、又保障数据新鲜度”的高级缓存机制,特别适用于对实时性要求不高但又不能完全依赖旧数据的场景。
我们不会只停留在理论层面,而是会通过 手动实现的方式,一步步带你从零构建一个支持 Stale-while-Revalidate 的缓存系统,并结合真实代码演示如何在浏览器或 Node.js 环境下使用它。
一、什么是 Stale-while-Revalidate?
定义与原理
Stale-while-Revalidate 是 HTTP 协议中的一个缓存指令(HTTP Cache-Control header),其含义是:
允许使用过期的缓存内容响应请求,同时后台自动发起更新请求以获取最新版本的数据。
换句话说:
- 如果缓存未过期 → 直接返回缓存;
- 如果缓存已过期 → 先返回旧数据(用户无感知),再异步拉取新数据并替换缓存。
这种策略非常适合以下场景:
- 用户首次加载页面时希望快速响应;
- 后台可以容忍短暂延迟更新(如新闻列表、商品信息等);
- 减少服务器压力,提升用户体验。
对比常见缓存策略
| 缓存策略 | 响应行为 | 数据新鲜度 | 性能表现 |
|---|---|---|---|
| Fresh Only(仅新鲜) | 只有未过期才返回 | 最高 | 较慢(需等待服务器) |
| Stale-while-Revalidate | 过期也返回 + 异步刷新 | 中等(有延迟) | 极快(本地缓存) |
| No-Cache | 每次都查服务器 | 最高 | 最慢(每次都网络请求) |
✅ Stale-while-Revalidate 是一种“权衡”型策略,在性能和准确性之间找到了最佳平衡点。
二、为什么需要手动实现?
虽然现代浏览器原生支持 Cache-Control: stale-while-revalidate,但在实际项目中你可能会遇到这些限制:
- 服务端不支持该 Header(比如老旧接口);
- 你需要更细粒度控制(比如缓存失效时间、重试逻辑);
- 想在 Node.js 或 SSR 中复用逻辑;
- 想自定义缓存键、存储方式(localStorage / Redis / 内存)。
因此,手动实现不仅能让你理解底层机制,还能灵活适配各种业务需求。
三、设计思路与核心组件
我们要实现一个通用的缓存管理器,包含以下功能模块:
| 模块 | 功能描述 |
|---|---|
| Cache Storage | 存储缓存对象(key, value, timestamp, ttl) |
| Get With Stale | 获取缓存数据,若过期则返回旧值并触发更新 |
| Update Background | 后台拉取最新数据并写回缓存 |
| Expiration Check | 判断是否过期(基于 TTL) |
我们将使用 JavaScript 实现这个系统,兼容浏览器和 Node.js(Node.js 版本可用 process.env.NODE_ENV 区分环境)。
四、完整代码实现(带注释)
class StaleWhileRevalidateCache {
constructor(options = {}) {
this.storage = options.storage || window.localStorage; // 默认用 localStorage
this.ttlSeconds = options.ttlSeconds || 60 * 5; // 默认 5 分钟
this.maxRetries = options.maxRetries || 3;
this.retryDelayMs = options.retryDelayMs || 1000;
}
/**
* 设置缓存项
* @param {string} key - 缓存键
* @param {*} value - 缓存值(任意类型)
* @returns {void}
*/
async set(key, value) {
const entry = {
value,
timestamp: Date.now(),
ttl: this.ttlSeconds
};
try {
this.storage.setItem(key, JSON.stringify(entry));
} catch (e) {
console.warn('Failed to store cache:', e.message);
}
}
/**
* 获取缓存项(支持 stale-while-revalidate)
* @param {string} key - 缓存键
* @param {Function} fetchFn - 获取新数据的函数(用于更新缓存)
* @returns {Promise<any>} 返回缓存数据(可能为过期数据)
*/
async get(key, fetchFn) {
const raw = this.storage.getItem(key);
if (!raw) return null;
const entry = JSON.parse(raw);
const now = Date.now();
const isExpired = now - entry.timestamp > entry.ttl * 1000;
// 如果未过期,直接返回
if (!isExpired) {
return entry.value;
}
// 已过期:先返回旧数据,再异步更新
const staleData = entry.value;
// 启动后台更新任务(非阻塞)
this._updateInBackground(key, fetchFn).catch(err => {
console.error(`Failed to update cache for key "${key}":`, err.message);
});
return staleData;
}
/**
* 后台更新缓存(异步执行)
* @private
*/
async _updateInBackground(key, fetchFn) {
let retryCount = 0;
while (retryCount < this.maxRetries) {
try {
const newValue = await fetchFn(); // 调用外部 API 获取最新数据
await this.set(key, newValue);
return; // 成功就退出
} catch (err) {
retryCount++;
if (retryCount >= this.maxRetries) {
console.error(`Max retries reached for key "${key}"`);
break;
}
await new Promise(resolve => setTimeout(resolve, this.retryDelayMs * retryCount));
}
}
}
/**
* 清除指定缓存
*/
clear(key) {
this.storage.removeItem(key);
}
/**
* 清空所有缓存(调试用)
*/
clearAll() {
this.storage.clear();
}
}
五、使用示例(浏览器环境)
假设我们有一个 API 接口 /api/news 返回最新的新闻列表,我们希望:
- 第一次加载时快速显示缓存内容;
- 同时在后台拉取最新新闻;
- 若失败也不影响前端展示。
// 创建缓存实例(TTL=30秒)
const cache = new StaleWhileRevalidateCache({
ttlSeconds: 30,
maxRetries: 2
});
async function fetchNews() {
const res = await fetch('/api/news');
if (!res.ok) throw new Error('Network error');
return res.json();
}
// 使用缓存获取新闻
async function loadNews() {
const news = await cache.get('news', fetchNews);
if (news) {
renderNews(news); // 渲染到 DOM
console.log('✅ 使用缓存数据渲染完成');
} else {
console.log('⚠️ 缓存不存在,等待后台加载...');
}
}
// 模拟用户点击刷新按钮
document.getElementById('refresh-btn').addEventListener('click', () => {
cache.clear('news'); // 手动清除缓存测试效果
loadNews();
});
此时你会发现:
- 第一次访问 → 快速显示旧数据(即使过期);
- 页面不会卡顿,因为异步更新;
- 后台成功拉取新数据后,下次调用将拿到最新结果。
六、Node.js 版本改造(适合 SSR/API)
如果你在 Express 或 NestJS 中做服务端渲染(SSR),也可以用同样的逻辑:
// node-cache.js
const fs = require('fs').promises;
const path = require('path');
class NodeStaleCache {
constructor(storagePath = './cache.json') {
this.storagePath = storagePath;
this.ttlSeconds = 60 * 5;
}
async readStorage() {
try {
const data = await fs.readFile(this.storagePath, 'utf8');
return JSON.parse(data);
} catch (err) {
return {};
}
}
async writeStorage(data) {
await fs.writeFile(this.storagePath, JSON.stringify(data, null, 2));
}
async set(key, value) {
const storage = await this.readStorage();
storage[key] = {
value,
timestamp: Date.now(),
ttl: this.ttlSeconds
};
await this.writeStorage(storage);
}
async get(key, fetchFn) {
const storage = await this.readStorage();
const entry = storage[key];
if (!entry) return null;
const now = Date.now();
const isExpired = now - entry.timestamp > entry.ttl * 1000;
if (!isExpired) {
return entry.value;
}
// 返回旧数据,后台更新
const staleData = entry.value;
this._updateInBackground(key, fetchFn).catch(console.error);
return staleData;
}
async _updateInBackground(key, fetchFn) {
try {
const newValue = await fetchFn();
await this.set(key, newValue);
} catch (err) {
console.error(`Update failed for ${key}:`, err.message);
}
}
}
这样就可以在 Node.js 中实现类似浏览器的行为,尤其适合 API 网关或中间件层缓存。
七、进阶优化建议
1. 添加缓存命中统计(可用于监控)
this.hitCount = 0;
this.missCount = 0;
// 在 get 方法中增加计数逻辑
if (!isExpired) {
this.hitCount++;
return entry.value;
} else {
this.missCount++;
...
}
2. 支持不同 TTL(按 key 精细化配置)
set(key, value, ttlSeconds = this.ttlSeconds) {
// 存储时带上 TTL
}
3. 加入缓存锁防止并发更新(避免多个请求同时触发更新)
const locks = new Map();
async _updateInBackground(key, fetchFn) {
if (locks.has(key)) return; // 已有更新任务,跳过
locks.set(key, true);
try {
// 更新逻辑...
} finally {
locks.delete(key);
}
}
八、总结与思考
今天我们从理论出发,亲手实现了 Stale-while-Revalidate 缓存策略的核心逻辑,不仅加深了对 HTTP 缓存机制的理解,还掌握了如何将其应用到实际开发中。
✅ 这种策略的优势在于:
- 提升首屏加载速度;
- 减少不必要的网络请求;
- 用户体验平滑过渡;
- 易于扩展(支持多种存储介质、TTL 控制等);
🚫 但它也有局限性:
- 不适合强一致性场景(如支付金额、订单状态);
- 失败重试机制必须合理设计(否则可能雪崩);
- 需要明确区分哪些资源适合用此策略。
📌 最佳实践建议:
- 对静态内容(如文章、商品详情)优先采用;
- 结合 ETag 或 Last-Modified 做条件请求进一步优化;
- 在关键路径上加入日志埋点,便于排查问题。
如果你想进一步研究,推荐阅读:
希望这篇文章对你有所帮助!欢迎留言交流你的应用场景或改进建议。感谢收听!