Skip to content

06|持久化与恢复 ​

第五章概览 / 06。目标:把 storage 当作不可信、可能失败、可能过期的外部输入;Pinia 本身不自动提供持久化。

持久化格式先于监听器 ​

localStorage 按 origin 保存 UTF-16 字符串,跨浏览器会话保留;浏览器策略、无效 origin 或隐私设置可能让访问抛 SecurityError。它是同步 API,大对象频繁序列化会阻塞主线程。

不要直接保存整个 $state。先定义最小、可迁移的信封:

ts
interface DraftEnvelopeV2 {
  version: 2
  savedAt: number
  value: {
    documentId: string
    text: string
  }
}

const STORAGE_KEY = 'draft:v2'
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000

恢复函数必须接住四类失败 ​

ts
type Draft = { documentId: string; text: string }

function readDraft(storage: Storage, now = Date.now()): Draft | null {
  try {
    const raw = storage.getItem(STORAGE_KEY)
    if (raw === null) return null

    const data: unknown = JSON.parse(raw)
    if (!isDraftEnvelopeV2(data)) {
      storage.removeItem(STORAGE_KEY)
      return null
    }
    if (data.savedAt > now || now - data.savedAt >= MAX_AGE_MS) {
      storage.removeItem(STORAGE_KEY)
      return null
    }
    return data.value
  } catch {
    // storage 被禁用、JSON 损坏或容量/策略异常:回到安全默认值
    return null
  }
}

function isDraftEnvelopeV2(value: unknown): value is DraftEnvelopeV2 {
  if (typeof value !== 'object' || value === null) return false
  const data = value as Record<string, unknown>
  const draft = data.value as Record<string, unknown> | undefined
  return data.version === 2
    && typeof data.savedAt === 'number' && Number.isFinite(data.savedAt)
    && typeof draft?.documentId === 'string'
    && typeof draft.text === 'string'
}

四类失败分别是:没有记录、JSON 损坏、schema 不认识、记录过期;此外 storage 自身也可能拒绝访问。恢复的服务端兜底是重新获取权威数据,不是猜测损坏字段。

版本迁移应是显式纯函数。例如 v1 只有 text 时,只有在能确定默认 documentId 的上下文里才迁到 v2;否则删除旧记录。不要把迁移藏进组件模板。

时间字段也要参加输入校验。本例约定“年龄严格小于有效期”才可用;刚好到期、未来时间或非有限数都拒绝。否则用户把 savedAt 改成远未来,记录就可能长期被误当新鲜。浏览器时钟倒退也会触发这个保守分支,结果是放弃恢复并重新取权威数据,而不是静默延长有效期。

从读取到恢复可以分成三步观察:getItem 只取得字符串,JSON.parse 只证明语法能解析,schema/时间检查才决定它能否进入 store。比如字符串 "null" 能被解析,却不是草稿信封;{"version":2,"savedAt":0,"value":{"text":"hello"}} 有文字,却缺文档身份。不要在第二步刚成功时就调用 store.$patch(data),那会把未验证的外部字段带入响应式状态。

先恢复,再订阅保存 ​

ts
export function installDraftPersistence(store: ReturnType<typeof useDraftStore>) {
  if (typeof window === 'undefined') return () => undefined

  let storage: Storage
  try { storage = window.localStorage }
  catch { return () => undefined }
  const restored = readDraft(storage)
  if (restored) store.restore(restored)

  const stop = store.$subscribe((_mutation, state) => {
    const envelope: DraftEnvelopeV2 = {
      version: 2,
      savedAt: Date.now(),
      value: { documentId: state.documentId, text: state.text },
    }
    try {
      storage.setItem(STORAGE_KEY, JSON.stringify(envelope))
    } catch (error) {
      // 不写回被订阅的store,避免持续保存失败触发反馈循环。
      console.warn('草稿暂时无法保存', error instanceof Error ? error.name : 'storage')
    }
  }, { detached: true })

  return stop
}

若先订阅再恢复,store 的默认空值可能先覆盖已有草稿。应用级安装函数返回 stop,测试、热更新或应用销毁时必须调用。输入高频时还应在 owner 内做节流/防抖,并在停止前决定是否 flush 最后一次保存。

多标签页和冲突 ​

其他标签页修改 localStorage 会在当前页面触发 storage 事件,写入它的同一页面不会收到自己的事件。监听器同样要清理:

ts
function onStorage(event: StorageEvent) {
  if (event.key !== STORAGE_KEY) return
  const incoming = readDraft(window.localStorage)
  if (incoming) store.offerExternalDraft(incoming)
}

window.addEventListener('storage', onStorage)
// owner 销毁时:window.removeEventListener('storage', onStorage)

不要静默用“最后写入获胜”覆盖正在编辑的本地草稿;至少比较 savedAt/revision 并提示用户选择。需要可靠多端协作时,服务端版本号和冲突协议才是 owner。

安全、SSR 与清理 ​

  • localStorage 对同源页面 JavaScript 可读;一旦发生 XSS,里面的 token、个人资料和密钥都可能被取走。不要把它当保险箱。
  • 前端打包中的 API key 也不是秘密;真正的私密凭证留在服务端。会话优先使用由服务端设置、前端脚本不可读的合适 cookie,并配套 CSRF 策略。
  • SSR 没有 window。服务端为每次请求创建 state;若把 state 注入 HTML,必须使用能安全转义的序列化方式,防止用户内容闭合脚本标签。
  • 登出应移除当前用户相关 key、清内存 store、取消请求,并让服务端 session 失效。

练习:可恢复筛选条件 ​

实现 readFilter(storage, now) 与 writeFilter(storage, value, now)。格式版本为 2,值只允许 { category: string, pageSize: 10 | 20 | 50 },有效期 24 小时。

验收:缺失、损坏 JSON、错误版本、非法 pageSize、过期记录都返回默认 { category: 'all', pageSize: 20 };写入失败不抛出到 UI,而返回 false;不能保存 token、请求结果或错误堆栈。再说明浏览器多标签冲突和 SSR 时分别如何处理。

参考答案:第 06 题。

本页结论 ​

持久化是一个输入校验与恢复协议:最小字段、版本、有效期、迁移、失败默认值和清理缺一不可。保存成功不代表数据可信或安全。

用心记录每一步成长