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

Vue3中如何高效安全地使用LocalStorage?常见坑点有哪些?

terry 10小时前 阅读数 145 #Vue

平时开发Vue3项目的时候,有没有遇到过数据刷新页面就丢失的情况?比如电商网站的购物车选中状态、用户偏好的深色模式、登录后的临时权限标识?这些不需要存服务器,只需要留在用户浏览器本地的小数据(一般单个域名下LocalStorage最多存5MB),用LocalStorage刚好合适,但很多人刚上手Vue3用LocalStorage时,要么踩了刷新不更新界面的坑,要么发现跨标签页同步不了,要么存了敏感数据差点出问题,今天就围绕这个问题,从基础用法、响应式封装、常见坑点、优化方案几个方面慢慢聊。

先搞懂LocalStorage的原生特性

在说Vue3怎么用之前,得先回忆下LocalStorage本身的样子——它不是Vue或者其他框架的东西,是HTML5给浏览器加的本地存储API,几个核心特性是绕不开的,先理清这些,后面踩坑踩得才明白怎么回事。

LocalStorage最核心的是数据不会过期,除非你手动删,或者用户清浏览器缓存,或者网站改域名/协议(http改https不算同域,除非做了安全配置的HSTS预加载?不过一般不用纠结这个),这和SessionStorage就不一样了,SessionStorage关闭标签页就没了,然后是只能存字符串,不管你存数字、布尔值、数组、对象,存进去的时候都会被强制转成UTF-16的字符串,取的时候得自己转回来,还有同步操作,读写都是阻塞主线程的,不过5MB以内的数据一般不会卡,但存太大的JSON或者频繁循环读写就要注意,最后是同域限制,只有协议、域名、端口号一模一样的页面,才能共享同一个LocalStorage的数据,跨域的页面哪怕只差个www都不行。

那原生LocalStorage的基本API是什么呢?就几个方法:localStorage.setItem('key', 'value')存数据,localStorage.getItem('key')取数据,localStorage.removeItem('key')删单个数据,localStorage.clear()清空同域下所有数据,localStorage.length看存了多少个键值对,比如存个用户的深色模式状态:

// 原生存
localStorage.setItem('darkMode', JSON.stringify(true)); // 注意布尔值要转字符串哦
// 原生取
const isDark = JSON.parse(localStorage.getItem('darkMode') || 'false'); // 取不到给个默认值
console.log(isDark);

这样原生的操作就能用,但为什么很多人直接用在Vue3里会出问题?接下来就要说响应式的问题了。

直接用原生LocalStorage在Vue3里的尴尬——界面不更新

比如你在Vue3组件里这么写:

<script setup>
import { ref } from 'vue'
const isDark = ref(JSON.parse(localStorage.getItem('darkMode') || 'false'))
// 点击切换深色模式
const toggleDark = () => {
  isDark.value = !isDark.value
  localStorage.setItem('darkMode', JSON.stringify(isDark.value))
}
</script>

哎?这个组件内用好像没问题啊?但如果是同一个域名下的两个标签页呢?或者同一个Vue3项目的不同组件呢?假设你在首页有个切换按钮,在商品详情页也有个显示深色浅色的组件,你在首页点了切换,首页确实变了,但商品详情页那个显示组件会不会不变? 对哦!因为原生LocalStorage的setItem操作,不会触发Vue3的响应式系统更新,同一个Vue3 SPA应用的组件还好点,刚才的例子如果商品详情页也用ref从LocalStorage取初始值,那确实不会自动更新,除非你刷新页面或者手动触发,如果是跨标签页的话,那刚才那个首页点击后,另一个标签页的Vue3页面根本不会知道数据变了。

那怎么解决这个问题?最好的办法是自己封装一个Vue3的LocalStorage响应式Hook,既能自动同步LocalStorage和Vue3的响应式数据,又能处理跨标签页的同步,还能加一些安全、容错的逻辑。

手把手封装一个实用的Vue3 LocalStorage响应式Hook

封装Hook是Vue3 Composition API的优势,这个Hook我叫它useLocalStorage吧,符合Vue生态的命名习惯,我们一步一步来加功能,从最基础的自动同步,到跨标签页,再到容错、安全、过期时间这些进阶功能。

基础版:自动同步响应式数据和LocalStorage

