第78集:状态持久化方案

概述

状态持久化是指将应用的状态保存到持久化存储中(如本地存储、会话存储或IndexedDB),以便在页面刷新或重新打开应用时恢复状态。Pinia提供了灵活的状态持久化机制,支持多种存储方式和配置选项。掌握状态持久化方案对于构建用户体验良好的现代应用至关重要。

核心知识点

1. 状态持久化的基本原理

状态持久化的基本原理是:

  1. 在应用初始化时,从持久化存储中恢复状态
  2. 监听状态变化,将更新后的状态保存到持久化存储
  3. 支持配置选项,如存储键名、存储方式、需要保存的状态路径等

2. 手动实现状态持久化

2.1 使用localStorage

// stores/counter.ts
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0,
    name: 'Pinia'
  }),
  
  actions: {
    // 恢复状态
    restoreState() {
      const savedState = localStorage.getItem('counterState')
      if (savedState) {
        this.$patch(JSON.parse(savedState))
      }
    },
    
    // 保存状态
    saveState() {
      localStorage.setItem('counterState', JSON.stringify(this.$state))
    }
  }
})

// 在组件中使用
const counterStore = useCounterStore()
// 恢复状态
counterStore.restoreState()

// 订阅状态变化,自动保存
counterStore.$subscribe((mutation, state) => {
  counterStore.saveState()
})

2.2 使用sessionStorage

// stores/auth.ts
export const useAuthStore = defineStore('auth', {
  state: () => ({
    user: null,
    token: null
  }),
  
  actions: {
    async login(email: string, password: string) {
      // 登录逻辑
      const response = await fetch('https://api.example.com/login', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({ email, password })
      })
      
      const data = await response.json()
      this.user = data.user
      this.token = data.token
      
      // 保存到sessionStorage
      sessionStorage.setItem('authState', JSON.stringify(this.$state))
    },
    
    logout() {
      this.user = null
      this.token = null
      // 清除sessionStorage
      sessionStorage.removeItem('authState')
    },
    
    restoreState() {
      const savedState = sessionStorage.getItem('authState')
      if (savedState) {
        this.$patch(JSON.parse(savedState))
      }
    }
  }
})

3. 使用Pinia插件实现持久化

3.1 自定义持久化插件

// plugins/pinia-persist.ts
import { PiniaPluginContext } from 'pinia'

interface PersistOptions {
  key?: string
  storage?: Storage
  paths?: string[]
}

export function createPersistPlugin(options: PersistOptions = {}) {
  return (context: PiniaPluginContext) => {
    const {
      store,
      options: storeOptions
    } = context
    
    // 检查Store是否配置了persist选项
    if (!storeOptions.persist) {
      return
    }
    
    // 合并插件选项和Store选项
    const persistConfig = typeof storeOptions.persist === 'boolean' 
      ? {} 
      : storeOptions.persist
    
    const {
      key = `pinia_${store.$id}`,
      storage = localStorage,
      paths
    } = { ...options, ...persistConfig }
    
    // 从存储中恢复状态
    const savedState = storage.getItem(key)
    if (savedState) {
      store.$patch(JSON.parse(savedState))
    }
    
    // 订阅状态变化,保存到存储
    store.$subscribe((mutation, state) => {
      let stateToSave = state
      
      // 如果指定了paths,只保存指定的路径
      if (paths) {
        stateToSave = paths.reduce((acc, path) => {
          const value = getNestedValue(state, path)
          if (value !== undefined) {
            setNestedValue(acc, path, value)
          }
          return acc
        }, {} as any)
      }
      
      storage.setItem(key, JSON.stringify(stateToSave))
    })
  }
}

// 辅助函数:获取嵌套值
function getNestedValue(obj: any, path: string): any {
  return path.split('.').reduce((acc, key) => acc?.[key], obj)
}

// 辅助函数:设置嵌套值
function setNestedValue(obj: any, path: string, value: any): void {
  const keys = path.split('.')
  const lastKey = keys.pop()
  if (!lastKey) return
  
  const nestedObj = keys.reduce((acc, key) => {
    if (!acc[key]) {
      acc[key] = {}
    }
    return acc[key]
  }, obj)
  
  nestedObj[lastKey] = value
}

// 注册插件
import { createPinia } from 'pinia'
const pinia = createPinia()
pinia.use(createPersistPlugin())

