JavaScript 性能优化系列(五):内存优化
5.3 缓存策略调整:合理控制缓存大小和有效期
缓存是前端性能优化的「双刃剑」——合理的缓存能将重复访问的资源加载时间缩短80%以上,而失控的缓存会成为内存泄漏的「重灾区」。在前端开发中,缓存失控主要表现为两类问题:
- 缓存大小失控:无限存储数据导致内存/存储容量耗尽(如localStorage存满大对象、内存缓存无限制增长);
- 缓存有效期失控:缓存数据长期不清理,导致「脏数据」(过期数据)干扰业务,或缓存未及时更新引发数据一致性问题。
本节将从「原理解析→策略设计→代码实现→团队落地」四个维度,系统讲解如何通过「大小控制」和「有效期管理」实现「收益最大化、成本最小化」的缓存方案,同时规避内存泄漏风险。
5.3.1 前端缓存的核心原理与失控风险
要合理控制缓存,首先需明确前端常见缓存的特性差异——不同缓存类型的容量、生命周期、读写性能截然不同,错误选型是缓存失控的首要原因。
5.3.1.1 前端常见缓存类型及特性对比
前端缓存可分为「内存级缓存」和「持久化缓存」两大类,每类包含多种实现,其核心特性对比如下:
| 缓存类型 | 存储位置 | 容量限制 | 生命周期 | 读写性能 | 适用场景 | 风险点 |
|---|---|---|---|---|---|---|
| 自定义内存缓存 | 内存(堆) | 无明确限制(受内存影响) | 页面刷新/进程关闭后失效 | 极快(μs级) | 高频访问的临时数据(如接口响应、组件状态) | 无限缓存导致内存泄漏、GC压力增大 |
sessionStorage | 磁盘 | 约5MB/域名 | 会话结束(标签页关闭) | 较快(ms级) | 会话级临时数据(如表单草稿、临时筛选条件) | 存大对象导致存储满、未清理残留数据 |
localStorage | 磁盘 | 约5MB/域名 | 永久(需手动清理) | 较快(ms级) | 低频访问的持久数据(如用户偏好、配置信息) | 无限存储导致存储满、数据过期未更新 |
| IndexedDB | 磁盘 | 无明确限制(受磁盘影响) | 永久(需手动清理) | 中(10-100ms) | 大量结构化数据(如离线缓存、历史记录) | 未清理旧数据导致磁盘占用过高 |
React.useMemo/Vue.computed | 内存(堆) | 组件生命周期内 | 组件卸载后失效 | 极快(μs级) | 组件内高频计算结果缓存(如列表过滤结果) | 滥用导致内存冗余、计算逻辑冗余 |
核心结论:
- 内存级缓存(自定义内存缓存、
useMemo)适合「高频临时数据」,但需严格控制大小,避免内存泄漏; - 持久化缓存(
localStorage、IndexedDB)适合「低频持久数据」,但需管理有效期,避免数据过期; - 任何缓存若不控制「大小」和「有效期」,都会从「性能加速器」沦为「内存/存储负担」。
5.3.1.2 缓存失控的三大核心问题
前端缓存失控会引发一系列连锁反应,具体表现为:
问题1:无限缓存导致内存/存储耗尽
- 内存缓存:若自定义内存缓存(如全局
cache对象)无容量限制,持续存储大对象(如10万条商品数据),会导致内存占用持续增长,触发浏览器内存预警,甚至页面崩溃; - 持久化缓存:
localStorage若无限存储数据(如每次请求都缓存接口响应),会很快达到5MB容量限制,导致后续缓存失败,甚至影响其他依赖localStorage的功能。
问题2:有效期缺失导致数据一致性问题
- 数据过期:缓存数据未设置有效期(如缓存用户信息后,用户修改昵称但缓存未更新),会导致页面显示旧数据,引发业务逻辑错误;
- 脏数据残留:会话级缓存(如
sessionStorage)未在会话结束前清理,若用户复用标签页,会导致旧数据干扰新会话。
问题3:缓存策略与数据特性不匹配
- 用
localStorage存储高频更新的大对象(如实时股价):localStorage读写性能有限,且无自动失效机制,导致数据更新不及时、内存占用过高; - 用内存缓存存储低频持久数据(如用户配置):页面刷新后缓存丢失,需重新请求,失去缓存意义。
5.3.2 缓存大小控制:避免无限增长的核心策略
缓存大小控制的核心目标是「在保证缓存命中率的前提下,将内存/存储占用控制在安全范围」,常见策略包括「容量限制算法」「场景化容量分配」「动态容量调整」三类。
5.3.2.1 经典缓存容量控制算法:LRU与LFU
当缓存达到预设容量时,需通过算法淘汰「价值最低」的缓存项,常见的淘汰算法中,LRU(最近最少使用) 和 LFU(最不经常使用) 最适合前端场景。
1. LRU(最近最少使用):淘汰最久未访问的缓存项
原理:假设「最近访问过的缓存项,未来被访问的概率更高」,当缓存满时,淘汰最久未访问的项。
适用场景:高频临时数据(如接口响应、组件渲染结果),访问模式符合「近期访问优先」。
代码实现(符合谷歌JS规范):
/**
* LRU缓存类:控制内存缓存大小,淘汰最久未访问的项
* @template K - 缓存键类型
* @template V - 缓存值类型
*/
class LRUCache {
/**
* 初始化LRU缓存
* @param {number} maxSize - 最大缓存容量(默认100)
*/
constructor(maxSize = 100) {
if (maxSize < 1) throw new Error('maxSize必须大于0');
this.maxSize = maxSize;
// 用Map存储缓存(Map的迭代顺序是插入顺序,便于维护访问顺序)
this.cache = new Map();
}
/**
* 存入缓存:若容量满,淘汰最久未访问的项
* @param {K} key - 缓存键
* @param {V} value - 缓存值
* @param {number} [ttl] - 可选:缓存有效期(毫秒)
*/
set(key, value, ttl) {
// 1. 若键已存在,先删除(保证重新插入后成为最新访问项)
if (this.cache.has(key)) {
this.cache.delete(key);
}
// 2. 存入缓存(包含值和过期时间)
this.cache.set(key, {
value,
expireTime: ttl ? Date.now() + ttl : Infinity // 无ttl则永久有效
});
// 3. 若超过最大容量,淘汰最久未访问的项(Map的第一个元素是最久未访问)
if (this.cache.size > this.maxSize) {
const oldestKey = this.cache.keys().next().value;
this.cache.delete(oldestKey);
console.log(`LRU缓存:淘汰最久未访问项,key=${String(oldestKey)}`);
}
}
/**
* 获取缓存:若过期或不存在,返回null
* @param {K} key - 缓存键
* @returns {V|null} 缓存值(过期/不存在则返回null)
*/
get(key) {
if (!this.cache.has(key)) return null;
// 1. 获取缓存项,检查是否过期
const entry = this.cache.get(key);
const now = Date.now();
if (entry.expireTime < now) {
this.cache.delete(key);
console.log(`LRU缓存:项已过期,key=${String(key)}`);
return null;
}
// 2. 重新插入,更新访问顺序(成为最新访问项)
this.cache.delete(key);
this.cache.set(key, entry);
return entry.value;
}
/**
* 删除指定缓存项
* @param {K} key - 缓存键
*/
delete(key) {
this.cache.delete(key);
}
/**
* 清空所有缓存
*/
clear() {
this.cache.clear();
console.log('LRU缓存:已清空所有项');
}
/**
* 获取当前缓存大小
* @returns {number} 缓存项数量
*/
getSize() {
return this.cache.size;
}
}
// --------------- 使用示例 ---------------
// 1. 初始化容量为10的LRU缓存,用于存储接口响应
const apiCache = new LRUCache(10);
// 2. 存入缓存(设置5分钟有效期)
apiCache.set('products:page1', [{ id: 1, name: '无线耳机' }, /* ... */], 5 * 60 * 1000);
// 3. 获取缓存(自动更新访问顺序)
const products = apiCache.get('products:page1');
if (products) {
console.log('从缓存获取商品列表,长度:', products.length);
}
// 4. 模拟缓存满时淘汰
for (let i = 0; i < 15; i++) {
apiCache.set(`key${i}`, `value${i}`);
}
console.log('缓存满后大小:', apiCache.getSize()); // 输出10(淘汰了key0-key4)
2. LFU(最不经常使用):淘汰访问频率最低的缓存项
原理:假设「访问频率高的缓存项,未来被访问的概率更高」,通过统计访问次数,淘汰频率最低的项。
适用场景:访问频率差异明显的数据(如热门商品、高频查询接口)。
代码实现:
/**
* LFU缓存类:控制内存缓存大小,淘汰访问频率最低的项
* @template K - 缓存键类型
* @template V - 缓存值类型
*/
class LFUCache {
/**
* 初始化LFU缓存
* @param {number} maxSize - 最大缓存容量(默认100)
*/
constructor(maxSize = 100) {
if (maxSize < 1) throw new Error('maxSize必须大于0');
this.maxSize = maxSize;
this.cache = new Map(); // 存储缓存项:key → { value, count, expireTime }
this.freqMap = new Map(); // 存储频率映射:频率 → Set<key>
this.minFreq = 1; // 当前最小访问频率(优化淘汰效率)
}
/**
* 更新缓存项的访问频率
* @param {K} key - 缓存键
*/
#updateFreq(key) {
const entry = this.cache.get(key);
if (!entry) return;
// 1. 从原频率集合中删除
const oldFreq = entry.count;
const oldFreqSet = this.freqMap.get(oldFreq);
oldFreqSet.delete(key);
// 2. 若原频率是最小频率且集合为空,更新最小频率
if (oldFreq === this.minFreq && oldFreqSet.size === 0) {
this.minFreq = oldFreq + 1;
}
// 3. 增加访问频率,加入新频率集合
const newFreq = oldFreq + 1;
entry.count = newFreq;
if (!this.freqMap.has(newFreq)) {
this.freqMap.set(newFreq, new Set());
}
this.freqMap.get(newFreq).add(key);
}
/**
* 存入缓存:若容量满,淘汰访问频率最低的项
* @param {K} key - 缓存键
* @param {V} value - 缓存值
* @param {number} [ttl] - 可选:缓存有效期(毫秒)
*/
set(key, value, ttl) {
if (this.cache.has(key)) {
// 键已存在:更新值和频率
this.cache.set(key, {
value,
count: this.cache.get(key).count, // 保留原频率
expireTime: ttl ? Date.now() + ttl : Infinity
});
this.#updateFreq(key);
return;
}
// 容量满:淘汰访问频率最低的项(从minFreq对应的集合中取第一个)
if (this.cache.size >= this.maxSize) {
const minFreqSet = this.freqMap.get(this.minFreq);
const leastFreqKey = minFreqSet.keys().next().value;
// 删除缓存项和频率映射
minFreqSet.delete(leastFreqKey);
this.cache.delete(leastFreqKey);
console.log(`LFU缓存:淘汰频率最低项,key=${String(leastFreqKey)}`);
}
// 存入新缓存项(初始频率1)
const newFreq = 1;
this.cache.set(key, {
value,
count: newFreq,
expireTime: ttl ? Date.now() + ttl : Infinity
});
if (!this.freqMap.has(newFreq)) {
this.freqMap.set(newFreq, new Set());
}
this.freqMap.get(newFreq).add(key);
this.minFreq = newFreq; // 新项频率为1,更新最小频率
}
/**
* 获取缓存:若过期或不存在,返回null
* @param {K} key - 缓存键
* @returns {V|null} 缓存值
*/
get(key) {
if (!this.cache.has(key)) return null;
const entry = this.cache.get(key);
const now = Date.now();
// 检查是否过期
if (entry.expireTime < now) {
// 清理过期项的频率映射
const freq = entry.count;
this.freqMap.get(freq).delete(key);
this.cache.delete(key);
console.log(`LFU缓存:项已过期,key=${String(key)}`);
return null;
}
// 更新访问频率
this.#updateFreq(key);
return entry.value;
}
/**
* 清空缓存
*/
clear() {
this.cache.clear();
this.freqMap.clear();
this.minFreq = 1;
console.log('LFU缓存:已清空所有项');
}
/**
* 获取当前缓存大小
* @returns {number} 缓存项数量
*/
getSize() {
return this.cache.size;
}
}
// --------------- 使用示例 ---------------
const hotProductCache = new LFUCache(5);
// 存入缓存(模拟热门商品访问)
hotProductCache.set('product:1', { id: 1, name: '无线耳机' });
hotProductCache.set('product:2', { id: 2, name: '智能手表' });
// 高频访问product:1
hotProductCache.get('product:1');
hotProductCache.get('product:1');
// 继续存入3个项,触发淘汰(淘汰频率最低的product:2)
hotProductCache.set('product:3', { id: 3, name: '充电宝' });
hotProductCache.set('product:4', { id: 4, name: '数据线' });
hotProductCache.set('product:5', { id: 5, name: '耳机壳' });
// 输出:LFU缓存:淘汰频率最低项,key=product:2
5.3.2.2 场景化缓存容量分配
不同类型的缓存(内存、localStorage、IndexedDB)容量限制不同,需结合场景分配合理容量:
1. 内存缓存(自定义缓存、useMemo)
- 容量建议:不超过当前页面内存占用的20%(如页面初始内存200MB,内存缓存控制在40MB以内);
- 分配原则:
- 高频临时数据(如列表过滤结果):容量设为「单次最大数据量×5」(如每次过滤返回20条数据,容量设为100条);
- 组件内缓存(如
useMemo):跟随组件生命周期,组件卸载时自动清理,无需额外容量限制。
React组件内存缓存示例:
import React, { useMemo, useState, useEffect } from 'react';
import { LRUCache } from './LRUCache';
// 初始化LRU缓存(容量10,存储商品列表过滤结果)
const productFilterCache = new LRUCache(10);
const ProductList = () => {
const [products, setProducts] = useState([]);
const [filter, setFilter] = useState({ category: 'all', priceRange: [0, 1000] });
// 用useMemo缓存过滤结果,同时结合LRU控制全局缓存大小
const filteredProducts = useMemo(() => {
// 生成缓存键(基于过滤条件)
const cacheKey = `filter:${JSON.stringify(filter)}`;
// 1. 尝试从LRU缓存获取
const cachedResult = productFilterCache.get(cacheKey);
if (cachedResult) {
console.log('从LRU缓存获取过滤结果');
return cachedResult;
}
// 2. 无缓存则计算,并存入LRU缓存(设置10分钟有效期)
const result = products.filter(item => {
const matchesCategory = filter.category === 'all' || item.category === filter.category;
const matchesPrice = item.price >= filter.priceRange[0] && item.price <= filter.priceRange[1];
return matchesCategory && matchesPrice;
});
productFilterCache.set(cacheKey, result, 10 * 60 * 1000);
console.log('过滤结果存入LRU缓存');
return result;
}, [products, filter]);
// 组件卸载时清理缓存(避免内存泄漏)
useEffect(() => {
return () => {
productFilterCache.clear();
};
}, []);
return (
<div className="product-list">
{/* 过滤控件 */}
<div className="filter-controls">
{/* 分类筛选 */}
<select
value={filter.category}
onChange={(e) => setFilter({ ...filter, category: e.target.value })}
>
<option value="all">全部分类</option>
<option value="electronics">电子产品</option>
<option value="clothing">服装</option>
</select>
{/* 价格筛选 */}
{/* ... */}
</div>
{/* 商品列表 */}
<div className="items">
{filteredProducts.map(item => (
<div key={item.id} className="product-item">
<h3>{item.name}</h3>
<p>¥{item.price}</p>
</div>
))}
</div>
</div>
);
};
export default ProductList;
2. 持久化缓存(localStorage/sessionStorage)
- 容量限制:严格控制在5MB以内(浏览器标准容量),建议预留20%空间(实际使用不超过4MB);
- 分配原则:
- 按数据类型分配:用户配置(100KB)、历史记录(1MB)、临时表单(500KB);
- 避免存储大对象(如超过100KB的数组),改用IndexedDB。
localStorage容量控制封装:
/**
* 带容量控制的localStorage封装
*/
const LocalStorage = {
/**
* 计算localStorage当前占用大小(字节)
* @returns {number} 占用大小(字节)
*/
#getUsedSize() {
let size = 0;
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
const value = localStorage.getItem(key);
// 每个键值对的大小 = 键长度 + 值长度(UTF-16编码,每个字符2字节)
size += (key.length + value.length) * 2;
}
return size;
},
/**
* 检查容量是否充足
* @param {string} value - 待存储的值
* @returns {boolean} 是否充足
*/
#isCapacityEnough(value) {
const maxSize = 4 * 1024 * 1024; // 4MB(预留1MB空间)
const valueSize = value.length * 2;
const usedSize = this.#getUsedSize();
return usedSize + valueSize <= maxSize;
},
/**
* 清理最旧的缓存项(按存储时间排序)
* @param {number} needFreeSize - 需要释放的空间(字节)
*/
#cleanOldItems(needFreeSize) {
// 1. 获取所有缓存项及存储时间(假设key包含时间戳,如"user:1620000000000")
const items = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
const value = localStorage.getItem(key);
// 从key提取时间戳(需约定key格式:"prefix:timestamp:id")
const timestampMatch = key.match(/:(\d+):/);
const timestamp = timestampMatch ? Number(timestampMatch[1]) : 0;
items.push({ key, value, timestamp, size: (key.length + value.length) * 2 });
}
// 2. 按时间戳排序(旧→新),依次删除直到释放足够空间
let freedSize = 0;
items.sort((a, b) => a.timestamp - b.timestamp).forEach(item => {
if (freedSize >= needFreeSize) return;
localStorage.removeItem(item.key);
freedSize += item.size;
console.log(`localStorage:清理旧项,key=${item.key},释放空间=${item.size}字节`);
});
// 3. 若仍无法释放足够空间,抛出错误
if (freedSize < needFreeSize) {
throw new Error('localStorage容量不足,无法清理足够空间');
}
},
/**
* 存入localStorage(带容量控制)
* @param {string} key - 缓存键(建议格式:"prefix:timestamp:id")
* @param {any} value - 缓存值(自动JSON.stringify)
* @param {number} [ttl] - 有效期(毫秒,过期后get时自动删除)
*/
set(key, value, ttl) {
try {
// 1. 序列化值(处理循环引用)
let valueStr;
try {
valueStr = JSON.stringify({
data: value,
expireTime: ttl ? Date.now() + ttl : Infinity
});
} catch (error) {
throw new Error(`值序列化失败:${error.message}`);
}
// 2. 检查容量,不足则清理
if (!this.#isCapacityEnough(valueStr)) {
const needFreeSize = (valueStr.length * 2) - (4 * 1024 * 1024 - this.#getUsedSize());
this.#cleanOldItems(needFreeSize);
}
// 3. 存入localStorage
localStorage.setItem(key, valueStr);
console.log(`localStorage:存入成功,key=${key}`);
} catch (error) {
console.error('localStorage存入失败:', error);
throw error;
}
},
/**
* 从localStorage获取值
* @param {string} key - 缓存键
* @returns {any|null} 缓存值(自动JSON.parse,过期返回null)
*/
get(key) {
try {
const valueStr = localStorage.getItem(key);
if (!valueStr) return null;
// 解析值
const { data, expireTime } = JSON.parse(valueStr);
const now = Date.now();
// 检查是否过期
if (expireTime < now) {
localStorage.removeItem(key);
console.log(`localStorage:项已过期,key=${key}`);
return null;
}
return data;
} catch (error) {
console.error('localStorage获取失败:', error);
localStorage.removeItem(key); // 清理损坏的缓存
return null;
}
},
/**
* 删除指定项
* @param {string} key - 缓存键
*/
remove(key) {
localStorage.removeItem(key);
},
/**
* 清空所有项
*/
clear() {
localStorage.clear();
}
};
// --------------- 使用示例 ---------------
// 存入用户配置(设置7天有效期)
LocalStorage.set(
`userConfig:${Date.now()}:123`, // key格式:prefix:timestamp:userId
{ theme: 'dark', fontSize: 16 },
7 * 24 * 60 * 60 * 1000
);
// 获取用户配置
const userConfig = LocalStorage.get('userConfig:1620000000000:123');
if (userConfig) {
console.log('用户配置:', userConfig);
}
3. IndexedDB:大容量数据的分段缓存
IndexedDB无明确容量限制,但需避免一次性存储过多数据导致磁盘占用过高,策略如下:
- 分段存储:将大数据集(如10万条历史记录)按时间分段(如每月一段),每段单独存储;
- 过期清理:定期删除超过保留期的分段(如只保留最近6个月数据);
- 游标查询:避免一次性读取所有数据,用游标逐段查询,减少内存占用。
IndexedDB分段缓存示例:
/**
* IndexedDB分段缓存:存储大量历史记录
*/
class IndexedDBHistoryCache {
constructor(dbName = 'HistoryDB', storeName = 'historySegments', maxRetentionMonths = 6) {
this.dbName = dbName;
this.storeName = storeName;
this.maxRetentionMonths = maxRetentionMonths;
this.db = null;
}
/**
* 打开数据库
* @returns {Promise<IDBDatabase>} 数据库实例
*/
#openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, 1);
// 数据库升级(首次创建或版本更新)
request.onupgradeneeded = (event) => {
const db = event.target.result;
// 创建存储对象(按月份分段,keyPath为"month",如"2024-05")
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName, { keyPath: 'month' });
}
};
request.onsuccess = (event) => {
this.db = event.target.result;
resolve(this.db);
};
request.onerror = (event) => {
reject(new Error(`IndexedDB打开失败:${event.target.error.message}`));
};
});
}
/**
* 生成月份分段键(如"2024-05")
* @param {Date} [date] - 日期,默认当前日期
* @returns {string} 分段键
*/
#getMonthKey(date = new Date()) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
}
/**
* 清理过期分段(保留最近N个月)
*/
async #cleanExpiredSegments() {
const db = await this.#openDB();
const transaction = db.transaction(this.storeName, 'readwrite');
const store = transaction.objectStore(this.storeName);
// 1. 获取所有分段键
const segments = await new Promise((resolve, reject) => {
const request = store.getAllKeys();
request.onsuccess = (e) => resolve(e.target.result);
request.onerror = (e) => reject(e.target.error);
});
// 2. 计算过期月份(当前月份 - maxRetentionMonths)
const now = new Date();
const expireMonth = new Date(now.setMonth(now.getMonth() - this.maxRetentionMonths));
const expireMonthKey = this.#getMonthKey(expireMonth);
// 3. 删除过期分段
for (const monthKey of segments) {
if (monthKey < expireMonthKey) {
await new Promise((resolve, reject) => {
const request = store.delete(monthKey);
request.onsuccess = () => {
console.log(`IndexedDB:删除过期分段,month=${monthKey}`);
resolve();
};
request.onerror = (e) => reject(e.target.error);
});
}
}
transaction.oncomplete = () => db.close();
}
/**
* 存入历史记录(自动按月份分段)
* @param {Array<object>} records - 历史记录数组
*/
async addRecords(records) {
if (!records.length) return;
const db = await this.#openDB();
const transaction = db.transaction(this.storeName, 'readwrite');
const store = transaction.objectStore(this.storeName);
// 1. 按月份分组记录
const recordsByMonth = {};
records.forEach(record => {
const recordDate = new Date(record.timestamp);
const monthKey = this.#getMonthKey(recordDate);
if (!recordsByMonth[monthKey]) {
recordsByMonth[monthKey] = [];
}
recordsByMonth[monthKey].push(record);
});
// 2. 存入每个月份的分段
for (const [monthKey, monthRecords] of Object.entries(recordsByMonth)) {
// 获取现有分段(若存在则合并)
const existingSegment = await new Promise((resolve) => {
const request = store.get(monthKey);
request.onsuccess = (e) => resolve(e.target.result || { month: monthKey, records: [] });
});
// 合并并去重(避免重复存储)
const existingIds = new Set(existingSegment.records.map(r => r.id));
const newRecords = monthRecords.filter(r => !existingIds.has(r.id));
existingSegment.records.push(...newRecords);
// 存入数据库
await new Promise((resolve, reject) => {
const request = store.put(existingSegment);
request.onsuccess = () => {
console.log(`IndexedDB:存入分段${monthKey},新增记录${newRecords.length}条`);
resolve();
};
request.onerror = (e) => reject(e.target.error);
});
}
// 3. 存入后清理过期分段
await this.#cleanExpiredSegments();
transaction.oncomplete = () => db.close();
}
/**
* 查询历史记录(按时间范围)
* @param {Date} startDate - 开始日期
* @param {Date} endDate - 结束日期
* @returns {Promise<Array<object>>} 匹配的记录
*/
async queryRecords(startDate, endDate) {
const db = await this.#openDB();
const transaction = db.transaction(this.storeName, 'readonly');
const store = transaction.objectStore(this.storeName);
// 1. 确定需要查询的月份分段
const startMonthKey = this.#getMonthKey(startDate);
const endMonthKey = this.#getMonthKey(endDate);
const monthKeys = [];
let currentMonth = new Date(startDate);
while (this.#getMonthKey(currentMonth) <= endMonthKey) {
monthKeys.push(this.#getMonthKey(currentMonth));
currentMonth.setMonth(currentMonth.getMonth() + 1);
}
// 2. 查询每个分段的记录
const allRecords = [];
for (const monthKey of monthKeys) {
const segment = await new Promise((resolve) => {
const request = store.get(monthKey);
request.onsuccess = (e) => resolve(e.target.result || { records: [] });
});
// 筛选时间范围内的记录
const filtered = segment.records.filter(record => {
const recordTime = new Date(record.timestamp);
return recordTime >= startDate && recordTime <= endDate;
});
allRecords.push(...filtered);
}
transaction.oncomplete = () => db.close();
return allRecords;
}
}
// --------------- 使用示例 ---------------
const historyCache = new IndexedDBHistoryCache('OrderHistoryDB', 'orderSegments', 6);
// 存入100条订单历史(自动按月份分段)
const orders = Array(100).fill().map((_, i) => ({
id: `order-${i}`,
amount: Math.random() * 1000,
timestamp: new Date(Date.now() - i * 86400000).toISOString() // 过去100天的订单
}));
historyCache.addRecords(orders);
// 查询最近30天的订单
const startDate = new Date();
startDate.setDate(startDate.getDate() - 30);
const recentOrders = await historyCache.queryRecords(startDate, new Date());
console.log('最近30天订单数量:', recentOrders.length);
5.3.2.3 动态容量调整:根据设备与网络适配
缓存容量并非固定不变,需根据设备性能(内存、磁盘)和网络状况动态调整:
- 设备性能检测:用
navigator.deviceMemory(检测设备内存,如4GB/8GB)、navigator.hardwareConcurrency(CPU核心数)调整缓存容量——低端设备(<4GB内存)缓存容量减半; - 网络状况适配:用
navigator.connection.effectiveType(检测网络类型,如2G/4G/Wi-Fi)调整缓存策略——弱网环境(2G/3G)增大缓存容量,减少重复请求;Wi-Fi环境可适当减小缓存,优先获取最新数据。
动态容量调整实现:
/**
* 动态缓存容量计算器:根据设备和网络调整容量
*/
const DynamicCacheCapacity = {
/**
* 检测设备内存(GB)
* @returns {number} 设备内存(如4、8)
*/
#getDeviceMemory() {
// navigator.deviceMemory返回值:0.25, 0.5, 1, 2, 4, 8(GB)
return navigator.deviceMemory || 4; // 默认4GB
},
/**
* 检测网络类型
* @returns {string} 网络类型(2g/3g/4g/wifi/unknown)
*/
#getNetworkType() {
if (!navigator.connection) return 'unknown';
const { effectiveType } = navigator.connection;
if (effectiveType.includes('2g')) return '2g';
if (effectiveType.includes('3g')) return '3g';
if (effectiveType.includes('4g')) return '4g';
return 'wifi'; // 包括5g、wifi等
},
/**
* 计算内存缓存最大容量(项数)
* @param {string} cacheType - 缓存类型(api/filter/render)
* @returns {number} 动态容量
*/
calculateMemoryCacheSize(cacheType) {
const deviceMemory = this.#getDeviceMemory();
const networkType = this.#getNetworkType();
// 基础容量(按缓存类型)
const baseSizes = {
api: 20, // 接口响应缓存基础容量20项
filter: 10, // 过滤结果缓存基础容量10项
render: 15 // 渲染结果缓存基础容量15项
};
let baseSize = baseSizes[cacheType] || 10;
// 设备内存调整:内存越大,容量越大
const memoryMultiplier = deviceMemory >= 8 ? 1.5 : (deviceMemory >= 4 ? 1 : 0.5);
// 网络调整:弱网环境增大容量
const networkMultiplier = networkType === '2g' || networkType === '3g' ? 1.2 : 1;
// 计算最终容量(取整)
const finalSize = Math.round(baseSize * memoryMultiplier * networkMultiplier);
console.log(`动态缓存容量:${cacheType}缓存,设备内存${deviceMemory}GB,网络${networkType},容量=${finalSize}项`);
return finalSize;
},
/**
* 计算localStorage最大容量(MB)
* @returns {number} 最大容量(MB)
*/
calculateLocalStorageSize() {
const deviceMemory = this.#getDeviceMemory();
// 内存越小,localStorage容量限制越严格(避免占用过多磁盘)
return deviceMemory >= 4 ? 4 : 2; // 4GB+设备4MB,否则2MB
}
};
// --------------- 使用示例 ---------------
// 1. 动态计算API缓存容量
const apiCacheSize = DynamicCacheCapacity.calculateMemoryCacheSize('api');
const dynamicApiCache = new LRUCache(apiCacheSize);
// 2. 动态设置localStorage容量限制
const maxLocalStorageMB = DynamicCacheCapacity.calculateLocalStorageSize();
// 在LocalStorage封装中使用该容量...
5.3.3 缓存有效期管理:避免数据过期与脏数据
缓存有效期管理的核心目标是「在保证数据新鲜度的前提下,最大化缓存收益」,常见策略包括「TTL机制」「定时清理」「主动失效」三类。
5.3.3.1 TTL(Time-To-Live):设置缓存过期时间
TTL是最基础的有效期管理策略,为每个缓存项设置「存活时间」,过期后自动失效。前端缓存中,TTL的实现需结合缓存类型:
- 内存缓存:在缓存项中存储
expireTime,获取时检查是否过期; - localStorage/sessionStorage:将
expireTime与数据一起序列化存储,获取时解析并校验; - IndexedDB:为存储对象添加
expireTime字段,查询时过滤过期项。
通用TTL缓存类实现:
/**
* 带TTL的通用缓存类(支持内存、localStorage、IndexedDB)
* @template K - 缓存键类型
* @template V - 缓存值类型
*/
class TTLCache {
/**
* 初始化TTL缓存
* @param {Object} options - 配置
* @param {number} options.defaultTTL - 默认有效期(毫秒,默认5分钟)
* @param {string} options.storageType - 存储类型(memory/localStorage/indexedDB)
* @param {number} options.maxSize - 最大容量(仅内存缓存)
*/
constructor({
defaultTTL = 5 * 60 * 1000,
storageType = 'memory',
maxSize = 100
}) {
this.defaultTTL = defaultTTL;
this.storageType = storageType;
this.maxSize = maxSize;
// 初始化对应存储类型的缓存
this.cache = this.#initStorage();
}
/**
* 初始化存储类型
* @returns {Object} 存储实例(含get/set/delete/clear方法)
*/
#initStorage() {
switch (this.storageType) {
case 'memory':
// 基于LRUCache扩展,添加TTL支持(已在LRUCache中实现)
return new LRUCache(this.maxSize);
case 'localStorage':
// 复用带容量控制的LocalStorage封装
return LocalStorage;
case 'indexedDB':
// 简化版IndexedDB TTL存储
return {
async get(key) {
const db = await indexedDB.open('TTLCacheDB', 1);
const transaction = db.transaction('ttlStore', 'readonly');
const store = transaction.objectStore('ttlStore');
const result = await new Promise((resolve) => {
const request = store.get(key);
request.onsuccess = (e) => {
const entry = e.target.result;
if (!entry) return resolve(null);
// 检查过期
if (entry.expireTime < Date.now()) {
store.delete(key);
resolve(null);
} else {
resolve(entry.value);
}
};
});
db.close();
return result;
},
async set(key, value, ttl) {
const db = await indexedDB.open('TTLCacheDB', 1);
db.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains('ttlStore')) {
db.createObjectStore('ttlStore', { keyPath: 'key' });
}
};
const transaction = db.transaction('ttlStore', 'readwrite');
const store = transaction.objectStore('ttlStore');
await new Promise((resolve) => {
store.put({
key,
value,
expireTime: ttl ? Date.now() + ttl : Infinity,
createTime: Date.now()
});
resolve();
});
db.close();
},
async delete(key) {
const db = await indexedDB.open('TTLCacheDB', 1);
const transaction = db.transaction('ttlStore', 'readwrite');
const store = transaction.objectStore('ttlStore');
await new Promise((resolve) => {
store.delete(key);
resolve();
});
db.close();
},
async clear() {
const db = await indexedDB.open('TTLCacheDB', 1);
const transaction = db.transaction('ttlStore', 'readwrite');
const store = transaction.objectStore('ttlStore');
await new Promise((resolve) => {
store.clear();
resolve();
});
db.close();
}
};
default:
throw new Error(`不支持的存储类型:${this.storageType}`);
}
}
/**
* 存入缓存(自动应用默认TTL)
* @param {K} key - 缓存键
* @param {V} value - 缓存值
* @param {number} [ttl] - 自定义有效期(毫秒,覆盖默认)
*/
async set(key, value, ttl = this.defaultTTL) {
await this.cache.set(key, value, ttl);
}
/**
* 获取缓存(自动过滤过期项)
* @param {K} key - 缓存键
* @returns {Promise<V|null>} 缓存值
*/
async get(key) {
return this.cache.get(key);
}
/**
* 删除指定缓存项
* @param {K} key - 缓存键
*/
async delete(key) {
await this.cache.delete(key);
}
/**
* 清空所有缓存
*/
async clear() {
await this.cache.clear();
}
/**
* 批量设置缓存
* @param {Array<{ key: K, value: V, ttl?: number }>} items - 缓存项数组
*/
async batchSet(items) {
for (const item of items) {
await this.set(item.key, item.value, item.ttl);
}
}
}
// --------------- 使用示例 ---------------
// 1. 初始化内存TTL缓存(默认5分钟有效期,容量20)
const memoryTTLCache = new TTLCache({
defaultTTL: 5 * 60 * 1000,
storageType: 'memory',
maxSize: 20
});
// 存入接口响应(自定义10分钟有效期)
await memoryTTLCache.set('api:products', [{ id: 1, name: '无线耳机' }], 10 * 60 * 1000);
// 2. 初始化localStorage TTL缓存(默认1小时有效期)
const localStorageTTLCache = new TTLCache({
defaultTTL: 60 * 60 * 1000,
storageType: 'localStorage'
});
// 存入用户偏好(永久有效,TTL设为Infinity)
await localStorageTTLCache.set('user:preference', { theme: 'dark' }, Infinity);
5.3.3.2 定时清理:主动淘汰过期缓存
TTL机制依赖「获取时检查」,若缓存项长期不被访问,过期后仍会占用内存/存储,需通过「定时清理」主动淘汰过期项:
- 内存缓存:用
setInterval定期遍历缓存,删除过期项; - localStorage/IndexedDB:结合页面加载、用户交互等时机,触发清理逻辑;
- 注意:定时清理间隔需合理(如5-10分钟一次),避免频繁遍历影响性能。
定时清理工具实现:
/**
* 缓存定时清理工具:主动淘汰过期项
*/
class CacheCleaner {
/**
* 初始化清理工具
* @param {Object} options - 配置
* @param {number} options.cleanInterval - 清理间隔(毫秒,默认5分钟)
* @param {Array<Object>} options.caches - 待清理的缓存实例(需含getSize/clear/遍历方法)
*/
constructor({
cleanInterval = 5 * 60 * 1000,
caches = []
}) {
this.cleanInterval = cleanInterval;
this.caches = caches;
this.cleanTimer = null;
}
/**
* 启动定时清理
*/
start() {
if (this.cleanTimer) return;
// 立即执行一次清理
this.#cleanExpiredItems();
// 设置定时清理
this.cleanTimer = setInterval(() => {
this.#cleanExpiredItems();
}, this.cleanInterval);
console.log('缓存定时清理已启动,间隔:', this.cleanInterval / 1000, '秒');
}
/**
* 停止定时清理
*/
stop() {
if (this.cleanTimer) {
clearInterval(this.cleanTimer);
this.cleanTimer = null;
console.log('缓存定时清理已停止');
}
}
/**
* 清理所有缓存中的过期项
*/
async #cleanExpiredItems() {
console.log('开始清理过期缓存项...');
let totalCleaned = 0;
for (const cache of this.caches) {
const cacheType = cache.constructor.name;
const beforeSize = await this.#getCacheSize(cache);
// 根据缓存类型执行清理
if (cacheType === 'LRUCache' || cacheType === 'LFUCache') {
// 内存缓存:遍历所有键,触发get时自动清理过期项
const keys = Array.from(cache.cache.keys());
for (const key of keys) {
await cache.get(key); // get时自动删除过期项
}
} else if (cacheType === 'TTLCache') {
// TTL缓存:若为IndexedDB,需查询所有项并过滤
if (cache.storageType === 'indexedDB') {
const db = await indexedDB.open('TTLCacheDB', 1);
const transaction = db.transaction('ttlStore', 'readwrite');
const store = transaction.objectStore('ttlStore');
const allItems = await new Promise((resolve) => {
const request = store.getAll();
request.onsuccess = (e) => resolve(e.target.result);
});
// 删除过期项
let cleaned = 0;
for (const item of allItems) {
if (item.expireTime < Date.now()) {
await new Promise((resolve) => {
store.delete(item.key);
resolve();
});
cleaned++;
}
}
totalCleaned += cleaned;
db.close();
}
}
const afterSize = await this.#getCacheSize(cache);
const cleanedCount = beforeSize - afterSize;
totalCleaned += cleanedCount;
console.log(`${cacheType}(${cache.storageType || 'memory'}):清理过期项${cleanedCount}个`);
}
console.log(`缓存清理完成,共清理过期项${totalCleaned}个`);
}
/**
* 获取缓存当前大小
* @param {Object} cache - 缓存实例
* @returns {Promise<number>} 缓存大小
*/
async #getCacheSize(cache) {
if (cache.getSize) return cache.getSize();
if (cache.storageType === 'localStorage') {
return localStorage.length;
}
if (cache.storageType === 'indexedDB') {
const db = await indexedDB.open('TTLCacheDB', 1);
const transaction = db.transaction('ttlStore', 'readonly');
const store = transaction.objectStore('ttlStore');
const count = await new Promise((resolve) => {
const request = store.count();
request.onsuccess = (e) => resolve(e.target.result);
});
db.close();
return count;
}
return 0;
}
}
// --------------- 使用示例 ---------------
// 1. 初始化缓存实例
const apiCache = new LRUCache(20);
const userCache = new TTLCache({ storageType: 'localStorage' });
// 2. 初始化清理工具(清理间隔10分钟)
const cacheCleaner = new CacheCleaner({
cleanInterval: 10 * 60 * 1000,
caches: [apiCache, userCache]
});
// 3. 启动清理(页面加载时启动)
window.addEventListener('DOMContentLoaded', () => {
cacheCleaner.start();
});
// 4. 页面卸载时停止清理
window.addEventListener('beforeunload', () => {
cacheCleaner.stop();
});
5.3.3.3 主动失效:数据更新时同步清理缓存
TTL和定时清理属于「被动失效」,若数据在有效期内更新(如用户修改昵称),需「主动失效」相关缓存,避免旧数据干扰。主动失效的核心是「数据更新→缓存清理」的联动,常见场景:
- 接口数据更新:调用POST/PUT/DELETE接口后,主动删除对应GET接口的缓存;
- 用户操作触发:用户修改配置、删除数据后,主动清理相关缓存项;
- 批量失效:按缓存键前缀清理(如删除所有
api:products:*前缀的缓存)。
主动失效工具实现:
/**
* 缓存主动失效工具:数据更新时同步清理缓存
*/
class CacheInvalidator {
/**
* 初始化失效工具
* @param {Array<Object>} caches - 待失效的缓存实例
*/
constructor(caches = []) {
this.caches = caches;
}
/**
* 按键精确失效缓存
* @param {string} key - 缓存键
*/
async invalidateByKey(key) {
for (const cache of this.caches) {
await cache.delete(key);
console.log(`缓存失效:${cache.constructor.name},key=${key}`);
}
}
/**
* 按前缀批量失效缓存
* @param {string} prefix - 缓存键前缀(如"api:products:")
*/
async invalidateByPrefix(prefix) {
for (const cache of this.caches) {
const cacheType = cache.constructor.name;
if (cacheType === 'LRUCache' || cacheType === 'LFUCache') {
// 内存缓存:遍历键,匹配前缀则删除
const keys = Array.from(cache.cache.keys());
for (const key of keys) {
if (String(key).startsWith(prefix)) {
await cache.delete(key);
console.log(`缓存失效:${cacheType},key=${key}`);
}
}
} else if (cacheType === 'TTLCache') {
// TTL缓存:根据存储类型处理
if (cache.storageType === 'localStorage') {
// localStorage:遍历所有键,匹配前缀则删除
const keys = Object.keys(localStorage);
for (const key of keys) {
if (key.startsWith(prefix)) {
await cache.delete(key);
console.log(`缓存失效:localStorage,key=${key}`);
}
}
} else if (cache.storageType === 'indexedDB') {
// IndexedDB:查询并删除匹配前缀的项
const db = await indexedDB.open('TTLCacheDB', 1);
const transaction = db.transaction('ttlStore', 'readwrite');
const store = transaction.objectStore('ttlStore');
const allItems = await new Promise((resolve) => {
const request = store.getAll();
request.onsuccess = (e) => resolve(e.target.result);
});
for (const item of allItems) {
if (String(item.key).startsWith(prefix)) {
await new Promise((resolve) => {
store.delete(item.key);
resolve();
});
console.log(`缓存失效:IndexedDB,key=${item.key}`);
}
}
db.close();
}
}
}
}
/**
* 按正则表达式失效缓存
* @param {RegExp} regex - 匹配缓存键的正则
*/
async invalidateByRegex(regex) {
for (const cache of this.caches) {
const cacheType = cache.constructor.name;
let keys = [];
if (cacheType === 'LRUCache' || cacheType === 'LFUCache') {
keys = Array.from(cache.cache.keys());
} else if (cache.storageType === 'localStorage') {
keys = Object.keys(localStorage);
} else if (cache.storageType === 'indexedDB') {
const db = await indexedDB.open('TTLCacheDB', 1);
const transaction = db.transaction('ttlStore', 'readonly');
const store = transaction.objectStore('ttlStore');
const allItems = await new Promise((resolve) => {
const request = store.getAllKeys();
request.onsuccess = (e) => resolve(e.target.result);
});
keys = allItems;
db.close();
}
// 匹配正则并删除
for (const key of keys) {
if (regex.test(String(key))) {
await cache.delete(key);
console.log(`缓存失效:${cacheType},key=${key}`);
}
}
}
}
}
// --------------- 使用示例 ---------------
// 1. 初始化缓存和失效工具
const apiCache = new LRUCache(20);
const userCache = new TTLCache({ storageType: 'localStorage' });
const cacheInvalidator = new CacheInvalidator([apiCache, userCache]);
// 2. 场景1:更新商品后,失效商品列表缓存
async function updateProduct(productId, data) {
// 调用更新接口
const response = await fetch(`/api/products/${productId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const updatedProduct = await response.json();
// 主动失效相关缓存:精确失效单个商品,批量失效列表
await cacheInvalidator.invalidateByKey(`api:product:${productId}`);
await cacheInvalidator.invalidateByPrefix('api:products:page');
return updatedProduct;
}
// 3. 场景2:用户登出后,失效所有用户相关缓存
async function logout() {
await fetch('/api/logout', { method: 'POST' });
// 按正则失效所有"user:"前缀的缓存
await cacheInvalidator.invalidateByRegex(/^user:/);
}
5.3.4 实践反例:缓存策略失控的常见错误
| 反例类型 | 问题分析 | 修复方案 |
|---|---|---|
| 1. 无限缓存无容量限制 | 1. 无容量限制,持续存储接口响应,内存占用暴涨; 2. 无有效期,数据过期后仍返回旧数据。 | 1. 改用LRUCache,设置容量(如20项); 2. 添加TTL(如5分钟),过期自动失效。 |
| 2. 不设有效期导致数据过期 | 1. 用户信息更新后(如修改昵称),缓存未同步更新,显示旧数据; 2. 长期不清理,占用localStorage容量。 | 1. 存储时添加过期时间(如7天); 2. 获取时检查过期,过期自动删除; 3. 用户信息更新后主动失效缓存。 |
| 3. 滥用localStorage存大对象 | 1. localStorage读写性能有限(大对象序列化/反序列化耗时); 2. 1MB数据易导致localStorage容量满,影响其他功能。 | 1. 改用IndexedDB存储大对象; 2. 按月份分段存储,定期清理过期数据; 3. 读取时用游标逐段查询,减少内存占用。 |
| 4. 缓存冗余重复存储 | 1. 组件内缓存与全局缓存重复存储,内存冗余; 2. 组件卸载后,全局缓存仍持有数据,导致内存泄漏。 | 1. 统一缓存策略:组件内用useMemo,全局用LRUCache,避免重复; 2. 组件卸载时清理全局缓存中该组件的缓存项; 3. 全局缓存设置容量限制,自动淘汰冗余项。 |
| 5. 定时清理过于频繁 | 1. 每秒遍历所有缓存项,主线程频繁阻塞; 2. 高频清理导致缓存命中率骤降,重复请求增多。 | 1. 延长清理间隔(如5-10分钟一次); 2. 结合「获取时检查过期」+「定时清理」,减少遍历频率; 3. 清理时用requestAnimationFrame,避免阻塞主线程。 |
5.3.5 代码评审要点:缓存策略的检查清单
| 评审维度 | 检查要点 | 工具支持 |
|---|---|---|
| 1. 缓存选型 | 1. 是否根据数据特性(频率、大小、生命周期)选择合适的缓存类型? 2. 高频临时数据是否用内存缓存? 3. 大对象(>100KB)是否避免用localStorage? | 1. 代码审查(检查缓存类型与数据特性匹配度); 2. Chrome DevTools → Application(查看缓存使用)。 |
| 2. 缓存大小控制 | 1. 是否设置缓存容量限制(如LRU的maxSize)? 2. 容量是否合理(不超过内存/存储的安全范围)? 3. 大对象是否分段存储(如IndexedDB按月份)? | 1. 检查缓存类初始化参数(如new LRUCache(20)); 2. Memory面板(监控内存缓存大小); 3. Application面板(查看localStorage/IndexedDB占用)。 |
| 3. 有效期管理 | 1. 是否为所有缓存项设置TTL? 2. TTL是否合理(高频数据短,低频数据长)? 3. 是否有定时清理或主动失效机制? | 1. 检查缓存set方法是否传入ttl参数; 2. 代码审查(TTL与数据更新频率匹配度); 3. 检查是否有CacheCleaner或CacheInvalidator实例。 |
| 4. 性能影响 | 1. 缓存读写是否阻塞主线程(如大对象JSON.stringify)? 2. 定时清理间隔是否合理(不小于5分钟)? 3. 缓存命中率是否达标(如>70%)? | 1. Performance面板(查看缓存操作耗时); 2. 代码审查(清理间隔设置); 3. 埋点统计(缓存命中次数/总请求次数)。 |
| 5. 数据一致性 | 1. 数据更新后是否主动失效相关缓存? 2. 缓存失效是否覆盖所有相关键(精确+批量)? 3. 是否有缓存降级方案(如缓存失效时直接请求)? | 1. 检查数据更新接口(POST/PUT/DELETE)后是否调用invalidate方法; 2. 代码审查(失效键的完整性); 3. 检查缓存get失败时是否有降级逻辑。 |
| 6. 可维护性 | 1. 缓存逻辑是否封装(如TTLCache类),避免散落在业务代码中? 2. 是否有缓存相关文档(容量、TTL、失效规则)? 3. 是否有缓存监控(大小、命中率、失效次数)? | 1. 代码审查(缓存逻辑封装度); 2. 检查项目文档(是否包含缓存策略说明); 3. 检查是否有埋点或监控工具(如Sentry缓存监控)。 |
对话小剧场:电商项目缓存问题的团队攻坚
场景:电商后台「商品管理」页面上线后,测试反馈两个问题:1)频繁操作后页面卡顿,内存从200MB涨到800MB;2)商品价格修改后,列表仍显示旧价格。团队(小美、小迪、小稳、大熊、小燕)召开优化会议。
小燕(质量工程师):“根据测试数据,商品列表页面每刷新10次,内存增加100MB;修改商品价格后,刷新列表仍显示旧价格——即使强制刷新页面,有时候也需要等几分钟才更新。我用Chrome DevTools看了,localStorage里的‘productList:page1’数据已经存了3天,还没过期。”
小迪(前端开发):“我查了商品列表的缓存代码,问题出在全局缓存对象上!之前为了减少接口请求,用了一个无限制的globalCache,每次刷新列表都往里面塞数据,既没容量控制也没过期时间。”
小迪投屏展示问题代码:
// 问题代码:全局缓存无容量限制、无TTL
const globalCache = {}; // 无限存储
const localStorageCache = {
set(key, value) {
// 错误:不设有效期,永久存储
localStorage.setItem(key, JSON.stringify(value));
},
get(key) {
const value = localStorage.getItem(key);
return value ? JSON.parse(value) : null;
}
};
// 商品列表请求逻辑
async function getProductList(page = 1) {
const cacheKey = `productList:page${page}`;
// 先查内存缓存,再查localStorage,最后请求接口
if (globalCache[cacheKey]) return globalCache[cacheKey];
const localValue = localStorageCache.get(cacheKey);
if (localValue) {
globalCache[cacheKey] = localValue;
return localValue;
}
// 请求接口后存入两个缓存
const response = await fetch(`/api/products?page=${page}`);
const data = await response.json();
globalCache[cacheKey] = data; // 内存缓存无限存
localStorageCache.set(cacheKey, data); // localStorage永久存
return data;
}
小美(前端开发):“价格修改后显示旧数据的问题,我也找到了——修改价格的接口调用后,没清理对应的缓存!比如改了商品ID=1001的价格,productList:page1和product:1001这两个缓存还在,列表刷新时优先读缓存,自然看不到新价格。”
大熊(后端开发):“后端这边也有优化空间——目前接口没返回数据更新时间戳,前端没法判断缓存是否新鲜。我可以在所有商品接口的响应头加Last-Modified,或者在返回体里加updateTime,方便前端做缓存校验。”
小稳(前端架构师):“现在问题很明确了,三个核心漏洞:1)内存缓存无容量限制,导致内存泄漏;2)无TTL和定时清理,数据永久过期;3)数据更新后无主动失效,缓存不同步。解决方案分三步:”
小稳边画架构图边说:
“第一步,统一缓存工具:把散落在业务里的缓存逻辑,换成我们之前封装的TTLCache,内存缓存用LRU控制容量(比如20项),TTL设5分钟;localStorage缓存也加TTL,容量控制在4MB以内。
第二步,补全主动失效:修改商品价格、库存后,不仅要调用后端接口,还要主动清理对应的列表缓存(如productList:page1)和单个商品缓存(如product:1001)。
第三步,后端配合:用大熊说的Last-Modified头做缓存校验,即使前端缓存没失效,也能对比时间戳,拉取最新数据。”
小迪(前端开发):“我来改缓存逻辑!把globalCache换成TTLCache,再在价格修改接口里加主动失效。”
小迪快速写出修复代码:
// 修复1:用TTLCache统一管理缓存
const productCache = new TTLCache({
defaultTTL: 5 * 60 * 1000, // 5分钟有效期
storageType: 'memory',
maxSize: 20 // 内存缓存最多20项
});
// 修复2:商品列表请求(加时间戳校验)
async function getProductList(page = 1) {
const cacheKey = `productList:page${page}`;
const cachedData = await productCache.get(cacheKey);
// 后端返回Last-Modified,对比缓存时间戳
const response = await fetch(`/api/products?page=${page}`, {
headers: cachedData ? { 'If-Modified-Since': cachedData.updateTime } : {}
});
if (response.status === 304) {
// 数据未更新,返回缓存
return cachedData;
}
const data = await response.json();
// 存入缓存(带后端返回的updateTime)
await productCache.set(cacheKey, { ...data, updateTime: response.headers.get('Last-Modified') });
return data;
}
// 修复3:修改价格后主动失效缓存
async function updateProductPrice(productId, newPrice) {
const response = await fetch(`/api/products/${productId}/price`, {
method: 'PUT',
body: JSON.stringify({ price: newPrice })
});
if (response.ok) {
// 主动失效单个商品和列表缓存
await productCache.delete(`product:${productId}`);
await cacheInvalidator.invalidateByPrefix('productList:page');
}
return response.json();
}
小美(前端开发):“我再补个定时清理!页面初始化时启动CacheCleaner,每10分钟清理一次过期缓存,避免内存缓存长期占用。”
// 页面初始化时启动清理
window.addEventListener('DOMContentLoaded', () => {
const cleaner = new CacheCleaner({
cleanInterval: 10 * 60 * 1000,
caches: [productCache]
});
cleaner.start();
});
小燕(质量工程师):“我马上测!修改后刷新10次列表,内存稳定在220MB左右,没再增长;修改商品价格后,列表立即显示新价格,localStorage里的旧缓存也被清理了。后端加了Last-Modified后,304响应率从0涨到60%,重复请求少了很多!”
大熊(后端开发):“后续我们还可以加个‘缓存刷新接口’,比如大促前批量更新商品时,后端主动通知前端清理缓存,不用等TTL过期。”
小稳(前端架构师):“最后补充个规范——以后所有缓存都要走统一的TTLCache和CacheInvalidator,禁止在业务代码里写散的globalCache或localStorage操作。代码评审时重点查三个点:容量限制、TTL、主动失效,避免再踩同样的坑。”
5.3.6 小结:缓存策略的“收益-成本”平衡法则
前端缓存的核心不是“越多越好”,而是在“性能收益”与“内存/存储成本”之间找到平衡。通过本章的分析,可总结出三类关键实践:
- 大小控制:用LRU/LFU算法限制缓存容量,结合场景分配资源(内存缓存<页面内存20%,localStorage<4MB),动态适配设备与网络;
- 有效期管理:为所有缓存项设置TTL(高频数据短、低频数据长),配合定时清理主动淘汰过期项,数据更新时通过主动失效保证一致性;
- 工具化封装:将缓存逻辑封装为
TTLCache、CacheCleaner等可复用工具,避免散落在业务代码中,同时通过代码评审和监控(如缓存命中率、内存占用)持续优化。
合理的缓存策略能将重复请求减少70%以上,页面加载时间缩短50%,但失控的缓存会沦为“内存黑洞”。记住:好的缓存不仅要“存得快、取得快”,更要“控得住、清得掉”——这才是前端内存优化中缓存的真正价值。
下一节将探讨“内存优化的进阶技巧”,包括弱引用的深度应用、内存碎片整理等高级话题,帮助应对更复杂的内存管理场景。

1431

被折叠的 条评论
为什么被折叠?



