Code前端首页关于Code前端联系我们

Vue3项目里怎么用watch实时监听localStorage的变化?有哪些避坑技巧?

terry 1天前 阅读数 223 #Vue

很多刚从Vue2转过来,或者刚开始写Vue3项目的开发者,遇到过一个挺挠头的问题:想让页面数据跟着localStorage里的某个值变,但直接写watch(() => localStorage.getItem('xxx'), () => {})根本没用,点击按钮改了localStorage里的内容,页面纹丝不动,这到底是为啥?怎么才能实现真正的实时监听?今天咱们就掰开揉碎了聊,从原理到可行方案,再把实际开发中容易踩的坑全列出来。

为啥Vue3原生watch监听不到localStorage变化?

先搞清楚底层逻辑,不然光记方案没用,下次换个场景又懵了,Vue3的响应式系统,不管是ref还是reactive,本质上都是通过Proxy拦截数据的读写操作,或者用defineProperty追踪单个属性的getter/setter,当这些被拦截的操作发生时,才会触发依赖收集和视图更新,那localStorage是什么?它是浏览器提供的全局存储对象,不属于Vue的响应式系统范畴——你直接调用localStorage.setItem()或者removeItem(),根本不会触发Vue内部的任何拦截逻辑,所以watch的回调自然不会被触发。

举个简单的例子对比下:你有个const count = ref(0),每次count.value++,都是通过Proxy修改了ref内部的value属性,Vue能“看到”;但你写localStorage.setItem('count', '1'),就像在Vue家门外偷偷放了个快递,Vue的门童(响应式系统)根本没收到通知,当然不会去开门。

可行的三种监听方案,各有优劣

既然原生watch不管用,就得自己搭个桥,把localStorage的变化“通知”给Vue,目前常用的有三种方案,大家可以根据项目场景选。

封装响应式存储钩子,把localStorage的值“搬”进Vue响应式系统

这是最稳妥、最符合Vue3开发习惯的方案,相当于给localStorage里的每个需要监听的key,都配一个Vue的ref/reactive“替身”,读写都通过替身来做,这样Vue的响应式系统就能正常工作了。

具体怎么做呢?可以写一个通用的useLocalStorage钩子,

import { ref, watch } from 'vue'
export function useLocalStorage(key, defaultValue = null) {
  // 初始化时从localStorage读取,如果没有就用默认值
  const storedValue = localStorage.getItem(key)
  const value = ref(storedValue ? JSON.parse(storedValue) : defaultValue)
  // 监听value的变化,一旦变了就同步到localStorage
  watch(value, (newVal) => {
    localStorage.setItem(key, JSON.stringify(newVal))
  }, { deep: true }) // 这里加deep是为了监听对象或数组的深层变化
  return value
}

然后在组件里直接用就行:

<script setup>
import { useLocalStorage } from './hooks/useLocalStorage'
// 把localStorage里的'theme'变成响应式的
const theme = useLocalStorage('theme', 'light')
function toggleTheme() {
  // 直接改theme.value,Vue会自动同步到localStorage
  theme.value = theme.value === 'light' ? 'dark' : 'light'
}
</script>
<template>
  <button @click="toggleTheme">切换{{ theme }}主题</button>
</template>

这个方案的好处是:完全符合Vue3的响应式思维,组件里不需要写任何额外的监听localStorage的代码,所有逻辑都封装在钩子里面,复用性极高;缺点是只能监听通过这个useLocalStorage钩子修改的localStorage值,如果有其他地方(比如浏览器控制台、第三方插件、同域名下的其他页面)直接改了localStorage,Vue还是不知道。

不过没关系,这个缺点可以结合方案二或者方案三来补全,但大多数单页应用场景下,只用这个钩子就足够了,因为应用里的localStorage修改一般都是通过代码控制的。

利用window的storage事件,监听同域名下所有localStorage的修改

刚才说的方案一的缺点,刚好可以用storage事件来解决,storage事件是浏览器原生提供的,当同域名下的任何页面(包括当前页面自己、其他标签页、iframe)修改了localStorage或者sessionStorage时,就会触发这个事件,不过这里有个小坑要注意:当前页面自己修改localStorage时,不会触发自己页面的storage事件——这是浏览器为了避免死循环特意设计的,比如你在当前页面改了值,触发事件又改回来,再触发再改,就无限循环了。

那怎么结合方案一和方案二用呢?可以在useLocalStorage钩子里面加一个监听storage事件的逻辑,当收到其他页面修改的通知时,更新自己的ref值:

