现代单页面应用(SPA)状态持久化:页面刷新后的状态保留与存储方案对比

132 阅读8分钟

目标:解决 SPA 在“刷新/关闭重开/断网恢复”后数据无法保留的问题,选择合适的持久化工具与介质,并给出落地用法与最佳实践。


为什么刷新后数据会丢失?

  • 内存态:多数状态管理库(如 Redux、Zustand、Recoil、Pinia)默认把数据保存在内存中,刷新会丢失。
  • 服务器缓存态:像 TanStack Query(React Query)默认把数据存在内存 cache,刷新后需要重新拉取。
  • 浏览器进程模型:刷新等于销毁运行时上下文,除非显式写入浏览器持久化存储。

哪些数据应该持久化,哪些不应该?

  • 适合持久化
    • 用户首选项(主题、语言、布局)
    • 非敏感的业务状态(上次过滤条件、分页、上次浏览轮次)
    • 可离线使用的数据缓存(列表/详情的只读结果,具有失效策略)
    • 较大、可重算成本高的衍生数据(注意版本与过期)
  • 不建议持久化
    • 高敏感数据(Access Token、刷新令牌、个人隐私信息)
    • 强时效/强一致状态(每次进入都需要实时拉取的库存/报价)
    • 强依赖会话安全的数据(优先放 HttpOnly Cookie,避免 XSS 读取)

原则:能从服务端可靠恢复的状态,尽量不要长久保存在前端明文存储中。


存储介质选择

  • localStorage
    • 同源永久(直到清理),同步 API,容量约 5MB,易用但阻塞主线程。
    • 适合小体量、低频写入的偏配置数据;敏感信息禁用或需加密。
  • sessionStorage
    • 会话级(标签页关闭即清),与 localStorage 类似。
    • 适合仅在当前会话保留的数据(如临时向导步骤)。
  • IndexedDB(推荐通过 localForage、Dexie 封装)
    • 异步、容量更大、适合结构化数据与离线缓存;适配度好。
    • 适合大体量、可离线的缓存与复杂对象。
  • Cookie
    • 建议仅用于 HttpOnly 会话标识,前端 JS 不应访问敏感 Cookie。
  • Cache Storage / Service Worker
    • 适合资源级缓存、离线优先;数据对象建议仍走 IndexedDB。
  • 内存
    • 性能最佳,不持久;可与以上介质配合做“读缓存-写持久”的双层结构。

工具与库:怎么选?(快速矩阵)

技术栈/需求推荐方案说明
React + 复杂全局状态(可观测/时光旅行/插件生态)Redux Toolkit + redux-persist生态成熟、可控;配置稍多
React + 轻量全局状态Zustand + persist 中间件API 简洁、按片段持久化、体积小
Vue 3Pinia + pinia-plugin-persistedstate社区主流、声明即用
Vue 2Vuex + vuex-persistedstate成熟稳定、配置型路径白名单
原子化状态Recoil + atomEffect/社区持久化插件需自定义 effect 或插件
服务器数据缓存TanStack Query 持久化有内置持久化工具,支持 localStorage/IndexedDB
大体量离线数据IndexedDB(localForage/Dexie)异步、容量大、可做复杂查询

实战:各库的持久化用法

Redux Toolkit + redux-persist

// store.ts
import { configureStore, combineReducers } from '@reduxjs/toolkit'
import { persistStore, persistReducer, FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER } from 'redux-persist'
import storage from 'redux-persist/lib/storage' // 默认 localStorage

import userReducer from './slices/user'
import uiReducer from './slices/ui'

const rootReducer = combineReducers({
  user: userReducer,
  ui: uiReducer,
})

const persistConfig = {
  key: 'root',
  version: 2, // 配合迁移
  storage,
  whitelist: ['ui'], // 仅持久化 ui
  blacklist: ['user'], // 或选择黑名单
  migrate: async (state) => {
    // 可在版本升级时做 schema 迁移
    return state
  },
}

const persistedReducer = persistReducer(persistConfig, rootReducer)

export const store = configureStore({
  reducer: persistedReducer,
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: {
        ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
      },
    }),
})

export const persistor = persistStore(store)
// App.tsx
import { PersistGate } from 'redux-persist/integration/react'
import { Provider } from 'react-redux'
import { store, persistor } from './store'

export default function App() {
  return (
    <Provider store={store}>
      <PersistGate loading={null} persistor={persistor}>
        {/* your routers */}
      </PersistGate>
    </Provider>
  )
}

