文章

状态持久化方案深度解析

全面解析前端状态持久化的各种方案,从 localStorage 基础到 redux-persist、vuex-persist 再到自定义实现,构建完整的离线状态管理知识体系。

状态持久化方案深度解析

一句话概括

状态持久化是将前端运行时状态同步到持久化存储(localStorage、IndexedDB、Cookie 等)的技术,确保用户在刷新页面或重新打开应用时能够恢复之前的操作状态,是提升用户体验的关键基础设施。

背景与意义

默认情况下,前端应用的状态是「活在内存中」的——刷新页面,一切归零。对于某些场景这没问题,但对于很多现实需求来说,这是一个严重的体验问题:

  • 用户填写了复杂的表单,不小心刷新页面——所有输入内容丢失
  • 用户在购物车中添加了 5 件商品,关闭浏览器再打开——购物车空空如也
  • 用户设置了深色主题和语言偏好——每次访问都需要重新设置

状态持久化的目标就是在「运行时状态的易失性」和「用户期望的持久性」之间建立桥梁。

概念与定义

持久化(Persistence):将内存中的数据写入持久化存储介质(磁盘、数据库等)的过程。在前端特指将 JavaScript 运行时状态同步到浏览器的持久化存储 API。

水合(Hydration):持久化的反向过程——从存储介质读取数据恢复到运行时状态。通常在应用初始化时执行。

存储适配器(Storage Adapter):封装不同存储后端(localStorage、sessionStorage、AsyncStorage 等)的统一接口。

版本迁移(Migration):当持久化数据的格式发生变化时,将旧数据迁到新格式的过程。

最小示例

最简单的持久化实现——手动持久化一个计数器:

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
// 保存
const count = 42
localStorage.setItem('count', String(count))

// 读取
const savedCount = Number(localStorage.getItem('count')) || 0

// 在 React 中的应用
function usePersistedState(key, defaultValue) {
  const [value, setValue] = useState(() => {
    const stored = localStorage.getItem(key)
    return stored !== null ? JSON.parse(stored) : defaultValue
  })

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value))
  }, [key, value])

  return [value, setValue]
}

// 使用
function Counter() {
  const [count, setCount] = usePersistedState('counter', 0)
  return <button onClick={() => setCount((c) => c + 1)}>Count: {count}</button>
}

核心知识点拆解

1. 存储引擎深度对比

浏览器提供了多种持久化存储方式,各有优劣:

特性localStoragesessionStorageCookieIndexedDBCache API
容量5-10MB5-10MB4KB不限(通常 250MB+)不限
数据类型字符串字符串字符串结构化数据网络请求
同步/异步同步同步同步异步异步
作用域协议+域名+端口协议+域名+端口+标签页可配置路径协议+域名+端口协议+域名+端口
过期手动删除标签页关闭可设置手动删除手动删除
Worker 访问

IndexedDB 的基础使用

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// IndexedDB 封装工具
class IndexedDBStore {
  constructor(dbName, storeName, version = 1) {
    this.dbName = dbName
    this.storeName = storeName
    this.version = version
    this.db = null
  }

  open() {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.dbName, this.version)

      request.onupgradeneeded = (event) => {
        const db = event.target.result
        if (!db.objectStoreNames.contains(this.storeName)) {
          db.createObjectStore(this.storeName, { keyPath: 'id' })
        }
      }

      request.onsuccess = (event) => {
        this.db = event.target.result
        resolve()
      }

      request.onerror = (event) => reject(event.target.error)
    })
  }

  get(id) {
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction(this.storeName, 'readonly')
      const store = transaction.objectStore(this.storeName)
      const request = store.get(id)

      request.onsuccess = () => resolve(request.result)
      request.onerror = (e) => reject(e.target.error)
    })
  }

  set(id, value) {
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction(this.storeName, 'readwrite')
      const store = transaction.objectStore(this.storeName)
      const request = store.put({ id, value })

      request.onsuccess = () => resolve()
      request.onerror = (e) => reject(e.target.error)
    })
  }

  delete(id) {
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction(this.storeName, 'readwrite')
      const store = transaction.objectStore(this.storeName)
      const request = store.delete(id)

      request.onsuccess = () => resolve()
      request.onerror = (e) => reject(e.target.error)
    })
  }
}

