
阅读大约需 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(),然后就是:
- 修改 state
JSON.stringify()整个故事列表- 写入
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.parse 不 try-catch |
| 本地 + 云端双保险 | 只依赖本地存储 |
小禾的感悟
用户的数据,
比我们的代码更珍贵。
丢一次数据,
可能就是丢一个用户。
所以,
能保存的都保存,
能恢复的都恢复。
防抖是为了性能,
兜底是为了安全,
云端同步是为了安心。
前端状态持久化,
不是"能不能做"的问题,
而是"必须做好"的问题。
小禾泡了杯茶,打开下一个需求。
“组件通信又乱了?看来得聊聊发布订阅模式了……”
下一篇预告:前端事件总线,我是怎么从乱用到精通的
跨组件通信,为什么大家都在劝退 EventBus?
敬请期待。
#Vue3 #Pinia #LocalStorage #状态管理 #前端开发 #用户体验
2122

被折叠的 条评论
为什么被折叠?