要点:精准选择持久化片段、设置 version/migrate、注意清理(登出时 persistor.purge())。

Zustand + persist 中间件

// store.ts
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'

type UiState = {
  theme: 'light' | 'dark'
  setTheme: (t: UiState['theme']) => void
}

export const useUiStore = create<UiState>()(
  persist(
    (set) => ({
      theme: 'light',
      setTheme: (theme) => set({ theme }),
    }),
    {
      name: 'ui',
      version: 1,
      storage: createJSONStorage(() => localStorage), // 或 localForage for IndexedDB
      partialize: (state) => ({ theme: state.theme }), // 仅持久化 theme
      migrate: (persistedState, version) => {
        return persistedState as any
      },
      onRehydrateStorage: () => (state) => {
        // rehydrate 后的钩子
      },
    }
  )
)

要点:partialize 精准持久化字段,version/migrate 处理升级;如需异步存储用 localForage。

Pinia + pinia-plugin-persistedstate

// main.ts
import { createPinia } from 'pinia'
import piniaPersist from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPersist)
// stores/ui.ts
import { defineStore } from 'pinia'

export const useUiStore = defineStore('ui', {
  state: () => ({ theme: 'light' as 'light' | 'dark', sidebarOpen: true }),
  actions: {
    setTheme(t: 'light' | 'dark') { this.theme = t },
  },
  persist: {
    key: 'ui',
    paths: ['theme'], // 仅持久化 theme
    storage: localStorage, // 或自定义 storage(如 localForage)
  },
})

要点:通过 paths 精选字段,可切到自定义 storage 以用 IndexedDB。

Vue 2 + Vuex + vuex-persistedstate

// store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
import createPersistedState from 'vuex-persistedstate'

Vue.use(Vuex)

export default new Vuex.Store({
  state: {
    theme: 'light',
    sidebarOpen: true,
    token: '', // 敏感信息,建议不要持久化
  },
  mutations: {
    setTheme(state, t) { state.theme = t },
    setSidebarOpen(state, v) { state.sidebarOpen = v },
    setToken(state, token) { state.token = token },
  },
  // 模块化时可使用 namespaced 并在 paths 中用 'moduleA.prop'
  modules: {},
  plugins: [
    createPersistedState({
      key: 'app',
      paths: ['theme', 'sidebarOpen'], // 精准持久化,避免敏感字段
      storage: window.localStorage, // 默认即可
      // 如需过滤器:filter: (mutation) => mutation.type === 'setTheme'
    }),
  ],
})
// main.js(Vue 2)
import Vue from 'vue'
import App from './App.vue'
import store from './store'

new Vue({
  store,
  render: h => h(App),
}).$mount('#app')

如需 IndexedDB(localForage),可自定义 storage(需配合 fetchBeforeUse: true 以等待异步存储准备):

import localforage from 'localforage'
createPersistedState({
  key: 'app',
  paths: ['theme'],
  fetchBeforeUse: true,
  storage: {
    getItem: (key) => localforage.getItem(key),
    setItem: (key, value) => localforage.setItem(key, value),
    removeItem: (key) => localforage.removeItem(key),
  },
})

要点:使用 paths 做白名单;模块化使用 'module.key' 路径;敏感数据(如 token)不落地或仅置于 HttpOnly 会话。

Recoil:atomEffect 自定义持久化

// storageEffect.ts
import { AtomEffect } from 'recoil'

export const storageEffect = <T>(key: string): AtomEffect<T> => ({ setSelf, onSet }) => {
  const saved = localStorage.getItem(key)
  if (saved != null) setSelf(JSON.parse(saved))

  onSet((newValue, _, isReset) => {
    if (isReset) {
      localStorage.removeItem(key)
    } else {
      localStorage.setItem(key, JSON.stringify(newValue))
    }
  })
}
// atoms.ts
import { atom } from 'recoil'
import { storageEffect } from './storageEffect'

export const themeAtom = atom<'light' | 'dark'>({
  key: 'theme',
  default: 'light',
  effects: [storageEffect('theme')],
})

要点:可替换为 IndexedDB(通过 localForage 的异步封装),并在 SSR 环境中延迟到浏览器再读取。

TanStack Query(React Query)持久化

// persist.ts
import { QueryClient } from '@tanstack/react-query'
import { persistQueryClient } from '@tanstack/query-persist-client-core'
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'