2. redux-persist 的完整配置

redux-persist 是 Redux 生态中最广泛使用的持久化方案:

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// store.js - redux-persist 完整配置
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' // defaults to localStorage
import autoMergeLevel2 from 'redux-persist/lib/stateReconciler/autoMergeLevel2'

// 持久化配置
const persistConfig = {
  key: 'root',                 // storage 根键名
  storage,                     // 存储引擎
  whitelist: ['auth', 'cart', 'theme'], // 只持久化这些 reducer
  blacklist: ['notifications', 'ui'],     // 排除这些 reducer
  version: 2,                  // 版本号,用于数据迁移
  stateReconciler: autoMergeLevel2, // 状态合并策略
  timeout: 5000,               // 超时时间(毫秒)
  writeFailHandler: (err) => {
    console.error('Persist failed:', err)
  },
}

// 带嵌套 reducer 的持久化
const rootReducer = combineReducers({
  auth: persistedAuthReducer,
  cart: persistedCartReducer,
  theme: persistedThemeReducer,
  notifications: notificationsReducer, // 不持久化
  ui: uiReducer,                       // 不持久化(黑名单)
})

const persistedReducer = persistReducer(persistConfig, rootReducer)

export const store = configureStore({
  reducer: persistedReducer,
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: {
        // redux-persist 的 action 不满足可序列化检查
        // 需要忽略这些 action type
        ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
      },
    }),
})

export const persistor = persistStore(store)

// App.js 中
import { PersistGate } from 'redux-persist/integration/react'

function App() {
  return (
    <Provider store={store}>
      <PersistGate loading={<LoadingScreen />} persistor={persistor}>
        <MainApp />
      </PersistGate>
    </Provider>
  )
}

数据版本迁移

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
// migration.js - 数据迁移
const migrations = {
  // version 0 → 1:添加新字段
  1: (state) => ({
    ...state,
    auth: {
      ...state.auth,
      lastLogin: null, // 新增字段
    },
  }),
  // version 1 → 2:重构数据结构
  2: (state) => ({
    ...state,
    cart: {
      items: state.cart.items || [],
      // 旧数据中 coupon 在根级别,新版本移到 cart 内
      coupon: state.coupon || null,
      lastUpdated: Date.now(),
    },
  }),
}

export const persistConfig = {
  key: 'root',
  storage,
  version: 2,
  migrate: createMigrate(migrations, { debug: true }),
}

3. vuex-persist 的实现

对于 Vuex,vuex-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
29
30
31
32
33
34
35
36
37
38
// store.js - vuex-persist
import Vuex from 'vuex'
import VuexPersistence from 'vuex-persist'

const vuexLocal = new VuexPersistence({
  key: 'vuex-store',      // localStorage key
  storage: window.localStorage,
  reducer: (state) => ({
    // 只持久化需要的模块
    auth: {
      token: state.auth.token,
      user: state.auth.user,
    },
    cart: state.cart,
    settings: state.settings,
  }),
  // 过滤某些 mutation 后不触发持久化
  filter: (mutation) => {
    return mutation.type !== 'ui/SET_SCROLL_POSITION'
  },
  // 存储前的数据转换
  serializer: {
    serialize: (data) => {
      // 加密敏感数据
      return JSON.stringify(encryptData(data))
    },
    deserialize: (raw) => {
      return decryptData(JSON.parse(raw))
    },
  },
})

const store = new Vuex.Store({
  modules: {
    auth, cart, settings, ui, notifications,
  },
  plugins: [vuexLocal.plugin],
})

Pinia 中的持久化(基于 pinia-plugin-persistedstate)

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
29
30
31
32
33
34
35
36
// stores/auth.js - Pinia 持久化
import { defineStore } from 'pinia'
import { ref } from 'vue'

