用户怒了:手一滑按了 F5,2 小时的工作全没了——我解决了问题,并顺带加了「防抖」

在这里插入图片描述

阅读大约需 8 分钟


那天凌晨 2 点的愤怒工单

小禾正准备下班,企业微信突然弹出一条消息。

来自用户"夜猫子写手":

你们这什么垃圾产品?!!!
我刚写了 2 个小时的故事,手一滑刷新了页面,
全!没!了!
连个草稿保存都没有???
我要退款!!!

在这里插入图片描述
小禾看着屏幕,心里咯噔一下。

作为一个 Vue3 开发者,他太清楚问题出在哪了:

前端状态默认活在内存里,页面一刷新,内存清空,数据归零。

这是 SPA 应用的通病。

小禾立刻打开项目代码,看了眼 Pinia Store:

// stores/story.ts
export const useStoryStore = defineStore('story', {
  state: () => ({
    currentStory: null as Story | null,
    stories: [] as Story[],
  }),

  actions: {
    updateStory(story: Story) {
      this.currentStory = story
      // 就这样,啥也没存
    }
  }
})

数据确实只在内存里。

用户刷新 = Ctrl+W = 数据蒸发。

小禾叹了口气,泡了杯咖啡,开始改代码。


第一版:最简单粗暴的方案

小禾想:保存到 localStorage 不就完了?

10 分钟后,他写出了第一版:

// ❌ 简单但有问题的做法
export const useStoryStore = defineStore('story', {
  state: () => ({
    stories: JSON.parse(localStorage.getItem('stories') || '[]')
  }),

  actions: {
    addStory(story: Story) {
      this.stories.push(story)
      localStorage.setItem('stories', JSON.stringify(this.stories))
    },

    updateStory(id: string, updates: Partial<Story>) {
      const story = this.stories.find(s => s.id === id)
      if (story) {
        Object.assign(story, updates)
        localStorage.setItem('stories', JSON.stringify(this.stories))
      }
    },

    deleteStory(id: string) {
      this.stories = this.stories.filter(s => s.id !== id)
      localStorage.setItem('stories', JSON.stringify(this.stories))
    }
  }
})

测试了一下,能用。

用户编辑内容 → 自动保存到 localStorage → 刷新页面 → 数据还在。

完美。

小禾准备提交代码。

然后他又看了一眼……

每个 action 都要手动调用 localStorage.setItem()

如果以后加了 50 个 action,就要写 50 次保存逻辑?

哪天忘了写一次,数据又丢了?

不行,这代码有"坏味道"。


第二版:统一的保存机制

小禾决定抽象一个 saveState() 方法,所有 action 统一调用:

export const useStoryStore = defineStore('story', {
  state: () => ({
    currentStory: null as Story | null,
    stories: [] as Story[],
    currentSceneId: '',
    currentShotId: '',
  }),

  actions: {
    // 📦 统一的保存方法
    saveState() {
      try {
        // 保存故事列表
        localStorage.setItem('stories', JSON.stringify(this.stories))

        // 保存当前选中状态
        if (this.currentStory) {
          localStorage.setItem('currentStoryId', this.currentStory.id)
        }
        localStorage.setItem('currentSceneId', this.currentSceneId)
        localStorage.setItem('currentShotId', this.currentShotId)

        console.log('✅ 状态已保存')
      } catch (e) {
        console.error('❌ 保存失败:', e)
        // 可能是存储空间不足
        this.handleStorageError(e)
      }
    },

    // 📂 初始化时恢复状态
    loadState() {
      try {
        // 恢复故事列表
        const storiesJson = localStorage.getItem('stories')
        if (storiesJson) {
          this.stories = JSON.parse(storiesJson)
        }

        // 恢复当前选中
        const currentId = localStorage.getItem('currentStoryId')
        if (currentId) {
          this.currentStory = this.stories.find(s => s.id === currentId) || null
        }

        this.currentSceneId = localStorage.getItem('currentSceneId') || ''
        this.currentShotId = localStorage.getItem('currentShotId') || ''

        console.log(`📂 已恢复 ${this.stories.length} 个故事`)
      } catch (e) {
        console.error('❌ 恢复失败:', e)
        // 数据损坏,重置
        this.resetState()
      }
    },

    // 其他 action 在修改后调用 saveState
    updateScene(sceneId: string, updates: Partial<Scene>) {
      const scene = this.getSceneById(sceneId)
      if (scene) {
        Object.assign(scene, updates)
        this.saveState()  // ← 自动保存
      }
    },

    deleteScene(sceneId: string) {
      if (!this.currentStory) return
      this.currentStory.scenes = this.currentStory.scenes.filter(s => s.id !== sceneId)
      this.saveState()  // ← 自动保存
    }
  }
})