import { ref, watch, onMounted, onUnmounted } from 'vue'
export function useLocalStorage(key, defaultValue = null) {
  const storedValue = localStorage.getItem(key)
  const value = ref(storedValue ? JSON.parse(storedValue) : defaultValue)
  // 监听自己代码里的修改,同步到localStorage
  watch(value, (newVal) => {
    localStorage.setItem(key, JSON.stringify(newVal))
  }, { deep: true })
  // 定义storage事件的回调函数
  const handleStorageChange = (e) => {
    // 只处理当前key的变化,且新值和旧值不一样时才更新
    if (e.key === key && e.newValue !== e.oldValue) {
      value.value = e.newValue ? JSON.parse(e.newValue) : defaultValue
    }
  }
  // 组件挂载时添加事件监听
  onMounted(() => {
    window.addEventListener('storage', handleStorageChange)
  })
  // 组件卸载时移除事件监听,避免内存泄漏
  onUnmounted(() => {
    window.removeEventListener('storage', handleStorageChange)
  })
  return value
}

这样结合之后,不管是当前页面通过useLocalStorage改的,还是其他标签页改的,ref值都会同步更新,页面也会跟着变,这个方案的好处是:覆盖了所有同域名下的localStorage修改场景;缺点是:需要手动管理事件监听的挂载和卸载(虽然钩子已经帮你做了,但如果不用钩子的话容易忘),而且当前页面直接用localStorage.setItem()改的还是监听不到——但既然你都用了这个钩子,为什么还要直接调用原生API呢?尽量统一用钩子就行。

重写localStorage的setItem、removeItem、clear方法,手动发布事件通知

如果你的项目里,必须有一些地方直接调用原生的localStorage API(比如引入了第三方库,第三方库会直接改localStorage),那刚才的方案二就不够用了,因为当前页面自己的原生API修改不会触发storage事件,这时候可以考虑重写localStorage的几个核心方法,在方法执行完之后,手动发布一个自定义事件,然后Vue的钩子或者组件监听这个自定义事件就行。

重写的代码大概长这样,可以放在项目的入口文件(比如main.js或者main.ts)里:

// 保存原始的localStorage方法
const originalSetItem = localStorage.setItem.bind(localStorage)
const originalRemoveItem = localStorage.removeItem.bind(localStorage)
const originalClear = localStorage.clear.bind(localStorage)
// 重写setItem
localStorage.setItem = function(key, value) {
  // 先获取旧值,后面自定义事件要用
  const oldValue = localStorage.getItem(key)
  // 调用原始方法执行真正的存储
  originalSetItem(key, value)
  // 发布自定义事件,传递key、oldValue、newValue
  window.dispatchEvent(new CustomEvent('localStorageChange', {
    detail: { key, oldValue, newValue: value }
  }))
}
// 重写removeItem
localStorage.removeItem = function(key) {
  const oldValue = localStorage.getItem(key)
  originalRemoveItem(key)
  window.dispatchEvent(new CustomEvent('localStorageChange', {
    detail: { key, oldValue, newValue: null }
  }))
}
// 重写clear
localStorage.clear = function() {
  originalClear()
  window.dispatchEvent(new CustomEvent('localStorageChange', {
    detail: { key: null, oldValue: null, newValue: null }
  }))
}

然后再修改一下useLocalStorage钩子,把storage事件换成刚才定义的localStorageChange自定义事件:

// 只需要修改事件监听的部分,其他不变
onMounted(() => {
  window.addEventListener('localStorageChange', handleStorageChange)
})
onUnmounted(() => {
  window.removeEventListener('localStorageChange', handleStorageChange)
})

这样之后,不管是通过useLocalStorage改的,还是直接调用原生API改的,还是第三方库改的,ref值都会同步更新,这个方案的好处是:覆盖了所有场景的localStorage修改,包括当前页面的原生API调用;缺点是:重写了浏览器的原生API,虽然一般不会有问题,但如果浏览器未来更新了localStorage的方法签名,或者有其他代码也重写了这些方法,可能会产生冲突——所以尽量把重写的代码放在项目的最前面执行,确保其他代码调用的是你重写后的方法。

实际开发中一定要注意的5个避坑技巧

聊完了方案,再说说避坑,这些都是我自己踩过或者见身边朋友踩过的,每一个都能让你少花半小时调试。

避坑1:不要忘记JSON序列化和反序列化

localStorage只能存储字符串类型的数据,如果你直接存数字、布尔值、对象、数组,取出来的时候都会变成字符串,比如存123取出来是"123",存true取出来是"true",存{a: 1}取出来是"[object Object]"——这个是localStorage本身的特性,和Vue没关系,但很多新手会忘。