export const useAuthStore = defineStore('auth', () => {
  const user = ref(null)
  const token = ref(null)

  function setCredentials(u, t) {
    user.value = u
    token.value = t
  }

  function logout() {
    user.value = null
    token.value = null
  }

  return { user, token, setCredentials, logout }
}, {
  // store 级别的持久化配置(通过 pinia-plugin-persistedstate)
  persist: {
    key: 'auth-store',
    storage: localStorage,
    paths: ['token'], // 只持久化 token,不持久化 user
  },
})

// 全局配置
// main.js
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)

4. Zustand 的 persist 中间件

Zustand 内置了 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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'

// 基本持久化
const useBearStore = create(
  persist(
    (set, get) => ({
      bears: 0,
      fish: 0,
      addBear: () => set((state) => ({ bears: state.bears + 1 })),
      addFish: () => set((state) => ({ fish: state.fish + 1 })),
    }),
    {
      name: 'bear-storage',
      storage: createJSONStorage(() => localStorage),
      // 只持久化部分字段
      partialize: (state) => ({
        bears: state.bears,
        fish: state.fish,
      }),
      // 版本迁移
      version: 3,
      migrate: (persistedState, version) => {
        if (version === 0) {
          // v0: { count: number }
          // v1: { bears: number, fish: number }
          return {
            bears: persistedState.count || 0,
            fish: 0,
          }
        }
        if (version === 1) {
          // Update to v2: add timestamp
          return {
            ...persistedState,
            lastUpdated: Date.now(),
          }
        }
        // Return as is (v2 → v3: unchanged)
        return persistedState
      },
    }
  )
)

自定义存储适配器

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
29
30
31
32
33
34
// custom-storage.ts - IndexedDB 存储适配器
import { createJSONStorage } from 'zustand/middleware'
import { IndexedDBStore } from './IndexedDBStore'

const dbStore = new IndexedDBStore('app-db', 'zustand', 1)
await dbStore.open()

export const indexedDBStorage = createJSONStorage(() => ({
  getItem: async (name) => {
    const record = await dbStore.get(name)
    return record?.value || null
  },
  setItem: async (name, value) => {
    await dbStore.set(name, value)
  },
  removeItem: async (name) => {
    await dbStore.delete(name)
  },
}))

// 使用 IndexedDB 作为存储后端
const useStore = create(
  persist(
    (set) => ({
      // large data that wouldn't fit in localStorage
      history: [],
      cache: {},
    }),
    {
      name: 'large-data',
      storage: indexedDBStorage,
    }
  )
)

实战案例:多级表单的断点续填

下面是电子商务中多步骤表单的完整持久化实现。用户可以在填写过程中随时离开,再回来时继续。

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
// useFormPersistence.js - 表单持久化 Hook
import { useCallback, useEffect, useRef } from 'react'

const STORAGE_KEY_PREFIX = 'form_draft_'

export function useFormPersistence(formId, formState) {
  // 使用防抖,防止频繁写入
  const timerRef = useRef(null)
  const storageKey = STORAGE_KEY_PREFIX + formId

  // 自动保存(组件卸载或页面隐藏时)
  useEffect(() => {
    function save() {
      try {
        localStorage.setItem(storageKey, JSON.stringify({
          data: formState,
          savedAt: Date.now(),
          version: 2,
        }))
      } catch (e) {
        // localStorage 满时的降级策略
        console.warn('Persistence save failed:', e)
        // 可以尝试清除旧数据或使用 sessionStorage 降级
        cleanupOldDrafts()
      }
    }

    // 页面关闭/隐藏时保存
    window.addEventListener('beforeunload', save)
    return () => {
      window.removeEventListener('beforeunload', save)
      clearTimeout(timerRef.current)
    }
  }, [formState, storageKey])

  // 手动保存(防抖)
  const saveDraft = useCallback(() => {
    clearTimeout(timerRef.current)
    timerRef.current = setTimeout(() => {
      localStorage.setItem(storageKey, JSON.stringify({
        data: formState,
        savedAt: Date.now(),
        version: 2,
      }))
    }, 500)
  }, [formState, storageKey])

  // 恢复草稿
  const loadDraft = useCallback(() => {
    try {
      const raw = localStorage.getItem(storageKey)
      if (!raw) return null

      const draft = JSON.parse(raw)
      return draft.data
    } catch {
      localStorage.removeItem(storageKey)
      return null
    }
  }, [storageKey])

  // 删除草稿(提交成功后调用)
  const clearDraft = useCallback(() => {
    localStorage.removeItem(storageKey)
  }, [storageKey])

  // 草稿信息
  const getDraftInfo = useCallback(() => {
    try {
      const raw = localStorage.getItem(storageKey)
      if (!raw) return null
      const draft = JSON.parse(raw)
      return {
        exists: true,
        savedAt: draft.savedAt,
        age: Date.now() - draft.savedAt,
      }
    } catch {
      return null
    }
  }, [storageKey])

  return { saveDraft, loadDraft, clearDraft, getDraftInfo }
}