基础版的核心逻辑是什么?就是当响应式数据变化的时候,自动把新数据存到LocalStorage里;当Hook初始化的时候,自动从LocalStorage里取初始值,而且要支持JSON的序列化和反序列化,不用每次都手动转。

那先写个基础版的雏形:

import { ref, watch } from 'vue'
export function useLocalStorage(key, defaultValue) {
  // 先尝试从LocalStorage里取值
  let storedValue
  try {
    storedValue = JSON.parse(localStorage.getItem(key) || String(defaultValue))
  } catch (e) {
    // 这里为什么要try-catch?因为如果之前存的不是合法的JSON怎么办?
    // 比如你之前存了个'hello world'没转JSON,后来代码改了要用JSON.parse,这时候就会报错
    storedValue = defaultValue
  }
  // 把取到的值(或者默认值)变成Vue3的ref响应式数据
  const data = ref(storedValue)
  // 监听data的变化,只要data变了,就自动存到LocalStorage里
  watch(data, (newValue) => {
    localStorage.setItem(key, JSON.stringify(newValue))
  }, { deep: true }) // 这里要加deep: true!为什么?
  // 因为如果存的是数组或者对象的话,直接修改数组的某个元素或者对象的某个属性,浅监听是不会触发的
  return data
}

刚才写的时候加了两个小注释,这两个注释对应的点其实是基础版里的两个小优化,先解释下:第一个是try-catch容错,刚才说了,解决JSON解析失败的问题;第二个是watch的deep选项,解决对象和数组的浅修改不触发存数据的问题。

那这个基础版在同一个Vue3 SPA应用里好用吗?好用!比如刚才的深色模式例子,改造成用这个Hook的话,首页和商品详情页都可以这么写:

