深度嵌套数据,Vue 页面渲染卡成 PPT,原来是这里做错了——Map前来救场

JavaScript性能优化实战 10w+人浏览 454人参与

在这里插入图片描述

阅读大约需 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

这路径也太深了吧?

而且,如果 sceneIndexshotIndex 是 -1 呢?代码直接崩。

如果要更新分镜的图片呢?又要写一遍这个查找逻辑?

不对劲。

非常不对劲。


数据结构的恐怖四层

小禾画了个图,看看整个应用的数据结构:

故事 (Story)
├── 基本信息 (title, description)
├── 角色列表 (characters[])
│   ├── 角色1 (name, description, imageUrl)
│   └── 角色2
└── 场景列表 (scenes[])
    ├── 场景1
    │   ├── 基本信息 (title, description)
    │   └── 分镜列表 (shots[])
    │       ├── 分镜1 (prompt, imageUrl, animation)
    │       └── 分镜2
    └── 场景2

四层嵌套。

要访问一个分镜的数据,需要:

  1. 找到故事
  2. 找到场景
  3. 找到分镜
  4. 修改属性

每一层都可能失败。

每一层都要写 .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存储原始数据 + 当前选中 IDcurrentStory, 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 #前端架构

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

程序员义拉冠

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

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

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

打赏作者

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

抵扣说明:

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

余额充值