
阅读大约需 12 分钟
故事从一行代码开始
上次解决了"刷新页面数据丢失"的问题后,小禾终于可以写点业务逻辑了。
产品经理递来需求:“点击分镜,要能修改它的提示词。”
小禾想:这还不简单?打开代码就是一顿操作:
// ShotEditor.vue
function updatePrompt(newPrompt: string) {
const story = storyStore.currentStory
const sceneIndex = story.scenes.findIndex(s => s.id === currentSceneId)
const shotIndex = story.scenes[sceneIndex].shots.findIndex(s => s.id === currentShotId)
story.scenes[sceneIndex].shots[shotIndex].prompt = newPrompt
}
写完之后,小禾盯着这行代码,陷入了沉思。
story.scenes[sceneIndex].shots[shotIndex].prompt
这路径也太深了吧?
而且,如果 sceneIndex 或 shotIndex 是 -1 呢?代码直接崩。
如果要更新分镜的图片呢?又要写一遍这个查找逻辑?
不对劲。
非常不对劲。
数据结构的恐怖四层
小禾画了个图,看看整个应用的数据结构:
故事 (Story)
├── 基本信息 (title, description)
├── 角色列表 (characters[])
│ ├── 角色1 (name, description, imageUrl)
│ └── 角色2
└── 场景列表 (scenes[])
├── 场景1
│ ├── 基本信息 (title, description)
│ └── 分镜列表 (shots[])
│ ├── 分镜1 (prompt, imageUrl, animation)
│ └── 分镜2
└── 场景2
四层嵌套。
要访问一个分镜的数据,需要:
- 找到故事
- 找到场景
- 找到分镜
- 修改属性
每一层都可能失败。
每一层都要写 .find() 或 [index]。
小禾突然理解了那些"Vue 2 时代的噩梦":
// Vue 2 的黑暗记忆
this.$set(this.story.scenes[2].shots[1], 'imageUrl', newUrl)
虽然 Vue 3 不需要 $set 了,但嵌套太深的问题依然存在。
第一版:暴力索引访问
小禾决定先把功能做出来,代码写成了这样:
// stores/story.ts
export const useStoryStore = defineStore('story', {
state: () => ({
currentStory: null as Story | null,
currentSceneIndex: 0,
currentShotIndex: 0,
}),
actions: {
updateShotPrompt(prompt: string) {
if (!this.currentStory) return
const scene = this.currentStory.scenes[this.currentSceneIndex]
if (!scene) return
const shot = scene.shots[this.currentShotIndex]
if (!shot) return
shot.prompt = prompt
},
updateShotImage(imageUrl: string) {
if (!this.currentStory) return
const scene = this.currentStory.scenes[this.currentSceneIndex]
if (!scene) return
const shot = scene.shots[this.currentShotIndex]
if (!shot) return
shot.imageUrl = imageUrl
}
}
})
测试了一下,能用。
然后产品经理说:“用户删除场景后,选中的分镜怎么乱了?”
小禾一拍脑门:用索引的话,删除一个场景,后面所有场景的索引都变了!
场景列表:[场景A, 场景B, 场景C]
当前索引:2(场景C)
删除场景B后:
场景列表:[场景A, 场景C]
当前索引:2(越界!)
不行,必须改。
第二版:用 ID 不用索引
小禾想起了一个原则:在前端状态管理中,用 ID 标识对象,而不是索引。
理由很简单:
- 索引会随数组变化而变化
- ID 是稳定的唯一标识
- ID 更具可读性(看日志时能看懂)
重构:
export const useStoryStore = defineStore('story', {
state: () => ({
currentStory: null as Story | null,
// 改用 ID
currentSceneId: '',
currentShotId: '',
}),
actions: {
updateShotPrompt(prompt: string) {
if (!this.currentStory) return
// 先找场景
const scene = this.currentStory.scenes.find(s => s.id === this.currentSceneId)
if (!scene) return
// 再找分镜
const shot = scene.shots.find(s => s.id === this.currentShotId)
if (!shot) return
// 修改
shot.prompt = prompt
}
}
})
好多了。
现在删除场景不会影响 ID,数据访问更稳定。
但新的问题来了:每个 action 都要重复写 .find() 逻辑。
小禾数了数,有 20 多个类似的 action。
如果每个都写一遍查找逻辑……
这代码又要臭了。
第三版:Getter 提供便捷访问
实习生小张路过:“禾哥,为什么不用 getter?”
小禾一愣:“getter?”
“对啊,把查找逻辑封装到 getter 里,组件直接用。”
小禾恍然大悟。
重构:
export const useStoryStore = defineStore('story', {
state: () => ({
currentStory: null as Story | null,
currentSceneId: '',
currentShotId: '',
}),
getters: {
// 获取当前场景
currentScene(): Scene | null {
if (!this.currentStory || !this.currentSceneId) return null
return this.currentStory.scenes.find(s => s.id === this.currentSceneId) || null
},
// 获取当前分镜
currentShot(): Shot | null {
const scene = this.currentScene // 复用上一个 getter!
if (!scene || !this.currentShotId) return null
return scene.shots.find(s => s.id === this.currentShotId) || null
},
// 通用查找方法(参数化 getter)
getSceneById(): (id: string) => Scene | undefined {
return (id: string) => {
return this.currentStory?.scenes.find(s => s.id === id)
}
},
getShotById(): (sceneId: string, shotId: string) => Shot | undefined {
return (sceneId: string, shotId: string) => {
const scene = this.getSceneById(sceneId)
return scene?.shots.find(s => s.id === shotId)
}
},
},
actions: {
// 现在 action 简洁多了
updateShotPrompt(sceneId: string, shotId: string, prompt: string) {
const shot = this.getShotById(sceneId, shotId)
if (shot) {
shot.prompt = prompt
this.saveState()
} else {
console.warn(`Shot not found: ${sceneId}/${shotId}`)
}
},
updateShotImage(sceneId: string, shotId: string, imageUrl: string) {
const shot = this.getShotById(sceneId, shotId)
if (shot) {
shot.imageUrl = imageUrl
shot.status = 'generated'
this.saveState()
}
},
}
})
舒服了。
现在代码有了清晰的层次:
- State:存原始数据
- Getters:提供便捷访问
- Actions:封装修改逻辑 + 自动保存
小禾在组件里试了试:
<script setup lang="ts">
import { useStoryStore } from '@/stores/story'
import { storeToRefs } from 'pinia'
const store = useStoryStore()
// ✅ 响应式解构
const { currentScene, currentShot } = storeToRefs(store)
// 更新分镜提示词
function onPromptChange(newPrompt: string) {
if (currentScene.value && currentShot.value) {
store.updateShotPrompt(
currentScene.value.id,
currentShot.value.id,
newPrompt
)
}
}
</script>
<template>
<div v-if="currentShot">
<h3>{{ currentShot.prompt }}</h3>
<img v-if="currentShot.imageUrl" :src="currentShot.imageUrl" />
</div>
</template>
代码清爽,逻辑清晰。
小禾准备提交。
然后测试发现了性能问题。
性能灾难:100 个分镜卡成 PPT
产品经理说有个用户反馈:“故事场景多了之后,切换分镜很卡。”
小禾打开控制台,Performance 一录:
一次切换分镜,触发了 200+ 次 getter 计算。
他仔细看了看代码,发现问题出在一个"聪明"的 getter 上:
// ❌ 性能杀手
getters: {
// 获取所有分镜(扁平化)
allShots(): Shot[] {
if (!this.currentStory) return []
// 每次访问都遍历所有场景和分镜!
return this.currentStory.scenes.flatMap(scene => scene.shots)
}
}
这个 getter 在组件里被用了好多次:
<!-- 每个 v-for 都触发一次计算 -->
<div v-for="shot in allShots" :key="shot.id">
<!-- 组件内部又访问了 allShots -->
<ShotCard :total="allShots.length" />
</div>
100 个分镜,每次切换都要遍历 100 次。
Vue 的响应式虽然有缓存,但这个 getter 依赖的 currentStory 经常变化,缓存经常失效。
小禾加了个性能优化:
getters: {
// ✅ 优化:先找到场景,再从场景中找分镜(范围更小)
currentShot(): Shot | null {
const scene = this.currentScene // 先拿到场景
if (!scene || !this.currentShotId) return null
// 只在当前场景的 shots 里找(数量远小于全部分镜)
return scene.shots.find(s => s.id === this.currentShotId) || null
},
// 如果真的需要扁平化,至少做个缓存
allShots(): Array<{ scene: Scene; shot: Shot }> {
if (!this.currentStory) return []
// 携带场景信息,方便后续使用
return this.currentStory.scenes.flatMap(scene =>
scene.shots.map(shot => ({ scene, shot }))
)
}
}
效果立竿见影,切换分镜从卡顿 500ms 降到了 20ms。
终极优化:用 Map 加速查找
又过了一周,产品经理说:“用户创建了 50 个场景,每个场景 10 个分镜,现在查找分镜有点慢。”
小禾算了算:50 × 10 = 500 个分镜。
每次查找都是 O(n) 遍历,确实会慢。
他想起了一个数据结构:Map。
export const useStoryStore = defineStore('story', {
state: () => ({
currentStory: null as Story | null,
currentSceneId: '',
currentShotId: '',
// 新增:ID 索引(加速查找)
_sceneMap: new Map<string, Scene>(),
_shotMap: new Map<string, Shot>(),
}),
actions: {
// 构建索引(在加载故事或修改数据后调用)
_buildIndex() {
this._sceneMap.clear()
this._shotMap.clear()
if (!this.currentStory) return
this.currentStory.scenes.forEach(scene => {
this._sceneMap.set(scene.id, scene)
scene.shots.forEach(shot => {
this._shotMap.set(shot.id, shot)
})
})
},
// 加载故事时构建索引
loadStory(story: Story) {
this.currentStory = story
this._buildIndex()
},
// 修改数据后重建索引
addShot(sceneId: string, shot: Shot) {
const scene = this._sceneMap.get(sceneId)
if (scene) {
scene.shots.push(shot)
this._shotMap.set(shot.id, shot) // 更新索引
this.saveState()
}
},
},
getters: {
// O(1) 查找!
getSceneById(): (id: string) => Scene | undefined {
return (id: string) => this._sceneMap.get(id)
},
getShotById(): (shotId: string) => Shot | undefined {
return (shotId: string) => this._shotMap.get(shotId)
},
}
})
查找性能从 O(n) 变成了 O(1)。
500 个分镜,查找时间从几毫秒降到了 0.1 毫秒。
完美。
避坑指南:这些错误要绕行
小禾把踩过的坑总结了一下:
坑 1:直接解构 store 失去响应式
// ❌ 错误:失去响应式
const { currentShot } = store
// ✅ 正确:用 storeToRefs
const { currentShot } = storeToRefs(store)
坑 2:直接修改 getter 返回的对象
// ❌ 可能有问题(取决于 getter 实现)
currentShot.value.imageUrl = newUrl
// ✅ 推荐:通过 action 修改
store.updateShot(sceneId, shotId, { imageUrl: newUrl })
坑 3:在 getter 里做复杂计算
// ❌ 性能差
getters: {
expensiveComputation() {
// 复杂的遍历、排序、过滤...
}
}
// ✅ 优化:缓存或移到 action 里
actions: {
computeAndCache() {
this._cachedResult = /* 计算结果 */
}
}
坑 4:忘记处理找不到对象的情况
// ❌ 可能崩溃
const shot = store.getShotById(shotId)
shot.imageUrl = newUrl // 如果 shot 是 undefined?
// ✅ 先检查
const shot = store.getShotById(shotId)
if (shot) {
shot.imageUrl = newUrl
}
完整的最佳实践
经过几次迭代,小禾总结出了一套完整方案:
| 层次 | 职责 | 示例 |
|---|---|---|
| State | 存储原始数据 + 当前选中 ID | currentStory, currentSceneId |
| Getters | 提供便捷访问 + 计算属性 | currentScene, getShotById() |
| Actions | 封装修改逻辑 + 自动保存 | updateShot(), addScene() |
| 索引(可选) | 加速查找(大数据量时) | _sceneMap, _shotMap |
代码模板:
export const useStoryStore = defineStore('story', {
state: () => ({
// 数据
stories: [] as Story[],
currentStory: null as Story | null,
// 当前选中(用 ID)
currentSceneId: '',
currentShotId: '',
// 索引(可选,大数据量时用)
_sceneMap: new Map<string, Scene>(),
_shotMap: new Map<string, Shot>(),
}),
getters: {
// 当前对象
currentScene(): Scene | null {
if (!this.currentStory || !this.currentSceneId) return null
return this._sceneMap.get(this.currentSceneId) || null
},
currentShot(): Shot | null {
if (!this.currentShotId) return null
return this._shotMap.get(this.currentShotId) || null
},
// 通用查找
getSceneById(): (id: string) => Scene | undefined {
return (id: string) => this._sceneMap.get(id)
},
getShotById(): (shotId: string) => Shot | undefined {
return (shotId: string) => this._shotMap.get(shotId)
},
},
actions: {
// 修改操作
updateShot(sceneId: string, shotId: string, updates: Partial<Shot>) {
const shot = this.getShotById(shotId)
if (shot) {
Object.assign(shot, updates)
this.saveState()
}
},
// 添加操作
addShot(sceneId: string, shot: Shot) {
const scene = this.getSceneById(sceneId)
if (scene) {
scene.shots.push(shot)
this._shotMap.set(shot.id, shot) // 更新索引
this.saveState()
}
},
// 删除操作
removeShot(sceneId: string, shotId: string) {
const scene = this.getSceneById(sceneId)
if (scene) {
const index = scene.shots.findIndex(s => s.id === shotId)
if (index > -1) {
scene.shots.splice(index, 1)
this._shotMap.delete(shotId) // 更新索引
// 如果删除的是当前选中,切换到下一个
if (this.currentShotId === shotId) {
this.currentShotId = scene.shots[0]?.id || ''
}
this.saveState()
}
}
},
// 重建索引
_buildIndex() {
this._sceneMap.clear()
this._shotMap.clear()
this.currentStory?.scenes.forEach(scene => {
this._sceneMap.set(scene.id, scene)
scene.shots.forEach(shot => {
this._shotMap.set(shot.id, shot)
})
})
},
}
})
调试神器:Vue DevTools
小禾发现一个宝藏工具:Vue DevTools。
Pinia 自动集成了 DevTools,可以实时看到:
- 当前 state 的值
- 所有 getters 的计算结果
- action 调用历史
- state 变化时间线
调试复杂嵌套数据时,DevTools 比 console.log 好用 100 倍。
小禾的感悟
数据嵌套不可怕,
可怕的是没有章法。
用 ID 不用索引,
getter 提供访问,
action 封装修改,
索引加速查找。
四层嵌套变扁平,
代码清爽人舒坦。
记住一句话:
状态管理的本质,
是把复杂的数据结构,
变成简单的访问方式。
小禾关掉电脑,又解决了一个难题。
明天,还有新的挑战在等着他。
下一篇预告:“生成中…请稍候”——这个 loading 比你想的复杂
一个简单的加载状态,为什么会有这么多边界情况?
敬请期待。
#Vue3 #Pinia #状态管理 #数据结构 #TypeScript #前端架构
5307

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



