文章

状态持久化方案深度解析

从 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' })
}
特性localStoragesessionStorageCookieIndexedDB
容量5-10MB5-10MB4KB数百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
  },
})

其实你每天都在用

  1. 主题切换 — 用户选了 dark mode,刷新后还是 dark mode。实现:localStorage.setItem('theme', 'dark') + 应用初始化时读取
  2. 表单草稿 — 写了一大段内容不小心关掉了页面,再打开时草稿还在。实现:防抖 + localStorage,配合 beforeunload 事件兜底
  3. 购物车 — 没有登录时加购的商品,登录后还能看到。实现:登录前存 localStorage,登录后合并到服务端
  4. Redux DevTools 的持久化 — devtools 设置(你勾了哪些 monitor、主题色)也是用 localStorage 存的
  5. 视频/音频播放进度 — 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。

本文由作者按照 CC BY 4.0 进行授权