Vue:计算属性何时可能成为错误的工具

2025-05-25

Vue:计算属性何时可能成为错误的工具

如果您是 Vue 用户,您可能知道计算属性,如果您像我一样,您可能会认为它们很棒 - 理所当然!

对我来说,计算属性是一种非常符合人体工程学且优雅的处理派生状态(即由其他状态(其依赖项)组成的状态)的方法。但在某些情况下,它们也会降低性能,我意识到很多人并没有意识到这一点,所以本文将尝试解释这一点。

为了清楚地说明我们在 Vue 中所说的“计算属性”是什么意思,这里有一个简单的例子:

const todos = reactive([
  { title: 'Wahs Dishes', done: true},
  { title: 'Throw out trash', done: false }
])

const openTodos = computed(
  () => todos.filter(todo => !todo.done)
)

const hasOpenTodos = computed(
  () => !!openTodos.value.length
)
Enter fullscreen mode Exit fullscreen mode

这里,openTodos派生自todoshasOpenTodos派生自openTodos。这很棒,因为现在我们有了可以传递和使用的响应式对象,并且只要它们所依赖的状态发生变化,它们就会自动更新。

如果我们在反应式上下文中使用这些反应式对象,例如 Vue 模板、渲染函数或watch(),它们也会对我们的计算属性和更新的变化做出反应 - 毕竟,这是我们非常重视的 Vue 核心的魔力。

注意:我使用 Composition API 是因为我现在喜欢用它。不过,本文描述的行为同样适用于普通 Options API 中的计算属性。毕竟,它们都使用相同的响应式系统。

计算属性有何特殊之处

计算属性有两点特殊之处,并且与本文的主题相关:

  1. 它们的结果被缓存,并且只有在其某个反应依赖项发生变化时才需要重新评估。
  2. 它们在访问时被懒惰地评估。

缓存

计算属性的结果会被缓存。在上面的例子中,这意味着只要todos数组不变,openTodos.value多次调用都会返回相同的值,而无需重新运行 filter 方法。这对于开销较大的任务尤其有用,因为它可以确保任务仅在必要时重新运行——即当其某个响应式依赖项发生变化时。

惰性求值

计算属性也是惰性求值的——但这究竟意味着什么呢?

这意味着计算属性的回调函数仅在读取计算值时运行(最初或在其某个依赖项发生更改而被标记为更新之后)。

因此,如果一个计算成本高昂的计算属性没有被任何东西使用,那么这个昂贵的操作根本就不会被执行——这在处理大量数据时会带来另一个性能上的好处。

惰性求值何时可以提高性能

如上一段所述,计算属性的惰性求值通常是一件好事,特别是对于昂贵的操作:它确保仅在实际需要结果时才进行求值。

这意味着,如果过滤结果不会被代码中的任何部分读取和使用,那么像过滤一个大列表这样的操作就会被跳过。这里有一个简单的例子:

<template>
  <input type="text" v-model="newTodo">
  <button type="button" v-on:click="addTodo">Save</button>
  <button @click="showList = !showList">
    Toggle ListView
  </button>
  <template v-if="showList">
    <template v-if="hasOpenTodos">
      <h2>{{ openTodos.length }} Todos:</h2> 
      <ul>
        <li v-for="todo in openTodos">
          {{ todo.title }}
        </li>
      </ul>
    </template>
    <span v-else>No todos yet. Add one!</span>
  </template>
</template>

<script setup>
const showListView = ref(false)

const todos = reactive([
  { title: 'Wahs Dishes', done: true},
  { title: 'Throw out trash', done: false }
])
const openTodos = computed(
  () => todos.filter(todo => !todo.done)
)
const hasOpenTodos = computed(
  () => !!openTodos.value.length
)

const newTodo = ref('')
function addTodo() {
  todos.push({
    title: todo.value,
    done: false
  })
}
</script>
Enter fullscreen mode Exit fullscreen mode

请参阅在SFC Playground上运行的此代码

由于showList初始值为false,模板/渲染函数不会读取openTodos,因此,无论是初始状态还是新增待办事项并todos.length发生更改后,过滤都不会发生。只有在showList设置为之后true,才会读取这些计算属性,并触发它们的求值。

当然,在这个小例子中,过滤的工作量很小,但你可以想象,对于更昂贵的操作来说,这可能是一个巨大的好处。

当惰性求值会降低性能时

这样做有一个缺点:如果计算属性返回的结果只能在你的代码在某处使用它之后才能知道,这也意味着 Vue 的 Reactivity 系统无法预先知道这个返回值。

换句话说,Vue 可以意识到计算属性的一个或多个依赖项已经发生了变化,因此应该在下次读取时重新评估,但 Vue 在那一刻无法知道计算属性返回的结果是否实际上会有所不同。

为什么这会是一个问题?

代码的其他部分可能依赖于该计算属性 - 可能是另一个计算属性,可能是watch(),可能是模板/渲染函数。

因此 Vue 别无选择,只能将这些依赖项标记为更新 - “以防万一”返回值会有所不同。

如果这些是昂贵的操作,即使您的计算属性返回与以前相同的值,您也可能触发昂贵的重新评估,因此重新评估是不必要的。

演示问题

这里有一个简单的例子:假设我们有一个项目列表,以及一个增加计数器的按钮。当计数器达到 100 时,我们希望以相反的顺序显示列表(是的,这个例子很傻。处理它)。