刚才的useLocalStorage钩子里面已经加了JSON.parse和JSON.stringify,但如果你自己写逻辑的话,一定要记得:存的时候用JSON.stringify转成字符串,取的时候用JSON.parse转回来,还要处理可能的JSON解析错误(比如localStorage里的内容被手动改成了非法的JSON格式),比如可以加个try-catch:

let storedValue
try {
  storedValue = JSON.parse(localStorage.getItem(key))
} catch (e) {
  storedValue = defaultValue
}

避坑2:deep选项不要随便加,但对象数组一定要加

在watch的配置项里加deep: true,会让Vue递归监听对象或数组的每一个属性的变化,虽然方便,但会消耗一定的性能——如果只是监听一个简单的字符串、数字、布尔值,完全不需要加deep;但如果监听的是对象或数组,比如用户信息、购物车列表,不加deep的话,只有当整个对象或数组的引用发生变化时(比如直接赋值user.value = {}),才会触发回调,修改里面的某个属性(比如user.value.name = '张三')是不会触发的。

那怎么平衡性能和功能呢?如果对象或数组的结构比较简单,只有几层,加deep没问题;如果结构非常复杂,有几十层上百层,或者数据量很大(比如几万条的数组),可以考虑用watchEffect或者只监听需要的属性,

// 只监听user的name属性,不监听整个user对象
watch(() => user.value.name, (newName) => {
  console.log('name变了', newName)
})

避坑3:组件卸载时一定要移除事件监听

刚才的方案二和方案三都用到了事件监听,如果组件卸载时不移除,这些事件监听会一直存在于内存中,下次组件再挂载又会添加新的,就会导致内存泄漏——虽然现代浏览器的内存管理已经很好了,但积少成多,还是会影响页面的性能,甚至导致页面卡顿。

Vue3的组合式API里提供了onUnmounted生命周期钩子,刚好可以用来做这个,刚才的useLocalStorage钩子已经加了,如果你不用钩子,自己在组件里写事件监听的话,一定不要忘了。

避坑4:watch的第一个参数如果是函数,要确保返回值的响应式

很多人知道可以用watch(() => localStorage.getItem('xxx'), ...),但不知道为什么没用——刚才已经解释过了,localStorage.getItem()不属于Vue的响应式系统,返回的是一个普通的字符串,Vue的响应式系统追踪不到它的变化。

那什么时候可以用函数作为watch的第一个参数呢?当函数里面用到了Vue的响应式数据时,比如watch(() => count.value + 1, ...),这时候函数返回值的变化是依赖于count.value的,Vue能追踪到。

避坑5:不要频繁修改localStorage

localStorage的读写操作是同步的,而且是磁盘I/O操作——虽然浏览器对localStorage做了缓存,读写速度很快,但频繁修改还是会影响页面的性能,比如在输入框的input事件里直接修改localStorage:

<input v-model="keyword" @input="saveKeyword">
function saveKeyword() {
  localStorage.setItem('keyword', keyword.value)
}

如果用户打字很快,每敲一个键就会触发一次磁盘I/O,虽然现在的浏览器可能优化了,但最好还是加个防抖:

import { ref, watch } from 'vue'
import { debounce } from 'lodash-es' // 或者自己写个简单的防抖函数
const keyword = ref('')
// 用lodash的debounce,延迟500ms再保存
const saveKeywordDebounced = debounce((newVal) => {
  localStorage.setItem('keyword', newVal)
}, 500)
watch(keyword, saveKeywordDebounced)

这样用户停止打字500ms之后,才会保存到localStorage,大大减少了磁盘I/O的次数。

今天咱们聊了Vue3里监听localStorage变化的三个方案,还有五个避坑技巧,

  • 原生watch监听不到localStorage,因为localStorage不属于Vue的响应式系统;
  • 最常用的方案是封装useLocalStorage钩子,把localStorage的值变成Vue的响应式数据;
  • 如果需要监听其他标签页的修改,结合window的storage事件;
  • 如果需要监听当前页面的原生API修改,重写localStorage的方法,手动发布自定义事件;
  • 避坑技巧:记得JSON序列化和反序列化、合理使用deep选项、组件卸载时移除事件监听、确保watch参数的响应式、不要频繁修改localStorage。

这三个方案可以根据项目的实际情况组合使用,比如大多数场景用方案一,需要跨标签页同步用方案一+方案二,有第三方库直接改localStorage用方案一+方案二+方案三,灵活运用就行。

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门