3.2 在Store中使用

// stores/counter.ts
export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0,
    name: 'Pinia',
    user: {
      id: 1,
      name: 'John Doe'
    }
  }),
  
  // 配置持久化
  persist: {
    key: 'counter_state',
    storage: localStorage,
    paths: ['count', 'user.name'] // 只保存count和user.name
  }
})

// 简单配置
const useSimpleStore = defineStore('simple', {
  state: () => ({ /* ... */ }),
  persist: true // 使用默认配置
})

4. 使用第三方持久化插件

4.1 pinia-plugin-persistedstate

pinia-plugin-persistedstate是一个流行的Pinia持久化插件,提供了丰富的配置选项:

npm install pinia-plugin-persistedstate
// main.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
// stores/user.ts
export const useUserStore = defineStore('user', {
  state: () => ({
    users: [],
    currentUser: null
  }),
  
  persist: {
    // 存储键名
    key: 'user_store',
    // 存储方式
    storage: localStorage,
    // 需要保存的状态路径
    paths: ['users', 'currentUser'],
    // 序列化函数
    serializer: {
      serialize: JSON.stringify,
      deserialize: JSON.parse
    },
    // 保存前的钩子
    beforeSave: (state) => {
      console.log('Saving state:', state)
      return state
    },
    // 恢复前的钩子
    afterRestore: (state) => {
      console.log('Restored state:', state)
    }
  }
})

4.2 支持多种存储方式

// 使用sessionStorage
export const useSessionStore = defineStore('session', {
  state: () => ({
    // ...
  }),
  
  persist: {
    storage: sessionStorage
  }
})

// 使用IndexedDB
import { get, set } from 'idb-keyval'

export const useIndexedDBStore = defineStore('indexeddb', {
  state: () => ({
    // ...
  }),
  
  persist: {
    storage: {
      getItem: async (key) => {
        const value = await get(key)
        return value
      },
      setItem: async (key, value) => {
        await set(key, value)
      },
      removeItem: async (key) => {
        await del(key)
      }
    }
  }
})

5. 状态持久化的高级配置

5.1 选择性持久化

可以根据条件选择性地保存状态:

// stores/counter.ts
export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0,
    isLoggedIn: false
  }),
  
  persist: {
    // 只在用户登录时保存状态
    beforeSave: (state) => {
      if (!state.isLoggedIn) {
        return { count: 0 } // 只保存默认值
      }
      return state
    }
  }
})

5.2 加密存储

对于敏感数据,可以使用加密存储:

// plugins/pinia-encrypted-persist.ts
import { PiniaPluginContext } from 'pinia'
import CryptoJS from 'crypto-js'

const SECRET_KEY = 'your-secret-key' // 实际应用中应该从环境变量获取

function encryptedPersistPlugin(context: PiniaPluginContext) {
  const { store } = context
  
  // 加密保存
  store.$subscribe((mutation, state) => {
    const encryptedState = CryptoJS.AES.encrypt(
      JSON.stringify(state),
      SECRET_KEY
    ).toString()
    localStorage.setItem(`encrypted_${store.$id}`, encryptedState)
  })
  
  // 解密恢复
  const encryptedState = localStorage.getItem(`encrypted_${store.$id}`)
  if (encryptedState) {
    const decryptedState = CryptoJS.AES.decrypt(encryptedState, SECRET_KEY)
    const plaintextState = decryptedState.toString(CryptoJS.enc.Utf8)
    if (plaintextState) {
      store.$patch(JSON.parse(plaintextState))
    }
  }
}

最佳实践

1. 选择合适的存储方式

存储方式 特点 适用场景
localStorage 持久存储,容量约5MB 用户偏好设置、主题配置、持久化状态
sessionStorage 会话存储,关闭标签页后清除 登录状态、临时数据
IndexedDB 大容量存储,支持复杂查询 大量数据、离线应用
Cookies 小型存储,随请求发送 认证令牌、会话标识

2. 合理配置持久化选项

  • 键名:使用唯一的键名,避免与其他应用冲突
  • 存储路径:只保存必要的状态,减少存储大小
  • 序列化方式:根据数据类型选择合适的序列化方式
  • 加密策略:对于敏感数据,使用加密存储