// 清理过期草稿
function cleanupOldDrafts() {
  const ONE_WEEK = 7 * 24 * 60 * 60 * 1000
  for (let i = 0; i < localStorage.length; i++) {
    const key = localStorage.key(i)
    if (key.startsWith(STORAGE_KEY_PREFIX)) {
      try {
        const data = JSON.parse(localStorage.getItem(key))
        if (Date.now() - data.savedAt > ONE_WEEK) {
          localStorage.removeItem(key)
        }
      } catch {
        localStorage.removeItem(key)
      }
    }
  }
}

多步骤表单中的使用

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
function CheckoutForm() {
  const steps = ['shipping', 'payment', 'review']
  const [currentStep, setCurrentStep] = useState(0)
  const [formData, setFormData] = useState({
    shipping: {},
    payment: {},
    billing: {},
  })

  const { loadDraft, clearDraft, saveDraft } = useFormPersistence('checkout_v2', formData)

  // 初始化:恢复草稿
  useEffect(() => {
    const draft = loadDraft()
    if (draft) {
      // 提示用户有未完成的订单
      if (confirm('检测到未完成的订单,是否继续填写?')) {
        setFormData(draft)
        // 跳转到上次中断的步骤
        const savedStep = getSavedStep(draft)
        if (savedStep !== null) setCurrentStep(savedStep)
      }
    }
  }, [])

  // 每次步骤变更时保存
  const goToStep = (step) => {
    saveDraft() // 保存当前步骤数据
    setCurrentStep(step)
  }

  const handleSubmit = async () => {
    await api.submitOrder(formData)
    clearDraft() // 提交成功后清除草稿
    // 跳转到成功页
  }

  return (
    <div className="checkout-form">
      <StepIndicator currentStep={currentStep} steps={steps} />
      {currentStep === 0 && (
        <ShippingForm
          data={formData.shipping}
          onChange={(data) => setFormData((prev) => ({ ...prev, shipping: data }))}
        />
      )}
      {currentStep === 1 && (
        <PaymentForm
          data={formData.payment}
          onChange={(data) => setFormData((prev) => ({ ...prev, payment: data }))}
        />
      )}
      <div className="step-actions">
        {currentStep > 0 && (
          <button onClick={() => goToStep(currentStep - 1)}>上一步</button>
        )}
        {currentStep < steps.length - 1 ? (
          <button onClick={() => goToStep(currentStep + 1)}>下一步</button>
        ) : (
          <button onClick={handleSubmit}>提交订单</button>
        )}
      </div>
    </div>
  )
}

底层原理(含源码分析)

1. localStorage 的同步陷阱

localStorage 的 getItem 和 setItem 是同步操作,但浏览器实现中涉及磁盘 I/O。当存储大量数据时,同步调用会阻塞主线程:

1
2
3
4
5
6
7
8
9
// localStorage 的阻塞问题
console.time('localStorage write')
for (let i = 0; i < 100; i++) {
  localStorage.setItem(`key_${i}`, 'x'.repeat(10000)) // 写入 10KB × 100
}
console.timeEnd('localStorage write')
// → 写入耗时可能达到 50-200ms,期间主线程完全阻塞