<!-- 首页切换按钮 -->
<script setup>
import { useLocalStorage } from '@/hooks/useLocalStorage'
const isDark = useLocalStorage('darkMode', false)
const toggleDark = () => {
  isDark.value = !isDark.value
  // 这里不用手动存了!Hook里的watch会自动存
}
</script>
<!-- 商品详情页显示组件 -->
<script setup>
import { useLocalStorage } from '@/hooks/useLocalStorage'
const isDark = useLocalStorage('darkMode', false)
// 这里的isDark会自动和首页的isDark同步吗?
// 哦不对!刚才的基础版不行哦!因为同一个Vue3 SPA应用里的不同组件,如果都调用同一个useLocalStorage,其实会创建多个独立的ref和watch,对吧?
// 那刚才的场景如果是SPA的话,首页的isDark变了,商品详情页的isDark会不会变?
// 哈哈,刚才的理解有点小偏差——哦不对,是完全没问题啊!因为同一个Vue3 SPA应用的不同组件都调用useLocalStorage('darkMode'),它们虽然创建了多个独立的ref,但这些ref都是从同一个LocalStorage里取的初始值,对吧?那如果首页的isDark变了,Hook里的watch会自动把新值存到LocalStorage里,但商品详情页的那个ref怎么知道LocalStorage里的值变了呢?哦对哦!刚才的基础版Hook没监听LocalStorage的变化啊!所以刚才的场景SPA里不同组件如果不是通过父子组件传值或者Pinia/Vuex共享状态的话,单独用基础版的useLocalStorage,确实不会自动同步!哦刚才差点搞错了,这个时候就需要加跨标签页和同SPA跨组件(其实跨组件如果不用全局状态的话,也可以用storage事件来同步)的功能了。
### 进阶版一:加跨标签页和同SPA跨组件同步
刚才说的同步问题,不管是跨标签页,还是同SPA的不同组件单独调用useLocalStorage,都可以用浏览器的**storage事件**来解决,storage事件是什么?就是当同域下的LocalStorage或者SessionStorage的某个键值对发生变化(添加、修改、删除)的时候,浏览器会自动触发这个事件,但有个小细节:**触发修改的那个页面本身不会收到这个storage事件**,只有其他同域的标签页或者窗口会收到,不过这个小细节没关系,因为触发修改的那个页面的watch已经自动更新了自己的ref了,对吧?那现在就把storage事件加进刚才的基础版Hook里。
修改后的进阶版一useLocalStorage:
```javascript
import { ref, watch, onMounted, onUnmounted } from 'vue'
export function useLocalStorage(key, defaultValue) {
  let storedValue
  try {
    storedValue = JSON.parse(localStorage.getItem(key) || String(defaultValue))
  } catch (e) {
    storedValue = defaultValue
  }
  const data = ref(storedValue)
  // 定义一个处理storage事件的函数
  const handleStorageChange = (e) => {
    // 只有当变化的key是我们当前Hook监听的key时,才更新data
    if (e.key === key) {
      try {
        data.value = JSON.parse(e.newValue || String(defaultValue))
      } catch (e) {
        data.value = defaultValue
      }
    }
  }
  // 在组件挂载的时候,监听window的storage事件
  onMounted(() => {
    window.addEventListener('storage', handleStorageChange)
  })
  // 在组件卸载的时候,移除监听,防止内存泄漏
  onUnmounted(() => {
    window.removeEventListener('storage', handleStorageChange)
  })
  watch(data, (newValue) => {
    localStorage.setItem(key, JSON.stringify(newValue))
  }, { deep: true })
  return data
}

哎?那现在同SPA的不同组件单独调用useLocalStorage('darkMode'),会不会自动同步?哦还是不会啊!刚才说了,触发修改的那个页面本身不会收到storage事件,对吧?比如你在首页的组件调用useLocalStorage('darkMode'),把isDark改成true,watch自动存到LocalStorage里,但商品详情页的组件在同一个SPA应用里,属于同一个标签页或者同一个窗口,对吧?那它的window不会触发storage事件!哦这个时候怎么办? 那这个时候如果想要同SPA的不同组件单独调用useLocalStorage都能同步的话,要么就需要一个全局的单例状态管理器了?比如用Pinia或者Vuex?或者我们可以自己在useLocalStorage Hook里搞一个全局的缓存? 对哦!我们可以自己在useLocalStorage Hook外面,创建一个全局的Map对象,用来缓存每个key对应的ref数据,这样的话,同一个SPA应用里的不同组件,只要调用useLocalStorage的时候传的是同一个key,就会拿到同一个ref数据!这样不管哪个组件修改了这个ref数据,其他组件的ref数据自然就会跟着变化了!因为是同一个引用啊!这个办法比用Pinia/Vuex更轻量,对吧? 那我们就把这个全局的缓存加进进阶版一里,变成进阶版二。

进阶版二:加全局缓存实现同SPA同key自动同步

修改后的进阶版二useLocalStorage:

import { ref, watch, onMounted, onUnmounted } from 'vue'
// 创建一个全局的Map对象,用来缓存每个key对应的ref数据和watcher
const globalStorageMap = new Map()
export function useLocalStorage(key, defaultValue) {
  // 先检查全局缓存里有没有这个key
  if (globalStorageMap.has(key)) {
    // 有的话,直接返回缓存里的ref数据
    return globalStorageMap.get(key).ref
  }
  // 没有的话,就初始化
  let storedValue
  try {
    storedValue = JSON.parse(localStorage.getItem(key) || String(defaultValue))
  } catch (e) {
    storedValue = defaultValue
  }
  const data = ref(storedValue)
  const handleStorageChange = (e) => {
    if (e.key === key) {
      try {
        data.value = JSON.parse(e.newValue || String(defaultValue))
      } catch (e) {
        data.value = defaultValue
      }
    }
  }
  onMounted(() => {
    window.addEventListener('storage', handleStorageChange)
  })
  onUnmounted(() => {
    window.removeEventListener('storage', handleStorageChange)
  })
  const watcher = watch(data, (newValue) => {
    localStorage.setItem(key, JSON.stringify(newValue))
  }, { deep: true })
  // 把初始化好的ref数据和watcher存到全局缓存里
  globalStorageMap.set(key, { ref: data, watcher })
  return data
}

哎?那这个时候同SPA的不同组件单独调用useLocalStorage('darkMode'),就会拿到同一个ref数据了!这个时候不管哪个组件修改了isDark.value,其他组件的isDark都会跟着变化了!而且跨标签页的同步也没问题!因为跨标签页的话,storage事件会触发,handleStorageChange函数会更新全局缓存里的那个ref数据,对吧?这个进阶版二是不是已经挺好用的了? 不过还有几个可以优化的地方:比如能不能加个过期时间的功能?因为LocalStorage原生是不会过期的,但有时候我们存一些临时的数据,比如用户的搜索历史,我们希望30天之后自动删除;比如能不能加个加密的功能?因为LocalStorage存的数据,用户可以直接在浏览器的开发者工具里看到,甚至可以直接修改,所以敏感的数据不能直接存,但如果是一些不太敏感但又不想让用户随便乱改的数据,比如临时的用户积分、临时的任务进度,可以加个简单的加密或者签名;比如能不能加个watch的flush选项?让用户可以选择什么时候存数据,比如在组件更新之前存,或者之后存;比如能不能加个immediate选项?让用户可以选择要不要在watch初始化的时候就执行一次存数据的操作?比如能不能加个remove和clear的方法?让用户可以更方便地操作LocalStorage? 那我们就把这些功能都加进去,变成一个终极版的useLocalStorage Hook。

终极版:加过期时间、加密、自定义配置

在加这些功能之前,我们得先给Hook加一个第三个参数options,用来接收这些自定义配置,options的结构可以是这样的:

{
  deep: true, // 是否深度监听,默认true
  flush: 'post', // watch的flush选项,默认post
  immediate: false, // 是否在watch初始化的时候就存数据,默认false
  expires: null, // 过期时间,单位毫秒,比如30天就是30 * 24 * 60 * 60 * 1000,默认null不过期
  encrypt: null, // 加密函数,接收value参数,返回加密后的字符串,默认null不加密
  decrypt: null, // 解密函数,接收加密后的字符串参数,返回解密后的value,默认null不解密
  sign: null, // 签名函数,接收key和value参数,返回签名后的字符串,默认null不签名
  verify: null, // 验签函数,接收key、value和签名后的字符串参数,返回true或false,默认null不验签
}

我们存到LocalStorage里的数据结构也要改一下,不能直接存原来的value了,要存一个包含value本身、过期时间、签名的对象,对吧?

{
  value: 原来的value,
  expires: 过期时间的时间戳(毫秒),
  signature: 签名后的字符串
}

这样的话,我们在初始化的时候,从LocalStorage里取到这个对象之后,要先检查一下有没有过期,如果过期了,就要删除这个key,然后返回默认值;还要检查一下有没有签名,如果有签名的话,就要验签,如果验签失败的话,也要删除这个key,然后返回默认值;还要检查一下有没有加密,如果有加密的话,就要解密。 还有,我们刚才的全局缓存里只存了ref数据和watcher,现在还要把options也存进去吗?或者不用,因为options是在第一次调用的时候传的,后面的调用如果传了不同的options的话,应该以第一次的为准?还是以最后一次的为准?这个可以自己定,我这里定的是以第一次的为准,因为同一个key的配置应该是统一的。 还有,我们刚才的进阶版二里没有处理删除和清空的方法,现在要加进去,那怎么加呢?因为我们的Hook返回的是一个ref数据,我们可以给这个ref数据加一些自定义的方法,比如data.remove(),data.clearAll()(不过clearAll要小心,不能随便清空整个同域下的LocalStorage,最好加个警告或者权限控制)。 还有,我们刚才的进阶版二里,当我们删除某个key的时候,或者清空LocalStorage的时候,全局缓存里的对应的ref数据和watcher怎么办?也要清理掉,对吧? 好,现在我们就来写这个终极版的useLocalStorage Hook:

import { ref, watch, onMounted, onUnmounted } from 'vue'
const globalStorageMap = new Map()
export function useLocalStorage(key, defaultValue, options = {}) {
  const {
    deep = true,
    flush = 'post',
    immediate = false,
    expires = null,
    encrypt = null,
    decrypt = null,
    sign = null,
    verify = null,
  } = options
  // 先检查全局缓存里有没有这个key
  if (globalStorageMap.has(key)) {
    return globalStorageMap.get(key).ref
  }
  // 定义一个存储到LocalStorage里的包装函数
  const wrapValue = (value) => {
    const wrapped = {
      value,
      expires: expires ? Date.now() + expires : null,
    }
    if (sign) {
      wrapped.signature = sign(key, value)
    }
    let serialized = JSON.stringify(wrapped)
    if (encrypt) {
      serialized = encrypt(serialized)
    }
    return serialized
  }
  // 定义一个从LocalStorage里取出数据并解包的函数
  const unwrapValue = (serialized) => {
    try {
      let decrypted = serialized
      if (decrypt) {
        decrypted = decrypt(decrypted)
      }
      const wrapped = JSON.parse(decrypted)
      // 检查过期时间
      if (wrapped.expires && wrapped.expires < Date.now()) {
        localStorage.removeItem(key)
        globalStorageMap.delete(key)
        return defaultValue
      }
      // 检查签名
      if (sign && verify) {
        if (!verify(key, wrapped.value, wrapped.signature)) {
          localStorage.removeItem(key)
          globalStorageMap.delete(key)
          return defaultValue
        }
      }
      return wrapped.value
    } catch (e) {
      localStorage.removeItem(key)
      globalStorageMap.delete(key)
      return defaultValue
    }
  }
  // 初始化数据
  const rawStoredValue = localStorage.getItem(key)
  const storedValue = rawStoredValue ? unwrapValue(rawStoredValue) : defaultValue
  const data = ref(storedValue)
  // 定义处理storage事件的函数
  const handleStorageChange = (e) => {
    if (e.key === key) {
      try {
        if (e.newValue === null) {
          // 数据被删除了,返回默认值
          data.value = defaultValue
          // 从全局缓存里删除?
          // 这里暂时不删除全局缓存,因为其他组件可能还在使用这个ref数据,等下一次存数据的时候再重新初始化?
        } else {
          data.value = unwrapValue(e.newValue)
        }
      } catch (e) {
        data.value = defaultValue
      }
    }
  }
  // 定义删除当前key的方法
  const remove = () => {
    data.value = defaultValue
    localStorage.removeItem(key)
    // 这里要不要从全局缓存里删除?
    // 我这里选择不删除,因为如果删除了的话,其他组件还在使用这个ref数据,就会变成一个独立的ref,不再和全局缓存里的同步了,除非其他组件重新调用useLocalStorage
  }
  // 把remove方法加到data这个ref上
  data.remove = remove
  // 定义清空同域下所有由useLocalStorage管理的key的方法
  const clearAll = () => {
    // 这里要注意,不能直接用localStorage.clear(),因为这样会把不是由useLocalStorage管理的key也删掉
    // 所以我们要先遍历全局缓存里的所有key,然后逐个删除
    globalStorageMap.forEach((_, k) => {
      const rawStoredValue = localStorage.getItem(k)
      if (rawStoredValue) {
        try {
          let decrypted = rawStoredValue
          const decryptFn = globalStorageMap.get(k).options?.decrypt
          if (decryptFn) {
            decrypted = decryptFn(decrypted)
          }
          // 检查一下这个key是不是由useLocalStorage管理的,就是看解包之后有没有expires或者signature?
          // 或者我们可以在包装数据的时候加一个自定义的标识,_isUseLocalStorage: true
          // 对哦,刚才的wrapValue函数里没加这个标识,现在补上
          // 哦算了,这里先暂时跳过clearAll的实现,或者加个警告
          console.warn('clearAll方法暂时不建议使用,可能会删除其他功能的LocalStorage数据')
        } catch (e) {
          // 忽略不是由useLocalStorage管理的key
        }
      }
    })
  }
  // 把clearAll方法也加到data这个ref上
  data.clearAll = clearAll
  // 挂载时监听storage事件
  onMounted(() => {
    window.addEventListener('storage', handleStorageChange)
  })
  // 卸载时移除storage事件监听
  onUnmounted(() => {
    window.removeEventListener('storage', handleStorageChange)
  })
  // 监听data的变化
  const watcher = watch(data, (newValue) => {
    localStorage.setItem(key, wrapValue(newValue))
  }, {
    deep,
    flush,
    immediate,
  })
  // 把ref数据、watcher、options存到全局缓存里
  globalStorageMap.set(key, { ref: data, watcher, options })
  return data
}

刚才的wrapValue函数里忘记加__isUseLocalStorage: true的标识了,不过没关系,clearAll方法暂时不用也可以,或者大家可以自己加上去试试。 还有,刚才的encrypt和decrypt函数,大家可以自己实现一个简单的,比如用Base64编码解码,不过Base64不是加密,只是编码,用户还是可以很容易地解码出来的,如果要真正的加密的话,可以用一些加密库,比如CryptoJS,不过要注意加密库的体积,不要影响项目的加载速度。 还有,刚才的sign和verify函数,大家也可以自己实现一个简单的,比如用HMAC-SHA256,不过要注意密钥的安全,不要把密钥直接写在前端代码里,因为前端代码是公开的,用户可以很容易地看到。 比如大家可以用CryptoJS实现一个简单的HMAC-SHA256签名和验签的例子:

// 首先安装CryptoJS:npm install crypto-js
import CryptoJS from 'crypto-js'
// 这里的密钥大家可以放在环境变量里,比如VUE_APP_SIGN_KEY,这样就不会直接暴露在前端代码里了
const SIGN_KEY = import.meta.env.VUE_APP_SIGN_KEY || 'your-secret-key-here'
// 签名函数
const sign = (key, value) => {
  return CryptoJS.HmacSHA256(`${key}:${JSON.stringify(value)}`, SIGN_KEY).toString()
}
// 验签函数
const verify = (key, value, signature) => {
  const expectedSignature = CryptoJS.HmacSHA256(`${key}:${JSON.stringify(value)}`, SIGN_KEY).toString()
  return expectedSignature === signature
}
// 然后在调用useLocalStorage的时候传进去:
const userProgress = useLocalStorage('userProgress', { level: 1, exp: 0 }, {
  expires: 7 * 24 * 60 * 60 * 1000, // 7天过期
  sign,
  verify,
})

这样的话,用户就不能随便在浏览器的开发者工具里修改userProgress的数据了,因为如果修改了的话,验签就会失败,Hook就会把这个key删掉,然后返回默认值。 好,这个终极版的useLocalStorage Hook是不是已经非常实用了?基本上能满足我们日常开发Vue3项目时对LocalStorage的所有需求了。

使用Vue3 LocalStorage的常见坑点

刚才说了封装Hook的好处,但不管是用原生LocalStorage,还是用自己封装的Hook,或者是用第三方的库,都有一些常见的坑点,我们来总结一下,避免大家踩坑。

存太大的数据导致页面卡顿

刚才说了,LocalStorage是同步操作,阻塞主线程的,而且单个域名下LocalStorage最多存5MB,如果存太大的JSON数据,或者频繁循环读写LocalStorage,就会导致页面卡顿,甚至白屏。 那怎么避免这个坑点?不要存太大的数据,比如图片、视频这些大文件,应该存到服务器或者IndexedDB里;不要频繁循环读写LocalStorage,比如不要在for循环里每次都setItem或者getItem,应该先在内存里处理好数据,然后一次性存进去;可以用一些性能优化的方法,比如防抖(debounce),比如在封装Hook的时候加个debounce选项,让用户可以选择要不要在数据变化之后延迟一段时间再存到LocalStorage里,这样可以减少setItem的次数。 比如我们可以给终极版的useLocalStorage Hook加个debounce选项:

// 先安装lodash-es:npm install lodash-es
import { debounce } from 'lodash-es'
// 然后在options里加个debounce选项,默认0
const {
  // ... 其他选项
  debounce: debounceTime = 0,
} = options
// 然后把watch的回调函数用debounce包起来
const watcher = watch(data, debounce((newValue) => {
  localStorage.setItem(key, wrapValue(newValue))
}, debounceTime), {
  deep,
  flush,
  immediate,
})

不过用debounce的话,要注意immediate选项的问题,因为如果immediate是true的话,debounce的第一次调用会被触发,这个没问题,但如果用户修改了数据,然后又在debounce的延迟时间内刷新了页面,那数据就不会被存到LocalStorage里了,这个要注意。

存敏感数据导致安全问题

刚才说了,LocalStorage存的数据,用户可以直接在浏览器的开发者工具里看到,甚至可以直接修改,而且LocalStorage不会受到HTTPOnly和Secure Cookie的保护,所以很容易受到XSS(跨站脚本攻击)的攻击,攻击者可以通过XSS攻击窃取用户的LocalStorage数据。 那怎么避免这个坑点?绝对不要存敏感数据,比如用户的密码、JWT的刷新令牌、银行卡号、身份证号这些;如果要存一些临时的不太敏感但又不想让用户随便乱改的数据,可以用刚才说的签名或者加密;如果必须要存一些和登录相关的临时权限标识的话,建议用HTTPOnly和Secure的Cookie,而不是LocalStorage,因为Cookie有SameSite属性,可以防止CSRF(跨站请求伪造)攻击,而且HTTPOnly的Cookie不能被JavaScript访问,所以可以防止XSS攻击窃取。

忘记处理JSON的序列化和反序列化导致数据类型丢失

刚才说了,LocalStorage只能存字符串,不管你存什么类型的数据,存进去的时候都会被强制转成字符串,取的时候得自己转回来,很多人刚上手的时候会忘记转,比如存个数字123,取的时候变成了字符串'123',然后用这个字符串去做加减乘除的运算,就会出问题。 那怎么避免这个坑点?刚才封装的Hook里已经加了JSON的序列化和反序列化了,大家用Hook的时候就不用担心这个问题了,如果用原生LocalStorage的话,一定要记得存的时候JSON.stringify,取的时候JSON.parse,还要加个try-catch容错,防止JSON解析失败。

同域限制导致跨域页面无法共享数据

刚才说了,LocalStorage是同域限制的,只有协议、域名、端口号一模一样的页面,才能共享同一个LocalStorage的数据,很多人刚上手的时候会忘记这个,比如在www.example.com的页面里存了数据,然后在example.com的页面里取不到,或者在http://example.com的页面里存了数据,然后在https://example.com的页面里取不到。 那怎么避免这个坑点?尽量让你的网站的所有页面都用同一个协议、同一个域名(最好是带www或者不带www的统一一个,比如用301重定向把不带www的重定向到带www的,或者反过来)、同一个端口号;如果必须要跨域共享数据的话,可以用postMessage API配合LocalStorage来实现,或者用服务器端的会话存储来实现,不过服务器端的会话存储就不是LocalStorage了。

Storage事件不会在触发修改的页面本身收到

刚才说了,Storage事件不会在触发修改的页面本身收到,所以很多人刚上手的时候会以为只要加了Storage事件,同一个页面的不同组件就会自动同步,但其实不会。 那怎么避免这个坑点?刚才封装的Hook里已经加了全局缓存了,大家用Hook的时候就不用担心这个问题了,如果用原生LocalStorage的话,要么用父子组件传值或者Pinia/Vuex共享状态,要么自己在同一个页面里搞一个事件总线(EventBus)来同步。

使用Vue3 LocalStorage的优化方案

除了刚才自己封装的Hook之外,还有一些第三方的库也可以用来实现Vue3 LocalStorage的持久化存储,

Pinia Persistedstate

如果你的项目已经用了Pinia作为全局状态管理器的话,那Pinia Persistedstate这个库是非常好用的,它可以自动把Pinia的状态持久化到LocalStorage、SessionStorage或者IndexedDB里,而且支持很多自定义配置,比如指定要持久化哪些状态、指定存储介质、指定序列化和反序列化函数、指定过期时间、指定加密函数等等。 使用方法也非常简单,首先安装这个库:

npm install pinia-plugin-persistedstate

然后在main.js或者main.ts里引入并注册这个插件:

import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

然后在定义Pinia Store的时候加上persist: true选项就可以了:

import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
  state: () => ({
    name: '',
    age: 0,
  }),
  persist: true,
})

这样的话,Pinia的user Store的状态就会自动持久化到LocalStorage里了,key就是user,而且跨标签页和同SPA跨组件都能自动同步,非常方便。

VueUse的useStorage

VueUse是一个非常流行的Vue3 Composition API工具库,里面有很多实用的Hook,其中就有useStorage这个Hook,它可以用来自动同步响应式数据和LocalStorage、SessionStorage或者Cookie,而且支持和我们刚才自己封装的终极版Hook差不多的所有功能,甚至更多,比如支持自定义存储介质、支持自定义序列化和反序列化函数、支持过期时间、支持watch的各种选项、支持跨标签页同步等等。 使用方法也非常简单,首先安装VueUse:

npm install @vueuse/core

然后在组件里引入并使用:

import { useStorage } from '@vueuse/core'
const isDark = useStorage('darkMode', false)

这样就可以了,是不是非常方便?如果你的项目已经用了VueUse的话,那直接用它的useStorage Hook就可以了,不用自己再封装了。

今天我们聊了Vue3中如何高效安全地使用LocalStorage,从原生LocalStorage的特性开始讲,然后讲了直接用原生LocalStorage在Vue3里的尴尬,然后手把手封装了一个从基础版到进阶版再到终极版的useLocalStorage Hook,然后讲了使用Vue3 LocalStorage的常见坑点,最后讲了两个第三方的优化方案。 Vue3中使用LocalStorage的最佳实践是什么?不要直接用原生LocalStorage,要么自己封装一个带全局缓存、带storage事件监听、带JSON序列化和反序列化、带容错的Hook,要么用第三方的库比如Pinia Persistedstate或者VueUse的useStorage;不要存太大的数据,不要存敏感数据;注意同域限制,注意Storage事件的小细节。 希望今天的文章能对大家有所帮助,大家如果有什么问题或者建议的话,欢迎在评论区留言哦!

版权声明

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

热门