LocalStorage 使用陷阱与性能优化实战

付楠 Dev 前端 阅读 782
赞 17 收藏
二维码
手机扫码查看
反馈

又踩坑了,用户刷新一下,购物车空了

这事儿发生在我给一个小型电商项目加购物车功能的时候。本来挺简单的需求:用户点“加入购物车”,我把商品ID存起来,页面刷新也不丢。我寻思着,LocalStorage 不就是干这个的吗?于是三下五除二写完,本地测试也没问题,一部署到测试环境,QA 妹妹直接甩我一句:“你这购物车不能用啊,我刚加的,刷新就没了。”

LocalStorage 使用陷阱与性能优化实战

我当场懵了。打开控制台一看,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 事件来做响应式更新。不过这个影响不大,用户手动刷新就行。以后要是产品提需求再说吧。

以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论