怎样封装一个带过期时间的 localStorage?
localStorage 只能存字符串,而且数据没有过期时间。封装时把值和过期时间戳一起用 JSON.stringify 存进去,读取时先比较当前时间,过期了就删除并返回 null。JSON.parse 要用 try...catch 包住,防止被别的代码写入的非 JSON 数据弄崩;写入可能因为超出配额抛出 QuotaExceededError,也要捕获。
简答
比如把城市列表缓存 1 小时,1 小时内再打开页面就不用重新请求。可是 MDN 写明,
localStorage的数据没有过期时间,不手动删就一直在,所以过期要自己实现。做法是写入时把值和过期时间戳包成一个对象,比如
{ value, expires: Date.now() + 3600000 },因为localStorage只能存字符串,所以要用JSON.stringify转一下。读取时先JSON.parse,再拿过期时间和当前时间比,过期了就删掉这一项,返回null。这种删除是“读的时候才删”,过期但一直没被读的数据还占着空间。需要的话,可以在页面启动时遍历一遍,把过期的清掉。
有两处要防出错。读取时,这个 key 可能被别的代码写成了普通字符串,
JSON.parse会抛错,要用try...catch包住;写入时,存储空间满了setItem会抛出QuotaExceededError,也要捕获,缓存写不进去不应该让页面报错。
封装 createExpiringStorage
为了在 Node 里也能运行,下面把存储对象和取当前时间的函数作为参数传进来。在浏览器里直接传 localStorage 和 Date.now 即可。
function createExpiringStorage(storage, now = () => Date.now()) {
return {
set(key, value, ttl) {
const record = { value, expires: ttl == null ? null : now() + ttl };
try {
storage.setItem(key, JSON.stringify(record));
return true;
} catch {
// 配额不足等写入失败时,缓存降级为不缓存。
return false;
}
},
get(key) {
const raw = storage.getItem(key);
if (raw === null) return null;
try {
const record = JSON.parse(raw);
if (record.expires !== null && now() >= record.expires) {
storage.removeItem(key);
return null;
}
return record.value;
} catch {
return null;
}
},
};
}
// 在 Node 里模拟 Storage 的三个方法和一个可以手动拨动的时钟。
const map = new Map();
const fakeStorage = {
getItem: (key) => (map.has(key) ? map.get(key) : null),
setItem: (key, value) => map.set(key, String(value)),
removeItem: (key) => map.delete(key),
};
let time = 0;
const cache = createExpiringStorage(fakeStorage, () => time);
cache.set('cities', ['北京', '上海'], 1000);
console.log(cache.get('cities').join(',')); // 北京,上海
time = 1000;
console.log(cache.get('cities')); // null
console.log(map.has('cities')); // false
fakeStorage.setItem('legacy', 'not json');
console.log(cache.get('legacy')); // null
time = 1000 时,过期时间正好是 0 + 1000,判断条件是 >=,所以算过期,返回 null,同时 map.has('cities') 变成 false,说明这一项已经被删掉了。
为什么把时钟作为参数传进来?除了能在 Node 里运行,测试的时候也不用真的等 1 小时,把 time 改一下就能验证过期逻辑。
'legacy' 那一项是别的代码直接写进去的普通字符串,JSON.parse('not json') 会抛出 SyntaxError,被 catch 接住后返回 null。如果不包这一层,一条脏数据就能让读缓存的地方报错。
不传 ttl 时为什么存 null
expires 写成 null 表示永不过期。不能写成 Infinity,因为 JSON.stringify(Infinity) 的结果是 'null',读出来就是 null,和直接存 null 没区别,不如一开始就用 null 表示。
实际使用时还要注意什么
存进去的值要能被 JSON 表示。Date 会变成字符串,Map、Set 会变成空对象,undefined 的属性会被丢掉,读出来以后形状就变了。
localStorage 的读写是同步的,大量数据会阻塞主线程,所以只适合存少量配置和缓存,大量数据应该放到 IndexedDB,见《IndexedDB 为什么比 localStorage 更适合大型离线数据?》。
另外,同一个源的多个标签页共用一份 localStorage。MDN 写明,一个标签页修改了数据,同源的其他标签页会收到 storage 事件,修改的那个标签页自己收不到。如果缓存的数据要在多个标签页之间保持一致,可以监听这个事件。
常见追问
用户修改了本机时间,过期判断会不会出错?
会。
Date.now()读的是本机时间,用户把时间往回调,缓存就会过期得更晚。缓存只是为了少发请求,影响不大;如果是登录状态这类有安全要求的数据,过期必须由服务端判断,不能只靠前端。
和 Cookie 的 Expires 有什么区别?
Cookie 的过期由浏览器管理,到期后浏览器自动删除,也不会再随请求发送。
localStorage的过期是自己写的逻辑,只有调用get时才会检查。两者的其他区别见《Cookie、localStorage 和 sessionStorage 如何选择?》。