
1. 存储落地前必须想清楚的三个问题类型、序列化、响应式前阵子帮同事排查一个线上问题用户反馈某个开关设置保存不了关掉页面再进来就又变成开启状态了。代码看了一圈localStorage 写入、读取都做了逻辑也没毛病。后来在控制台手动跑了一遍才发现开关的布尔值读取出来之后变成了字符串false而false在 if 判断里是个真值于是关掉就被当成开启处理了。这种问题在 Vue 项目里太典型了而且往往不是业务逻辑写错而是栽在了一个基础认知上本地存储localStorage / sessionStorage天生只能存字符串。你给它一个数字 18它还给你字符串18你给它一个布尔值 false它还给你字符串false你给它一个对象它干脆调用了toString()返回[object Object]。而 Vue 项目的核心数据形态恰恰就是基础类型、对象、数组这三类所以Vue 本地存储这个命题本质上是三个问题的叠加类型转换怎么做、序列化怎么处理、Vue 的响应式怎么联动。今天这篇就围绕这三件事把代码和思路一次讲透。这篇文章适合刚把 Vue 基础语法学完、开始在真实项目中写存储逻辑的开发者也适合项目里已经积累了不少能用但浑身难受的存储代码、想系统性整理一下的同行。下面每个小节都有可以直接复制使用的代码片段也会把为什么这么写、不这么写会出什么问题讲清楚。2. 基础类型进本地存储类型失真才是头号坑不是存不进去很多新手以为 localStorage 用起来就是setItem和getItem两行代码的事直到在真机环境里读出数据后发现自己被Well well well了。这一节我把基础类型逐个过一遍讲清楚每个类型在存储过程中的实际行为。2.1 数字会被静默转成字符串直接上代码验证localStorage.setItem(age, 28) const age localStorage.getItem(age) console.log(typeof age) // string console.log(age 28) // false console.log(age 28) // true数字 28 写进去出来就变成28。如果你的业务里要拿这个值做比较、做运算就很容易出问题。比如一个购物车场景const price localStorage.getItem(price) // 99.9 const quantity 2 const total price * quantity // 这里 JS 会自动做隐式转换结果是 199.8例子里的乘法因为隐式转换侥幸对上了但如果你做的是字符串拼接比如price quantity得到的就是99.92这属于隐藏炸弹。我见过一个报表项目里日期的年份直接从 localStorage 读出来参与比较因为没做 Number 转换导致当年份跨入下一年时所有统计都按字符串字典序排序结果一片混乱。这类问题平时不爆发一旦爆发就得花半天来定位。正确的做法是读取后根据业务场景做一次明确转换const age Number(localStorage.getItem(age)) // 或者 const age parseInt(localStorage.getItem(age), 10)2.2 字符串 false 是真值布尔值存储的翻车现场这是我在文章开头提到的那个线上 bug 的完整版。布尔值在 localStorage 里的表现极具迷惑性localStorage.setItem(enabled, false) // 实际上存储的是字符串 false const enabled localStorage.getItem(enabled) if (enabled) { // 这里 enabled 是字符串 false不是空字符串所以条件成立 // 这个分支会被执行但它不应该执行 }在 JS 的真值表里false这个字符串非空所以它是一个真值。你以为是关闭状态程序却判断成开启整个逻辑反了。这个问题在表单类、配置类项目中出现的频率非常高。2.3 null 与 undefined别被 console.log 骗了localStorage.setItem(user, null) // 实际存储的是字符串 null localStorage.setItem(something, undefined) // 实际存储的是字符串 undefinedconsole.log 里看着像是 null 和 undefined但它们的类型都是 string。更折磨人的是localStorage.getItem(不存在的key)返回的是真正的null不是字符串null。于是你会遇到一种诡异情况const data localStorage.getItem(user) if (data) { } // 有时候进不来有时候进来却是 null同一个 null来源不同行为不同。处理办法是在读取后统一做一次字符串转实际类型的判断这一步最好收敛到同一个工具函数里不要散落在业务各处。下表是我整理的 localStorage 读写真实行为对照建议截图保存写入值实际存储内容读取返回值读取后 typeof是否等于原值282828string否hellohellohellostring是truetruetruestring否falsefalsefalsestring否nullnullnullstring否undefinedundefinedundefinedstring否{ name: 张三 }[object Object][object Object]string彻底丢失[1, 2, 3]1,2,31,2,3string数组变字符串基础类型这块的结论很直接所有读取操作都必须经过字符串还原成正确类型的步骤这个步骤放在业务里很容易漏必须收敛到一个公共模块。3. 对象与数组JSON 方案的正确打开方式与特殊值陷阱基础类型还不算最痛苦的对象和数组才是。localStorage 本身不认对象和数组所以业界通行的方案是用JSON.stringify()序列化成字符串再存储读取时用JSON.parse()反序列化回来。原理不复杂但使用边界得摸清楚。3.1 JSON.stringify 之前的预处理清单先看正确写法const user { id: 1001, name: 张三, tags: [admin, editor], address: { city: 北京, district: 朝阳区 } } // 写入 localStorage.setItem(user, JSON.stringify(user)) // 读取 const str localStorage.getItem(user) const storedUser JSON.parse(str) console.log(storedUser.id) // 1001 console.log(storedUser.address.district) // 朝阳区这套写法能覆盖绝大多数常规对象和数组嵌套结构也能正确还原。但接下来这些特殊值是你迟早会踩的坑。3.2 Date、NaN、Infinity 全都悄悄变了形JSON.stringify对特殊类型有自己的处理规则如果你不知道就会出现存储成功但读出来不对的诡异问题const data { date: new Date(2025-01-01), score: NaN, nothing: undefined, fn: function() { console.log(hi) }, sym: Symbol(id), infinity: Infinity } const saved localStorage.setItem(data, JSON.stringify(data)) // 读出来后你会发现 // date 变成了字符串 2025-01-01T00:00:00.000Z // NaN 变成了 null // undefined 这个字段直接消失了 // fn 这个字段直接消失了 // sym 这个字段直接消失了 // Infinity 变成了 null这个陷阱在真实项目里最常见的触发点有两个。第一个是时间字段后端返回的时间戳或 Date 对象存进去再读出来就成字符串了如果页面里直接拿它new Date()还好要是拿它当 0 点做日期计算很容易因为时区导致差 8 小时。第二个是数值字段接口可能返回 NaN 或 null序列化后大家都成了 null如果存储端没有判空页面渲染时就多了一个奇怪的 null。我的处理习惯是在序列化之前先做数据清洗把 Date 统一转成时间戳getTime()的结果把 NaN / undefined / Infinity 这类统一转成 null这样反序列化后的数据结构是可控的。如果你有特殊需求要完整保留 Date 类型可以自己实现一个序列化器比如长度不够再考虑用JSON.stringify的 replacer 参数function serialize(data) { return JSON.stringify(data, (key, value) { if (value instanceof Date) { return { __type: Date, value: value.getTime() } } return value }) } function deserialize(str) { return JSON.parse(str, (key, value) { if (value value.__type Date) { return new Date(value.value) } return value }) }在自定义序列化器里加一个__type字段标记类型反序列化时根据标记还原。这套逻辑对嵌套结构同样生效比在外层逐个字段处理干净得多。3.3 深拷贝误区与性能取舍JSON.parse JSON.stringify 还有一个隐藏性能特点它实际上做了一次深拷贝。所以如果项目里有人这样写const copy JSON.parse(localStorage.getItem(bigList))每次读取存储都会完整反序列化出整个对象树。如果你的数组很大比如五千条商品记录这个操作的耗时是肉眼可见的。开发环境内存充足还好低端移动设备上直接卡顿。性能优化思路是按需存储。不要把整个大对象一把梭塞进一个 key而是按最小使用单元拆分。比如购物车列表你可以按品类或按状态拆成多个 key页面初始化时只读取当前场景需要的部分其余数据等到真正需要时再去读。另外一个常用手段是给读取操作做缓存const cache new Map() function readFromStore(key) { if (cache.has(key)) return cache.get(key) const data JSON.parse(localStorage.getItem(key)) cache.set(key, data) return data }但注意这个缓存只在同一个页面生命周期内有效如果你在多标签页里同时操作同一份本地存储缓存就会造成数据不一致。关于多标签页的同步问题后面单独开一节说。4. 对象赋值页面不更新本地存储读出的数据不是响应式的热搜词里有一个点特别扎眼——vue 对象赋值页面不变。这几乎是每个 Vue 开发者都经历过的困惑而本地存储场景尤其容易触发。原因其实就一句话从 localStorage 读取的数据是普通对象它们跟 Vue 的响应式系统没有关系。4.1 读出来的永远是普通对象无论你用什么方式获取数据从本地存储解析出来的对象都是平铺直叙的 plain objectVue 不会自动跟踪它内部的属性变化。看看这个反面教材script setup import { ref, onMounted } from vue const user ref({}) onMounted(() { // 直接把本地存储的数据赋值给 ref user.value JSON.parse(localStorage.getItem(user)) }) function updateName() { user.value.name 李四 // 页面上的 name 不更新 } /script template p{{ user.name }}/p /template表面上看user.value.name 李四改了数据但页面不刷新。原因在于 Vue 的响应式代理是在赋值那一刻建立的。读取出的对象在被ref()包装的那一刻Vue 会把内部属性变成响应式的。问题出在上面的写法里你是先建了一个空的 ref之后再整体赋值整体替换这个操作本身是响应式的但你赋进去的是普通对象属性只是被 ref 的 value 接住了。注意在 Vue 3 中ref()的深层响应式体现在内部当整个 value 被赋值成一个新的对象时Vue 会调用reactive()把它包装成响应式代理。但如果你在赋值之后直接操作的是这个对象的子对象属性且这个子对象本身不经过 Vue 的代理就可能出现更新丢失。实际上更常见的情况是从 localStorage 里解析出来的对象被赋值之后reactive()的代理已经建立修改普通属性是能触发更新的。真正的问题往往出在深层属性、新增属性、以及数组的某些操作方式上。4.2 深层嵌套与新增属性的响应式盲区拿一个两层嵌套的对象举例script setup import { reactive } from vue const state reactive({ profile: { name: 张三, settings: { theme: light } } }) function updateTheme() { state.profile.settings.theme dark // 在 Vue 3 中可以正常响应 } function addNewField() { state.profile.avatar http://xxx.png // 新增属性也是响应式 } /scriptVue 3 基于 Proxy 的响应式系统对深层嵌套和新增属性都能正确处理这是 Vue 2 做不到的。但注意如果数据是从本地存储读取的读取时没有经过 reactive 包装或者包装时机不对深层属性的响应式就无从谈起。解决方法是统一入口所有从本地存储解析出的数据都先交给 reactive/ref 处理再进入业务逻辑。很多在 Vue 3 里仍然出现对象赋值页面不更新的代码根因其实是不小心把数据赋值给了非响应式的普通变量比如let user {} user JSON.parse(localStorage.getItem(user)) // user 是普通变量不是响应式变量4.3 数组方法为啥有些灵有些不灵数组的情况和对象类似。Vue 3 的响应式系统通过 Proxy 拦截了数组的索引访问、属性修改、length变化以及push/pop/shift/unshift/splice/sort/reverse这些方法。但注意这要求数组本身处于响应式状态下script setup import { reactive } from vue const list reactive([]) // 从本地存储读取并填充数组 function loadList() { const stored JSON.parse(localStorage.getItem(list) || []) // 方式一整体替换响应式 list.splice(0, list.length, ...stored) // 这种方式 spring 不行用 push 每项会更稳 // stored.forEach(item list.push(item)) } /script如果你这样写let list JSON.parse(localStorage.getItem(list) || []) list.push({ id: 1 }) // 这个 list 不是响应式的页面不会更新这就解释了为什么有的数组方法灵有的不灵——核心不在于方法本身而在于是不是响应式数组。实战中我的建议是数据从存储层出来第一时间交给响应式容器接管后续不要再用普通变量保存同一份引用。这样可以避免大部分赋值了页面不更新的问题。5. 封装一个 useStorage 模块把类型和响应式一次性解决前面讲了这么多问题现在来把它们收敛掉。项目开发中我从来不直接在组件里散落地调用localStorage.getItem而是封装一个useStorage模块统一处理序列化、反序列化、类型还原和响应式联动。5.1 设计目标与对外 API这个模块要解决的核心问题有三个自动处理 JSON 格式的读写、自动把读取结果接回 Vue 响应式系统、对外暴露统一且易用的 API。最终用法长这样const { data, setData, removeData } useStorage(user, { name: 张三 }) // data 是响应式的修改 data.name 后自动写回 localStorage // setData 允许整体替换数据 // removeData 清除指定 key设计上我把变量命名为data而不是user是为了让封装具备通用性。你可以在多个组件里分别存储不同的 key。5.2 核心代码实现下面给出一个可直接复制到项目的实现基于 Vue 3 的 ref 和 watchimport { ref, watch } from vue const STORAGE_PREFIX app_ function normalizeKey(key) { return STORAGE_PREFIX key } export function useStorage(key, defaultValue null) { const storageKey normalizeKey(key) const data ref(defaultValue) // 初始化从 localStorage 读取并解析 try { const stored localStorage.getItem(storageKey) if (stored ! null stored ! undefined) { // 这里使用自定义反序列化避免 Date/NaN 等丢失 data.value deserialize(stored) } } catch (e) { console.warn([useStorage] 读取 ${key} 失败, e) } // 响应式变化时自动写回 watch( data, (newValue) { try { const str serialize(newValue) localStorage.setItem(storageKey, str) } catch (e) { console.error([useStorage] 写入 ${key} 失败, e) } }, { deep: true } ) function setData(value) { data.value value } function removeData() { localStorage.removeItem(storageKey) data.value null } return { data, setData, removeData } } function serialize(value) { return JSON.stringify(value, (key, val) { if (val instanceof Date) { return { __type: Date, value: val.getTime() } } if (typeof val bigint) { return { __type: BigInt, value: val.toString() } } if (Number.isNaN(val)) { return { __type: NaN } } if (val Infinity) { return { __type: Infinity } } return val }) } function deserialize(str) { return JSON.parse(str, (key, value) { if (value typeof value object) { switch (value.__type) { case Date: return new Date(value.value) case BigInt: return BigInt(value.value) case NaN: return NaN case Infinity: return Infinity } } return value }) }5.3 为什么用 watch 而不是手动同步很多自行封装过的同学会问为什么不直接用data.value的 getter/setter 手动同步而是用watch我的理由是watch天然处理了数据什么时候变化的问题你不需要在每个修改data的地方手动调用写回函数。比如你有一个复杂表单绑定了data.name、data.age、data.address.city用户每改一个字段watch的deep: true都会自动把整个对象序列化写回。如果走 getter/setter 方案你得在每一个输入事件里手动触发写多了容易漏。5.4 多组件共享与注意事项封装之外还有几个经验要交代。同一个 key 如果被多个组件同时useStorage在同一个页面生命周期内因为存储模块做了响应式包装两个组件的 data 会指向同一个对象的两个不同代理引用它们之间的同步依赖于localStorage的写入和读取。这个流程在同一个标签页里是同步的可用但跨标签页时就不会自动同步了要靠storage事件。另外存储空间的溢出不能用 try/catch 完全解决。iOS 部分浏览器在隐私模式下localStorage.setItem会直接抛 QuotaExceededError我的模块里已经把这个包进了 try/catch但业务层最好也有降级方案比如判断写入失败后改用内存缓存保证页面逻辑不断链。6. 从本地存储调试到线上维护的实战笔记最后这一节我把实际项目中围绕本地存储做过的调试、排查、维护经验集中整理出来可能比上面任何一段代码都值钱。6.1 key 命名与版本迁移本地存储的 key 一旦混用线上排查成本极高。我见过一个项目里 key 有叫userInfo的有叫user-info的还有直接叫token的时间一长根本不知道哪个是哪个。我建议所有 key 统一加业务前缀比如app_user_info再用一个常量文件集中管理// storage-key.js export const STORAGE_KEYS { USER_INFO: app_user_info, CART_LIST: app_cart_list, THEME: app_theme, SETTINGS: app_settings }另外一个必须提前设计的点是数据结构版本号。本地存储不像后端数据库有迁移工具一旦上线后结构变了旧版本读出来就是脏数据。我的做法是存一份带 version 的元信息const CART_KEY app_cart_list_v2如果结构发生重大调整直接换 key 名同时把旧的 key 用脚本清掉。这样不会把新逻辑和旧数据混在一起。6.2 跨标签页同步的正确姿势同源下多个标签页操作同一份 localStorage数据并不会自动做到处处一致。原生解决方案是监听storage事件window.addEventListener(storage, (event) { if (event.key normalizeKey(user)) { window.dispatchEvent(new CustomEvent(user-storage-change, { detail: event.newValue })) } })但注意storage事件只在 ** 其他标签页 ** 写入 localStorage 时触发当前标签页自己写入不会触发。所以在使用useStorage时如果是数据来自服务端推送或定时器轮询我建议配合BroadcastChannel一起做这样同一页面内的多个组件也能收到通知。6.3 存不下、读不到的排查思路本地存储 5MB 的容量限制在纯浏览器环境里已经足够绝大多数页面使用了但遇到复杂表单、草稿缓存、图片 base64 等场景就会爆。遇到写入失效但没报错的情况我一般按这个链路排查先看setItem有没有抛异常尤其是隐私模式再查getItem(key)是否为null——可能是 key 没写成、写入到了别的域名下、或者之前写的时候被异常清掉了确认页面所在的域名、协议是否一致localhost和127.0.0.1是两个完全不同的存储空间检查开发者工具 Application 面板里的实际存储值跟业务预期是否匹配看是不是在无痕模式下某些浏览器完全禁用 localStorage我去年还遇到过一个很隐蔽的问题代码里用了一个叫window.localStorage的全局变量结果在某个 iframe 环境里父页面和子页面的存储域不一致读出来永远是空。后来把所有 localStorage 访问都收敛到统一的 storage 模块里这个问题才彻底解决。如果你项目里存的东西比较大还可以考虑换成 IndexedDB那又是一个独立的主题了。但在大部分表单配置、用户偏好设置、购物车轻量数据这些场景下localStorage 上面的封装方案已经足够稳。后面有机会我再单独写一篇用 IndexedDB 做离线数据缓存的内容欢迎持续关注。