3. 性能优化

  • 防抖保存:对于频繁变化的状态,使用防抖技术减少保存次数
  • 批量更新:使用$patch方法批量更新状态,减少订阅触发次数
  • 懒加载恢复:对于大型状态,考虑懒加载恢复,提高应用启动速度
// 防抖保存示例
function debounce(fn: Function, delay: number) {
  let timer: number | null = null
  return function(...args: any[]) {
    if (timer) clearTimeout(timer)
    timer = window.setTimeout(() => {
      fn.apply(this, args)
      timer = null
    }, delay)
  }
}

const debouncedSave = debounce((state: any, key: string) => {
  localStorage.setItem(key, JSON.stringify(state))
}, 300)

store.$subscribe((mutation, state) => {
  debouncedSave(state, `pinia_${store.$id}`)
})

4. 错误处理

  • 添加适当的错误处理,避免存储操作失败导致应用崩溃
  • 考虑存储容量限制,添加溢出处理机制
  • 对于加密存储,添加解密失败的处理
// 错误处理示例
store.$subscribe((mutation, state) => {
  try {
    localStorage.setItem(`pinia_${store.$id}`, JSON.stringify(state))
  } catch (err) {
    console.error('Failed to save state:', err)
    // 处理存储错误,如清除旧数据、提示用户等
    if (err instanceof DOMException && err.name === 'QuotaExceededError') {
      // 存储容量不足,清除旧数据
      localStorage.removeItem(`pinia_${store.$id}`)
      console.warn('Storage quota exceeded, cleared old state')
    }
  }
})

常见问题与解决方案

1. 存储容量不足

问题:状态过大,导致本地存储容量不足。

解决方案

  • 只保存必要的状态,使用paths选项过滤
  • 压缩状态数据,使用压缩算法如LZString
  • 考虑使用IndexedDB存储大量数据
  • 实现自动清理机制,定期清除旧数据

2. 状态恢复导致性能问题

问题:大型状态恢复导致应用启动缓慢。

解决方案

  • 实现懒加载恢复,只在需要时恢复状态
  • 分批次恢复状态,避免阻塞主线程
  • 使用Web Workers在后台恢复状态

3. 跨标签页状态同步

问题:多个标签页之间状态不同步。

解决方案

  • 使用storage事件监听其他标签页的状态变化
  • 实现广播机制,通知其他标签页更新状态
// 跨标签页同步示例
window.addEventListener('storage', (event) => {
  if (event.key === `pinia_${store.$id}` && event.newValue) {
    store.$patch(JSON.parse(event.newValue))
  }
})

4. 敏感数据泄露

问题:敏感数据存储在本地,存在泄露风险。

解决方案

  • 使用加密存储,如AES加密
  • 对于非常敏感的数据,不建议存储在客户端
  • 使用短期令牌,定期刷新
  • 实现自动清理机制,在用户退出时清除数据

进一步学习资源

  1. Pinia官方文档 - 插件
  2. pinia-plugin-persistedstate
  3. Web Storage API
  4. IndexedDB API
  5. CryptoJS

课后练习

  1. 基础练习

    • 创建一个简单的计数器Store
    • 实现状态持久化到localStorage
    • 测试页面刷新后状态是否恢复
  2. 进阶练习

    • 创建一个用户管理Store,包含敏感数据
    • 实现加密存储,使用CryptoJS加密敏感数据
    • 配置只保存必要的状态路径
    • 测试加密和解密功能
  3. 性能优化练习

    • 创建一个包含大量数据的Store
    • 实现防抖保存,减少存储操作次数
    • 实现懒加载恢复,提高应用启动速度
    • 测试不同实现方式的性能差异
  4. 跨标签页同步练习

    • 创建一个支持跨标签页同步的Store
    • 使用storage事件实现状态同步
    • 测试多个标签页之间的状态同步

通过本节课的学习,你应该能够掌握Pinia状态持久化的各种方案,理解状态持久化的基本原理,掌握手动实现和使用插件实现状态持久化的方法,以及状态持久化的最佳实践和常见问题解决方案。这些知识将帮助你构建用户体验良好的现代应用,提高用户满意度和应用的可用性。

« 上一篇 插件开发与中间件 - Pinia扩展机制 下一篇 » 服务端渲染集成 - Pinia SSR支持