(您可以在这个SFC 游乐场上玩这个示例

<template>
  <button @click="increase">
    Click me
  </button>
  <br>
  <h3>
    List
  </h3>
  <ul>
    <li v-for="item in sortedList">
      {{ item }}
    </li>
  </ul>
</template>

<script setup>
import { ref, reactive, computed, onUpdated } from 'vue'

const list = reactive([1,2,3,4,5])

const count = ref(0)
function increase() {
  count.value++
}

const isOver100 = computed(() => count.value > 100)

const sortedList = computed(() => {
  // imagine this to be expensive
  return isOver100.value ? [...list].reverse() : [...list]
})

onUpdated(() => {
  // this eill log whenever the component re-renders
  console.log('component re-rendered!')
})
</script>
Enter fullscreen mode Exit fullscreen mode

问题:你点击了按钮 101 次。我们的组件多久重新渲染一次?

找到答案了吗?你确定吗?

答案:它将重新渲染101 次*。 *

我猜你们中的一些人可能期待一个不同的答案,比如:“一次,在第 101 次点击时”。但这是错误的,其原因在于计算属性的惰性求值。

感到困惑?我们将逐步讲解具体操作:

  1. 当我们点击按钮时,值count会增加。组件不会重新渲染,因为我们在模板中没有使用计数器。
  2. 但自从count改变以来,我们的计算属性isOver100被标记为“脏” - 反应依赖关系发生了变化,因此必须重新评估其返回值。
  3. 但由于惰性求值,这种情况只会在读取其他内容时发生isOver100.value- 在此之前,我们(和 Vue)不知道这个计算属性是否仍会返回false或是否会更改为true
  4. sortedList取决于isOver100——所以它也必须被标记为脏。同样,它也不会被重新评估,因为只有在读取它时才会重新评估。
  5. 由于我们的模板依赖于sortedList,并且它被标记为“脏”(可能已更改,需要重新评估),因此组件会重新渲染。
  6. 在渲染过程中,它读取sortedList.value
  7. sortedList现在重新评估并读取isOver100.value- 现在重新评估,但仍然false再次返回。
  8. 所以现在我们重新渲染了组件重新运行了“昂贵的”sorteList计算,尽管所有这些都是不必要的——最终的新虚拟 DOM / 模板看起来完全相同。

真正的罪魁祸首是isOver100——它是一种经常更新的计算类型,但通常返回的值与之前相同。最重要的是,它是一种开销低的操作,并没有真正从计算属性提供的缓存特性中获益。我们使用计算类型只是因为它符合人体工程学,感觉“不错”。

当在另一个昂贵的计算(确实从缓存中获利)或模板中使用时它将触发不必要的更新,这可能会严重降低代码的性能,具体取决于场景。

本质上是这样的结合:

  1. 昂贵的计算属性、观察器或模板依赖于
  2. 另一个经常重新评估为相同值的计算属性。

当你遇到这个问题时如何解决。

现在您可能有两个问题:

  1. 哇!这个问题严重吗?
  2. 我该如何摆脱它?

所以首先要冷静。通常情况下,这不是什么大问题

Vue 的反应系统通常非常高效,重新渲染也同样高效,尤其是现在的 Vue 3。通常情况下,这里和那里的一些不必要的更新仍然会比默认情况下在任何状态改变时重新渲染的 React 对应物表现得更好。

因此,该问题仅适用于特定场景,即在一个地方混合频繁的状态更新,从而在另一个地方触发频繁的不必要的更新,这种更新成本高昂(非常大的组件、计算繁重的计算属性等)。

如果遇到这样的情况,可以用自定义小帮手来优化:

注意:
在本文之前的版本中,我原本在这里提到了三点。其中两点最终与问题本身无关,而是我错误地强行塞入之前草稿的遗留内容。
为了清晰起见并直奔主题,我删除了它们。

自定义eagerComputed助手

Vue 的反应系统为我们提供了构建我们自己的版本的 所需的所有工具computed(),该版本是积极评估的,而不是懒惰评估的。

我们称之为eagerComputed()

import { watchEffect, shallowRef, readonly } from 'vue'
export function eagerComputed(fn) {
  const result = shallowRef()
  watchEffect(() => {
    result.value = fn()
  }, 
  {
    flush: 'sync' // needed so updates are immediate.
  })

  return readonly(result)
}
Enter fullscreen mode Exit fullscreen mode

然后我们可以像使用计算属性一样使用它,但行为上的不同在于更新将是急切的,而不是懒惰的,摆脱不必要的更新。

查看此 SFC Playground上的修复示例

什么时候使用computed()以及什么时候使用eagerComputed()

  • computed()当进行复杂的计算时使用,这实际上可以从缓存和惰性求值中获益,并且只应在真正必要时进行(重新)计算。
  • eagerComputed()当您有一个简单的操作并且返回值很少改变(通常是布尔值)时使用。

注意:请记住,此助手使用同步观察器,这意味着它将同步且单独地评估每个响应式更改 - 如果响应式依赖项更改了 3 次,则它将重新运行 3 次。因此,它应该仅用于简单且廉价的操作。

完成

就是这样。我们深入研究了计算属性的实际工作原理。我们了解了它们何时对应用性能有益,何时会降低性能。对于后一种情况,我们学习了如何通过使用急切求值的辅助函数来避免不必要的被动更新来解决性能问题。

希望以上内容对您有所帮助。如果您有任何疑问,请告诉我,或者告诉我您希望我讨论的其他主题。

文章来源:https://dev.to/linusborg/vue-when-a-compulated-property-can-be-the-wrong-tool-195j
PREV
创建 Node.js 服务器的初学者指南
NEXT
面向开发者的十大 YouTube 频道