// 解决方案:使用 IndexedDB(异步)或分批次写入

异步持久化的价值:对于大型数据(如离线地图数据、用户操作日志),推荐使用 IndexedDB。其异步 API 不会阻塞主线程,并且支持事务和索引。

2. redux-persist 的源码核心

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// redux-persist 核心逻辑简化
function persistReducer(config, reducer) {
  return (state, action) => {
    if (action.type === 'PERSIST') {
      // 持久化阶段:将 state 写入存储
      const stored = config.serialize
        ? config.serialize(state)
        : JSON.stringify(state)

      config.storage.setItem(config.key, stored)

      return state
    }

    if (action.type === 'REHYDRATE') {
      if (action.key === config.key) {
        // 水合阶段:从存储读取并还原 state
        const restored = config.deserialize
          ? config.deserialize(action.payload)
          : JSON.parse(action.payload)

        // 使用 stateReconciler 合并策略
        return config.stateReconciler(state, restored)
      }
      return state
    }

    // 正常 reducer
    return reducer(state, action)
  }
}

// PersistGate 组件
function PersistGate({ persistor, children, loading }) {
  const [bootstrapped, setBootstrapped] = useState(false)

  useEffect(() => {
    // 启动水合
    persistor.start()
    persistor.subscribe(() => {
      if (persistor.getState().bootstrapped) {
        setBootstrapped(true)
      }
    })
  }, [])

  return bootstrapped ? children : loading
}

stateReconciler 的合并策略

1
2
3
4
5
6
7
8
9
10
11
默认合并(level 0):
  存储中的数据完全覆盖当前 state
  → 适合首次启动

自动合并 level 1:
  只合并 state 中存在的 key
  → 防止旧数据中的过时 key 污染新 state

自动合并 level 2(推荐):
  递归合并嵌套对象
  → 适合有嵌套状态的 reducer

3. 序列化与加密

敏感数据(如 token、用户信息)在持久化时应该加密:

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// crypto-utils.js - 持久化加密
const ENCRYPTION_KEY = 'app-specific-key'

function encrypt(text) {
  // 轻量级混淆(注意:不是真正的安全加密)
  // 真正需要安全时应该使用 Web Crypto API
  return btoa(
    text.split('').map((char, i) =>
      String.fromCharCode(char.charCodeAt(0) ^ ENCRYPTION_KEY.charCodeAt(i % ENCRYPTION_KEY.length))
    ).join('')
  )
}

function decrypt(encoded) {
  return atob(encoded).split('').map((char, i) =>
    String.fromCharCode(char.charCodeAt(0) ^ ENCRYPTION_KEY.charCodeAt(i % ENCRYPTION_KEY.length))
  ).join('')
}

// 使用 Web Crypto API 的 AES-GCM 加密(生产级)
async function securelyEncrypt(text, password) {
  const encoder = new TextEncoder()
  const data = encoder.encode(text)

  // 从 password 派生密钥
  const keyMaterial = await crypto.subtle.importKey(
    'raw',
    encoder.encode(password),
    'PBKDF2',
    false,
    ['deriveKey']
  )

  const key = await crypto.subtle.deriveKey(
    {
      name: 'PBKDF2',
      salt: encoder.encode('unique-salt'),
      iterations: 100000,
      hash: 'SHA-256',
    },
    keyMaterial,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt']
  )

  const iv = crypto.getRandomValues(new Uint8Array(12))
  const encrypted = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv },
    key,
    data
  )

  // 返回 iv + ciphertext
  const result = new Uint8Array(iv.length + encrypted.byteLength)
  result.set(iv)
  result.set(new Uint8Array(encrypted), iv.length)

  return btoa(String.fromCharCode(...result))
}

高频面试题解析

Q1: localStorage 的容量限制(通常 5-10MB)满了怎么办?

考察点:对存储容量限制和降级策略的理解。

答案核心

