LocalStorage 使用陷阱与性能优化实战
又踩坑了,用户刷新一下,购物车空了
这事儿发生在我给一个小型电商项目加购物车功能的时候。本来挺简单的需求:用户点“加入购物车”,我把商品ID存起来,页面刷新也不丢。我寻思着,LocalStorage 不就是干这个的吗?于是三下五除二写完,本地测试也没问题,一部署到测试环境,QA 妹妹直接甩我一句:“你这购物车不能用啊,我刚加的,刷新就没了。”
我当场懵了。打开控制台一看,localStorage 里确实啥都没有。但我在开发时明明能看到数据的。折腾了半天发现,不是代码逻辑的问题,而是——不同环境下 localStorage 的行为不一致。
排查过程像在解谜
第一步我先确认是不是跨域问题。项目是部署在子域名上的,比如 shop.jztheme.com,而主站是 jztheme.com。这两个域名共享 LocalStorage 吗?不共享。每个子域名都有独立的存储空间。这我知道,但我的应用就是在同一个子域名下跑的,按理说没问题。
后来我打印了一下 window.location.origin,才发现 QA 测试的是 https://shop-staging.jztheme.com,而我本地联调的是 http://localhost:3000。两个完全不同的 origin,当然数据隔离。但这不是根本原因,因为即使在同一个 origin 下,还是有问题。
我又开始怀疑是不是浏览器隐私模式或者无痕窗口导致的。试了下 Chrome 无痕,果然,加进去的数据刷新后没了。查资料发现有些浏览器在无痕模式下会清空或限制 localStorage,但这属于特殊情况,不能作为线上问题的理由。
最后我决定直接监听 Storage 事件看看有没有异常:
window.addEventListener('storage', (e) => {
console.log('Storage changed:', e.key, e.oldValue, e.newValue);
});
结果什么都没打出来。说明根本没有触发写入失败的情况,也就是说,数据压根没存进去?不可能啊,我明明写了 localStorage.setItem。
这时候我才想到:会不会是代码执行时机的问题?我是在组件 mounted 之后才去读取 localStorage 的,但有没有可能某个地方异步操作还没完成,就去读了?于是我在 setItem 后立刻 getItem,结果能读到。可刷新后就读不到了。
终于,在一次偶然调试中,我发现有个请求拦截器里做了全局状态重置的操作,把整个购物车模块的状态清掉了——但它并没有清除 localStorage,只是清了内存里的变量。可为什么 localStorage 自己也空了?
顺着这个思路,我发现了一个更隐蔽的问题:有同事为了“防止数据污染”,在登录成功后加了一行代码:
localStorage.clear();
WTF!整个存储都被清了!这不是我写的,我也完全不知道。这个逻辑藏在 auth 模块的一个 callback 里,没有任何注释。删掉这一行之后,购物车终于稳了。
但到这里还没完。
真正的大坑:序列化和类型丢失
解决了清空问题后,我又遇到另一个诡异现象:某些用户的购物车里,商品数量变成了字符串,而不是数字。导致后续计算总价时出错。
看代码:
const cart = [
{ id: 1, name: 'T恤', count: 2 }
];
localStorage.setItem('cart', JSON.stringify(cart));
读取的时候:
const raw = localStorage.getItem('cart');
const cart = raw ? JSON.parse(raw) : [];
看起来没问题对吧?但问题出在后续更新逻辑上。我们有个函数用来增加商品数量:
function addToCart(productId, addCount) {
const cart = getCartFromStorage(); // 就是上面那个 parse
const item = cart.find(i => i.id === productId);
if (item) {
item.count += addCount; // 这里炸了
} else {
cart.push({ id: productId, count: addCount });
}
saveCartToStorage(cart);
}
看似没问题,但有一次我传了个字符串 '1' 当 addCount,结果 item.count 变成了 "21"(字符串拼接)。虽然参数校验可以解决,但更深层的问题是:JavaScript 类型在序列化/反序列化过程中完全丢失了。
比如日期对象,你存进去是个 Date 实例,取出来就是字符串;布尔值、数字都变成基本类型,没办法保留原始类型信息。这在复杂结构下很容易埋雷。
最终方案:封装一层 Storage 工具
为了避免再出幺蛾子,我干脆封装了个简单的 safeStorage 工具:
const safeStorage = {
get(key, defaultValue = null) {
try {
const raw = localStorage.getItem(key);
if (raw === null || raw === 'undefined') return defaultValue;
return JSON.parse(raw);
} catch (e) {
console.warn(Failed to parse localStorage[${key}], e);
return defaultValue;
}
},
set(key, value) {
try {
const serialized = JSON.stringify(value);
localStorage.setItem(key, serialized);
} catch (e) {
console.error(Failed to save to localStorage[${key}], e);
// 可以在这里降级到内存存储或其他 fallback
}
},
remove(key) {
localStorage.removeItem(key);
},
clear() {
// 不提供全局 clear,避免误操作
throw new Error("Don't clear all. Use remove(key) instead.");
}
};
然后购物车相关操作统一走这个工具:
function getCart() {
return safeStorage.get('user_cart_v2', []); // 加个版本号防冲突
}
function saveCart(cart) {
// 写入前做一次数据清洗
const cleaned = cart.map(item => ({
...item,
id: Number(item.id),
count: Number(item.count) || 1
}));
safeStorage.set('user_cart_v2', cleaned);
}
function addToCart(productId, addCount = 1) {
const cart = getCart();
const item = cart.find(i => i.id === Number(productId));
if (item) {
item.count = (Number(item.count) || 1) + Number(addCount);
} else {
cart.push({
id: Number(productId),
count: Number(addCount) || 1
});
}
saveCart(cart);
}
这里我加了几个防护措施:
- 所有数值强制转成 number,防止字符串拼接
- 使用
_v2后缀避免和其他旧版本 key 冲突 - catch 异常并给出 warning,而不是让整个流程崩溃
- 禁用
clear()防止误清
其实还可以进一步优化,比如用 Map 存储中间状态,配合 storage 事件实现多标签页同步,但目前项目没这需求,我就懒得搞那么复杂了。
还有个小问题:容量限制和写入失败
有一天突然收到 Sentry 报错:QuotaExceededError: The quota has been exceeded. 才意识到,localStorage 最大也就 5-10MB,而且一旦超出,setItem 就会抛异常。
现在我的 safeStorage.set 已经包了 try-catch,至少不会让页面挂掉。但我加了个简单的监控:
function checkStorageUsage() {
let total = 0;
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
const value = localStorage.getItem(key);
total += (key.length + value.length) * 2; // UTF-16 字节估算
}
return total;
}
// 超过 4MB 就报警(留点余地)
if (checkStorageUsage() > 4 * 1024 * 1024) {
console.warn('LocalStorage nearing limit');
}
暂时没做自动清理策略,但至少知道什么时候该出手干预了。
总结一下
LocalStorage 看起来简单,实则处处是坑。这次我踩的包括:
- 被别人代码里的
clear()暗算 - 类型丢失导致数值运算错误
- 没处理序列化异常
- 忽略了容量上限
现在的方案不是最优的,比如未来可以考虑迁移到 IndexedDB,但对于当前这种轻量级需求,封装一层带容错的工具已经够用了。改完后目前跑了两周,没再收到相关 bug 报告。
唯一的小遗憾是,如果用户开了多个标签页,修改购物车后其他页面不会实时更新——因为没监听 storage 事件来做响应式更新。不过这个影响不大,用户手动刷新就行。以后要是产品提需求再说吧。
以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。

暂无评论