然后在应用启动时恢复状态:

// App.vue
import { onMounted } from 'vue'
import { useStoryStore } from '@/stores/story'

const storyStore = useStoryStore()

// 应用启动时恢复状态
onMounted(() => {
  storyStore.loadState()
})

// 页面关闭前保存(兜底)
window.addEventListener('beforeunload', () => {
  storyStore.saveState()
})

好多了。

现在只需要在每个 action 最后加一行 this.saveState()

代码整洁了,也不容易遗漏。

小禾测试了一下,用户编辑故事 → 刷新页面 → 数据完美恢复。

他准备提交代码。

然后实习生小张跑过来:“禾哥,我发现一个问题……”


性能灾难:用户每输入一个字就卡一下

小张演示了一下:在文本框里输入内容,每输入一个字符,页面就卡一下。

打开控制台,发现 saveState() 被疯狂调用:

✅ 状态已保存
✅ 状态已保存
✅ 状态已保存
✅ 状态已保存
...(100 次/秒)

小禾一拍脑门:忘了防抖!

用户在输入框打字,每输入一个字符就会触发 updateScene(),然后就是:

  1. 修改 state
  2. JSON.stringify() 整个故事列表
  3. 写入 localStorage

如果故事列表很大(比如 100 个故事,每个 50 个镜头),JSON.stringify() 可能要几十毫秒。

每秒 60 帧的界面,几十毫秒就是好几帧的卡顿。

必须防抖。


第三版:防抖保存 + 关键时刻立即保存

小禾改用 VueUse 的 useDebounceFn 来防抖:

import { useDebounceFn } from '@vueuse/core'

export const useStoryStore = defineStore('story', {
  state: () => ({ /* ... */ }),

  actions: {
    saveState() {
      // 原来的保存逻辑
    },

    // 防抖保存,500ms 内只保存一次
    debouncedSave: null as any,

    init() {
      // 初始化防抖函数
      this.debouncedSave = useDebounceFn(() => {
        this.saveState()
      }, 500)
    },

    // 常规操作用防抖保存
    updateScene(sceneId: string, updates: Partial<Scene>) {
      const scene = this.getSceneById(sceneId)
      if (scene) {
        Object.assign(scene, updates)
        this.debouncedSave()  // ← 防抖保存
      }
    },

    // 关键操作立即保存
    createStory(story: Story) {
      this.stories.push(story)
      this.saveState()  // ← 立即保存,不能等
    },

    deleteStory(id: string) {
      this.stories = this.stories.filter(s => s.id !== id)
      this.saveState()  // ← 立即保存
    }
  }
})

小禾做了个区分:

操作类型保存方式原因
编辑文本防抖保存频繁触发,500ms 后再保存
切换选中防抖保存用户可能连续点击
创建故事立即保存关键操作,不能丢
删除故事立即保存关键操作,不能丢
生成图片立即保存生成结果必须保存

这样既不卡顿,又不会丢数据。

测试了一下,丝滑流畅。


新的敌人:存储空间不足

过了一周,产品经理跑过来:“小禾,有个重度用户说保存失败了。”

小禾看了下错误日志:

DOMException: Failed to execute 'setItem' on 'Storage':
Setting the value of 'stories' exceeded the quota.

存储空间不足。

localStorage 的容量限制通常是 5-10MB(不同浏览器不同)。

