Vue 本地存储实战:类型转换、序列化与响应式一次讲透
2026/9/9 14:25:41 网站建设 项目流程

1. 存储落地前必须想清楚的三个问题:类型、序列化、响应式

前阵子帮同事排查一个线上问题,用户反馈"某个开关设置保存不了,关掉页面再进来就又变成开启状态了"。代码看了一圈,localStorage 写入、读取都做了,逻辑也没毛病。后来在控制台手动跑了一遍才发现,开关的布尔值读取出来之后变成了字符串"false",而"false"在 if 判断里是个真值,于是"关掉"就被当成"开启"处理了。

这种问题在 Vue 项目里太典型了,而且往往不是业务逻辑写错,而是栽在了一个基础认知上:本地存储(localStorage / sessionStorage)天生只能存字符串。你给它一个数字 18,它还给你字符串"18";你给它一个布尔值 false,它还给你字符串"false";你给它一个对象,它干脆调用了toString()返回"[object Object]"。而 Vue 项目的核心数据形态恰恰就是基础类型、对象、数组这三类,所以"Vue 本地存储"这个命题,本质上是三个问题的叠加:类型转换怎么做、序列化怎么处理、Vue 的响应式怎么联动。今天这篇就围绕这三件事把代码和思路一次讲透。

这篇文章适合刚把 Vue 基础语法学完、开始在真实项目中写存储逻辑的开发者,也适合项目里已经积累了不少"能用但浑身难受"的存储代码、想系统性整理一下的同行。下面每个小节都有可以直接复制使用的代码片段,也会把为什么这么写、不这么写会出什么问题讲清楚。

2. 基础类型进本地存储:类型失真才是头号坑,不是存不进去

很多新手以为 localStorage 用起来就是setItemgetItem两行代码的事,直到在真机环境里读出数据后发现自己被"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) // 实际存储的是字符串 "undefined"

console.log 里看着像是 null 和 undefined,但它们的类型都是 string。更折磨人的是localStorage.getItem('不存在的key')返回的是真正的null,不是字符串"null"。于是你会遇到一种诡异情况:

const data = localStorage.getItem('user') if (data) { } // 有时候进不来,有时候进来却是 "null"

同一个 null,来源不同行为不同。处理办法是在读取后统一做一次"字符串转实际类型"的判断,这一步最好收敛到同一个工具函数里,不要散落在业务各处。

下表是我整理的 localStorage 读写真实行为对照,建议截图保存:

写入值实际存储内容读取返回值读取后 typeof是否等于原值
28"28""28"string
"hello""hello""hello"string
true"true""true"string
false"false""false"string
null"null""null"string
undefined"undefined""undefined"string
{ name: '张三' }"[object Object]""[object Object]"string彻底丢失
[1, 2, 3]"1,2,3""1,2,3"string数组变字符串

基础类型这块的结论很直接:所有读取操作都必须经过"字符串还原成正确类型"的步骤,这个步骤放在业务里很容易漏,必须收敛到一个公共模块。

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 object,Vue 不会自动跟踪它内部的属性变化。看看这个反面教材:

<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' // 新增属性也是响应式 } </script>

Vue 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 和 watch:

import { 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.namedata.agedata.address.city,用户每改一个字段,watchdeep: 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 没写成、写入到了别的域名下、或者之前写的时候被异常清掉了
  • 确认页面所在的域名、协议是否一致,localhost127.0.0.1是两个完全不同的存储空间
  • 检查开发者工具 Application 面板里的实际存储值,跟业务预期是否匹配
  • 看是不是在无痕模式下,某些浏览器完全禁用 localStorage

我去年还遇到过一个很隐蔽的问题:代码里用了一个叫window.localStorage的全局变量,结果在某个 iframe 环境里,父页面和子页面的存储域不一致,读出来永远是空。后来把所有 localStorage 访问都收敛到统一的 storage 模块里,这个问题才彻底解决。

如果你项目里存的东西比较大,还可以考虑换成 IndexedDB,那又是一个独立的主题了。但在大部分表单配置、用户偏好设置、购物车轻量数据这些场景下,localStorage + 上面的封装方案已经足够稳。后面有机会我再单独写一篇用 IndexedDB 做离线数据缓存的内容,欢迎持续关注。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询