状态持久化方案深度解析
从 localStorage 到 IndexedDB,从 redux-persist 到 Zustand persist,全面拆解前端状态持久化的核心方案与面试要点。
一句话概括
状态持久化是将应用运行时的内存状态”写死”到浏览器存储中,页面刷新/关闭后再”复活”回来的技术——没有它,你刷一下页面,Redux/Zustand/Pinia 里的所有数据全会归零。
核心知识点
1. 四大存储引擎怎么选
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 80% 的场景用 localStorage 就够了
localStorage.setItem('theme', 'dark')
const theme = localStorage.getItem('theme') // 'dark'
// sessionStorage:关标签页就没了,适合分步表单的临时缓存
sessionStorage.setItem('form_step1', JSON.stringify(formData))
// Cookie:自动随请求发送,适合 token(配合 HttpOnly)
document.cookie = 'token=xxx; Secure; SameSite=Strict'
// IndexedDB:大数据(>5MB)、二进制文件、离线缓存
const req = indexedDB.open('MyDB', 1)
req.onsuccess = () => {
const db = req.result
const tx = db.transaction('users', 'readwrite')
tx.objectStore('users').put({ id: 1, name: 'Alice' })
}
| 特性 | localStorage | sessionStorage | Cookie | IndexedDB |
|---|---|---|---|---|
| 容量 | 5-10MB | 5-10MB | 4KB | 数百MB+ |
| 过期 | 手动清除 | 标签页关闭 | 可设过期 | 手动清除 |
| 异步 | ❌ 同步 | ❌ 同步 | ❌ 同步 | ✅ 异步 |
| 随请求发送 | ❌ | ❌ | ✅ 自动 | ❌ |
| 存对象 | 需 JSON 序列化 | 需 JSON 序列化 | 字符串 | ✅ 结构化 |
2. hydation(水合)——最容易被忽略的坑
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// ❌ 常见错误:组件渲染时还没读到持久化数据
function App() {
const [user, setUser] = useState(null)
useEffect(() => {
// 异步读取,但首次渲染 user 已经是 null
const saved = JSON.parse(localStorage.getItem('user'))
setUser(saved)
}, [])
// user 为 null 时可能直接跳到登录页!
if (!user) return <LoginPage /> // 闪屏!
}
// ✅ 正确姿势:用 loading 状态桥接"水合"过程
function App() {
const [user, setUser] = useState(undefined) // undefined = 尚未水合
useEffect(() => {
const saved = localStorage.getItem('user')
setUser(saved ? JSON.parse(saved) : null)
}, [])
if (user === undefined) return <SplashScreen /> // 水合中
if (user === null) return <LoginPage />
return <MainApp user={user} />
}
水合冲突是指旧数据的 schema 和新代码不匹配——比如旧数据存的是 { name },新代码期望 { firstName, lastName }。解决方式:版本号 + 迁移函数。
3. redux-persist 三板斧
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { persistStore, persistReducer } from 'redux-persist'
import storage from 'redux-persist/lib/storage'
const persistConfig = {
key: 'root',
storage, // 默认 localStorage
whitelist: ['auth', 'cart'], // 只持久化这两个 reducer
// blacklist: ['loading'], // 或者:排除某些
version: 2,
migrate: (state) => {
// v1 → v2:user.token 挪到 auth.token
if (state._persist?.version < 2) {
return { ...state, auth: { token: state.user?.token } }
}
return state
},
}
const persistedReducer = persistReducer(persistConfig, rootReducer)
const store = configureStore({ reducer: persistedReducer })
export const persistor = persistStore(store)
// 入口组件
<Provider store={store}>
<PersistGate loading={<Loading />} persistor={persistor}>
<App />
</PersistGate>
</Provider>
PersistGate 的作用:在从 localStorage 恢复状态(rehydrate)完成之前,显示 loading 界面,避免组件拿到”一半”的状态。
4. Zustand persist —— 一行搞定
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
const useStore = create(
persist(
(set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}),
{
name: 'my-storage', // localStorage key
partialize: (state) => ({ count: state.count }), // 只持久化 count
version: 1, // 版本号
}
)
)
Zustand 的 persist 中间件内置了 hydration 处理,不需要 PersistGate 包装,store 创建完成后数据已经就绪——这是 Zustand 比 Redux 更简洁的核心原因之一。
5. 不可序列化数据怎么存
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Set、Map、Date、BigInt 不能直接 JSON.stringify
const state = {
visited: new Set([1, 2, 3]),
cache: new Map([['a', 1]]),
createdAt: new Date(),
}
// ✅ 用自定义 serializer + deserializer
const storage = createJSONStorage(() => localStorage, {
replacer: (_, value) => {
if (value instanceof Set) return { __t: 'Set', v: [...value] }
if (value instanceof Map) return { __t: 'Map', v: [...value] }
if (value instanceof Date) return { __t: 'Date', v: value.toISOString() }
return value
},
reviver: (_, value) => {
if (value?.__t === 'Set') return new Set(value.v)
if (value?.__t === 'Map') return new Map(value.v)
if (value?.__t === 'Date') return new Date(value.v)
return value
},
})
其实你每天都在用
- 主题切换 — 用户选了 dark mode,刷新后还是 dark mode。实现:
localStorage.setItem('theme', 'dark')+ 应用初始化时读取 - 表单草稿 — 写了一大段内容不小心关掉了页面,再打开时草稿还在。实现:防抖 + localStorage,配合
beforeunload事件兜底 - 购物车 — 没有登录时加购的商品,登录后还能看到。实现:登录前存 localStorage,登录后合并到服务端
- Redux DevTools 的持久化 — devtools 设置(你勾了哪些 monitor、主题色)也是用 localStorage 存的
- 视频/音频播放进度 — B 站/YouTube 记住你上次看到哪里,原理就是定时往 localStorage 写当前播放时间
常见误解(FAQ)
❌ 误区:「所有状态都该持久化」 实际上只有”用户期望恢复的”状态才需要——用户偏好(theme、语言)、登录态、草稿数据。loading 状态、动画中间态、临时 UI 状态都不该持久化。问自己:用户明天打开这个页面,还期望看到这个值吗?
❌ 误区:「localStorage 的 5MB 用不完」 单个站的 5MB 极限很容易碰到——一个中大型应用把 Redux 整个 store 序列化存进去,加上历史数据、草稿缓存,很轻松就填满了。setItem 超过限制会抛出 QuotaExceededError,必须 try-catch。解决方案:分层存储(小数据 localStorage,大数据 IndexedDB)。
❌ 误区:「localStorage 存 token 很安全」 localStorage 可以被任何同源 JS 读取,XSS 攻击下 token 瞬间泄露。生产环境里认证 token 应该存在 HttpOnly Cookie 中(JS 无法读取),或至少加密后再存 localStorage。Web Crypto API 提供了标准的 AES-GCM 加密能力。
❌ 误区:「redux-persist 会自动合并新旧状态」 默认的合并策略是完全覆盖——持久化的旧数据直接覆盖 initialState。如果旧数据里有个 key 新代码不需要了,它会一直留在 store 里。需要用 stateReconciler: autoMergeLevel2 来做浅合并,或者写 migrate 函数在版本升级时清理旧字段。
一句话总结
持久化的本质不是”把数据存起来”这么简单——它是运行时状态(快)和持久化存储(慢)之间的桥梁,bridge 两边有同步/异步差异、schema 差异、安全差异,每一项处理不好都会变成线上 bug。