那个用户创建了 200 个故事,每个故事 10 个场景,每个场景 5 个镜头……

算下来超过 10MB 了。

小禾加了个异常处理:

handleStorageError(error: any) {
  if (error.name === 'QuotaExceededError') {
    console.warn('⚠️ 存储空间不足,尝试清理...')

    // 方案1:清理旧数据(只保留最近 10 个故事)
    if (this.stories.length > 10) {
      const recentStories = this.stories
        .sort((a, b) => b.updatedAt - a.updatedAt)
        .slice(0, 10)

      this.stories = recentStories

      try {
        this.saveState()
        ElMessage.success('已自动清理旧故事,保留最近 10 个')
      } catch (e) {
        // 还是失败,用方案2
        this.compressAndSave()
      }
    } else {
      // 方案2:压缩数据
      this.compressAndSave()
    }
  }
}

compressAndSave() {
  // 移除不必要的字段(临时生成的图片 URL 等)
  const minimalStories = this.stories.map(story => ({
    id: story.id,
    title: story.title,
    updatedAt: story.updatedAt,
    scenes: story.scenes.map(scene => ({
      id: scene.id,
      title: scene.title,
      shots: scene.shots.map(shot => ({
        id: shot.id,
        prompt: shot.prompt,
        // imageUrl 可以重新生成,不保存
      }))
    }))
  }))

  try {
    localStorage.setItem('stories', JSON.stringify(minimalStories))
    ElMessage.warning('存储空间不足,已压缩数据')
  } catch (e) {
    // 彻底失败,提示用户
    ElMessage.error('本地存储已满,请导出重要数据或删除旧故事')
  }
}

完整方案:双保险

最后,小禾又加了个"双保险"机制:

用户操作
   │
   ├─→ 实时保存到 localStorage(防刷新)
   │
   └─→ 定期同步到后端(防设备丢失)

后端同步代码:

import { useIntervalFn } from '@vueuse/core'

// 每 5 分钟同步一次到后端
useIntervalFn(() => {
  const storyStore = useStoryStore()
  if (storyStore.stories.length > 0) {
    syncToBackend(storyStore.stories)
  }
}, 5 * 60 * 1000)  // 5 分钟

async function syncToBackend(stories: Story[]) {
  try {
    await api.post('/stories/sync', { stories })
    console.log('☁️ 已同步到云端')
  } catch (e) {
    console.error('同步失败:', e)
    // 不影响本地使用,静默失败
  }
}

这样就算用户换了电脑,数据也能恢复。


一个月后的数据统计

小禾看了下后台监控:

指标数值
总用户数10,000
刷新恢复成功率99.8%
平均保存延迟12ms(防抖后)
存储空间溢出0.3%(自动清理后)
用户投诉0

"夜猫子写手"也发来了消息:

不好意思上次太激动了……
现在确实不会丢数据了,点赞!

小禾笑了笑,关掉电脑。


持久化清单:该做的和不该做的

✅ 该做❌ 不该做
统一的 saveState() 方法每个 action 单独写保存逻辑
防抖保存常规操作不做防抖,每次都保存
关键操作立即保存所有操作都防抖
处理存储空间不足忽略 QuotaExceededError
beforeunload 兜底完全依赖手动保存
加载时校验数据格式直接 JSON.parsetry-catch
本地 + 云端双保险只依赖本地存储

小禾的感悟

用户的数据,
比我们的代码更珍贵。

丢一次数据,
可能就是丢一个用户。

所以,
能保存的都保存,
能恢复的都恢复。

防抖是为了性能,
兜底是为了安全,
云端同步是为了安心。

前端状态持久化,
不是"能不能做"的问题,
而是"必须做好"的问题。

小禾泡了杯茶,打开下一个需求。

“组件通信又乱了?看来得聊聊发布订阅模式了……”


下一篇预告:前端事件总线,我是怎么从乱用到精通的

跨组件通信,为什么大家都在劝退 EventBus?

敬请期待。


#Vue3 #Pinia #LocalStorage #状态管理 #前端开发 #用户体验

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

程序员义拉冠

你的鼓励是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值