当 localStorage 满了时,setItem 会抛出一个 QuotaExceededError。处理方案:

  1. 存储分层策略
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    
    // 分层存储适配器
    class TieredStorage {
      async set(key, value) {
        const size = new Blob([value]).size
    
        if (size < 100 * 1024) { // < 100KB → localStorage
          localStorage.setItem(key, value)
        } else { // ≥ 100KB → IndexedDB
          await dbStore.set(key, value)
        }
      }
    }
    
  2. LRU 缓存淘汰:当 localStorage 满时,自动删除最久未访问的数据
  3. 压缩存储:使用 lz-string 等压缩算法压缩存储内容(文本通常可压缩 50-70%)
  4. 提示用户清理:当存储使用超过 80% 时,提示用户清理缓存

Q2: 持久化数据与运行时状态不一致如何处理?(水合冲突)

考察点:对版本迁移和数据一致性的理解。

答案核心

水合冲突的常见原因和解决方案:

1
2
3
4
5
6
7
8
9
10
11
原因 1:数据结构变化
  持久化的旧数据与当前代码期望的新结构不匹配
  解决:版本迁移(migration)

原因 2:并发更新
  多个标签页打开了同一个应用,各自独立持久化
  解决:使用 storage 事件监听跨标签页变化

原因 3:部分更新
  只持久化了 store 的一部分,但代码期望完整数据
  解决:设置合理的默认值 + 深度合并(autoMergeLevel2)
1
2
3
4
5
6
7
8
9
// storage 事件监听——跨标签页同步
window.addEventListener('storage', (event) => {
  if (event.key === 'app-storage') {
    // 其他标签页修改了存储内容
    const newData = JSON.parse(event.newValue)
    // 与当前 state 合并
    reconcileState(newData)
  }
})

Q3: 如何持久化「不可序列化」的数据?(如 Set、Map、Date、函数)

考察点:对序列化限制和处理技巧的理解。

答案核心

JSON.stringify 无法处理 Set、Map、Date、函数等特殊类型。解决方案:

  1. 自定义序列化器
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    
    const serializer = {
      serialize: (data) => JSON.stringify(data, (key, value) => {
        if (value instanceof Set) return { __type: 'Set', values: [...value] }
        if (value instanceof Map) return { __type: 'Map', entries: [...value] }
        if (value instanceof Date) return { __type: 'Date', timestamp: value.getTime() }
        return value
      }),
      deserialize: (raw) => JSON.parse(raw, (key, value) => {
        if (value?.__type === 'Set') return new Set(value.values)
        if (value?.__type === 'Map') return new Map(value.entries)
        if (value?.__type === 'Date') return new Date(value.timestamp)
        return value
      }),
    }
    
  2. 不持久化不可序列化数据:在持久化之前剥离不可序列化的字段
  3. 使用结构克隆算法structuredClone() 可以复制大部分数据类型,但无法处理函数和 Symbol
  4. 替代方案:能在运行时重建的数据就不持久化(如计算结果、缓存数据)

总结与扩展

状态持久化是前端工程中「常常被忽略但至关重要」的环节。本文从存储引擎的基础知识出发,覆盖了 redux-persist、vuex-persist、Pinia、Zustand 等主流方案的配置,并通过多步骤表单的案例展示了完整实现。

关键原则

  1. 按需持久化:不是所有状态都需要持久化,区分「用户期望恢复的」和「临时性的」
  2. 降级策略:存储满时要有降级方案,不能因此崩溃
  3. 版本管理:持久化数据要有版本号,支持迁移
  4. 敏感数据加密:token、个人信息在持久化时必须加密

扩展思考

随着 PWA(渐进式 Web 应用)和离线优先(Offline First)模式的普及,状态持久化的需求正在演变。从简单的 localStorage 方案到完整的离线数据同步架构(如 Workbox + IndexedDB + Background Sync),持久化已经从「保存状态」进化为「构建离线体验的基础设施」。

另一个值得关注的趋势是浏览器即将(或正在)推出的 Storage Foundation API——一个更底层的、专为大容量存储设计的 API,有望替代 localStorage 成为新一代持久化标准。

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