目标:解决 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 3 | Pinia + pinia-plugin-persistedstate | 社区主流、声明即用 |
| Vue 2 | Vuex + 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控制新鲜度。
- TanStack Query 用
- 结构变更:为 key 增加命名空间,如
app:v2:ui,兼容并行灰度。
多标签页同步与 SSR 注意
- 多标签页同步:
storage事件可监听localStorage变更;BroadcastChannel更可靠、双向;- 部分库已内建(如 Zustand 在 rehydrate 后可自行触发)。
- SSR/Hydration:
- 仅在浏览器侧访问存储(
typeof window !== 'undefined'或放在useEffect); - 避免初始渲染不一致导致的水合警告,必要时先渲染 loading 占位。
- 仅在浏览器侧访问存储(
对比要点表
| 方案 | 典型存储 | 易用性 | 性能 | 体积/依赖 | 适配场景 | 风险/注意 |
|---|---|---|---|---|---|---|
| Redux Toolkit + redux-persist | localStorage/IndexedDB | 中 | 中 | 中 | 中大型、状态受控 | 需精细白/黑名单、迁移配置 |
| Zustand + persist | localStorage/IndexedDB | 高 | 高 | 小 | 轻量应用、模块化 | 谨慎挑字段、注意版本 |
| Pinia + 插件 | localStorage/IndexedDB | 高 | 高 | 小 | Vue3 主流 | paths 精选、SSR 延后读取 |
| Vuex + vuex-persistedstate | localStorage/IndexedDB | 高 | 高 | 小 | Vue2 主流 | paths 白名单、模块路径、SSR 延后读取 |
| Recoil + effect | localStorage/IndexedDB | 中 | 高 | 小 | 原子粒度控制 | 需自定义 effect、处理异步存储 |
| TanStack Query 持久化 | localStorage/IndexedDB | 高 | 高 | 中 | 服务端数据缓存 | maxAge、失效策略至关重要 |
| 直接 IndexedDB | IndexedDB | 中 | 高 | 小 | 离线优先/大数据 | 需自行封装与过期清理 |
常见坑清单
- 刷新闪烁: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 在刷新、离线与恢复场景下都表现稳定且可控。