export const queryClient = new QueryClient()

persistQueryClient({
  queryClient,
  persister: createSyncStoragePersister({ storage: window.localStorage }),
  maxAge: 1000 * 60 * 60, // 1 小时
})

如需 IndexedDB:

import localforage from 'localforage'
import { createAsyncStoragePersister } from '@tanstack/query-persist-client-core'

persistQueryClient({
  queryClient,
  persister: createAsyncStoragePersister({ storage: localforage }),
  maxAge: 1000 * 60 * 60,
})

要点:设置 maxAge 控制过期;对易变数据及时 invalidateQueries


安全与合规:必须重视

  • 避免在可被 JS 读取的存储中保存敏感令牌:优先服务端会话 + HttpOnly Cookie。
  • 防 XSS:一旦发生 XSS,localStorage/IndexedDB 明文数据可能外泄;对必要数据可使用 Web Crypto 加密(仍无法抵御脚本层窃取密钥的强 XSS)。
  • 最小化持久范围:只持久化必要字段,避免“全量落地”。
  • 登出/账号切换:统一清理本地持久化(persistor.purge()、删除 key)。

版本、迁移与过期

  • 版本化:为持久化状态设置 version(Zustand/Redux-persist),配合 migrate 平滑升级。
  • 过期策略
    • TanStack Query 用 maxAge
    • 自研可在对象上携带 expiresAt 字段并在 rehydrate 时清理;
    • 利用 staleTime 和手动 invalidateQueries 控制新鲜度。
  • 结构变更:为 key 增加命名空间,如 app:v2:ui,兼容并行灰度。

多标签页同步与 SSR 注意

  • 多标签页同步
    • storage 事件可监听 localStorage 变更;
    • BroadcastChannel 更可靠、双向;
    • 部分库已内建(如 Zustand 在 rehydrate 后可自行触发)。
  • SSR/Hydration
    • 仅在浏览器侧访问存储(typeof window !== 'undefined' 或放在 useEffect);
    • 避免初始渲染不一致导致的水合警告,必要时先渲染 loading 占位。

对比要点表

方案典型存储易用性性能体积/依赖适配场景风险/注意
Redux Toolkit + redux-persistlocalStorage/IndexedDB中大型、状态受控需精细白/黑名单、迁移配置
Zustand + persistlocalStorage/IndexedDB轻量应用、模块化谨慎挑字段、注意版本
Pinia + 插件localStorage/IndexedDBVue3 主流paths 精选、SSR 延后读取
Vuex + vuex-persistedstatelocalStorage/IndexedDBVue2 主流paths 白名单、模块路径、SSR 延后读取
Recoil + effectlocalStorage/IndexedDB原子粒度控制需自定义 effect、处理异步存储
TanStack Query 持久化localStorage/IndexedDB服务端数据缓存maxAge、失效策略至关重要
直接 IndexedDBIndexedDB离线优先/大数据需自行封装与过期清理

常见坑清单

  • 刷新闪烁:rehydrate 前 UI 读取到默认值,需 Gate/占位。
  • 无限循环写入:监听状态变化写回存储时注意节流与变更检测。
  • JSON 序列化丢失方法/日期:仅持久化可序列化数据,日期用 ISO 字符串。
  • 大对象频繁写 localStorage 阻塞主线程:改为 IndexedDB 或批处理。
  • 误持久化敏感字段:务必审计白名单与可观察数据流。

TL;DR 推荐

  • 偏配置/轻量全局状态(React):Zustand + persist(partialize 精选字段)。
  • 复杂全局状态:Redux Toolkit + redux-persist(启用 version/migrate)。
  • Vue 3:Pinia + pinia-plugin-persistedstate。
  • Vue 2:Vuex + vuex-persistedstate(使用 paths 白名单,避免敏感字段)。
  • 服务器数据缓存:TanStack Query + 持久化(本地 maxAge + 服务端失效策略)。
  • 大体量/离线:localForage/Dexie(IndexedDB 封装),必要时结合 SW。
  • 安全:敏感令牌放 HttpOnly Cookie,不落地到可被 JS 读取的存储。

结语

持久化并非“把所有状态都写入本地”,而是“对正确的数据,用正确的介质与策略”。围绕可用性、性能与安全三要素,结合库的原生支持能力与业务约束,设计你的持久化方案,才能让 SPA 在刷新、离线与恢复场景下都表现稳定且可控。