Vue:知道什么时候使用计算属性并不能提高性能吗?

如果你是一个 Vue 用户,你肯定知道计算属性,它用起来很舒服!

个人认为,计算属性是由其他状态(其_依赖项_)组成的状态。但在某些情况下,计算属性也许达不到我们想要的效果,可能很多人都不知道这一点,所以本文将试图解释一下。

当我们在 Vue 中说“计算属性”时,为了清楚我们在谈论什么,这里有一个简单的例子:

代码语言:javascript
复制
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
)
复制代码

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

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

注意:我正在使用 composition API,因为这是我最近用的比较多的。不过,本文中描述的行为同样适用于普通 Options API 中的计算属性。毕竟,两者都使用相同的反应系统。

1. 计算属性有什么特别之处

关于计算属性,有两件事使它们变得特别,并且它们与本文的要点相关:

  1. 它们的结果会被缓存,并且只需要在其反应性依赖项之一发生变化时重新计算。
  2. 它们在访问时被惰性计算。

缓存

计算属性的结果被缓存。在我们上面的例子中,这意味着只要todos数组没有改变,openTodos.value多次调用将返回相同的值,而无需重新运行 filter 方法。这对于很耗性能的任务尤其有用。

懒惰评估

计算属性也会被_惰性_计算——但这究竟意味着什么?

这意味着计算属性的回调函数只会在计算值被读取时运行(最初或在它被标记为更新之后,因为它的依赖项之一发生了变化)。

因此,如果任何东西都没有使用具有很耗性能计算的计算属性,那么该很耗性能的操作甚至不会首先完成 - 在大量数据上进行繁重工作时的另一个性能优势。

2. 当惰性求值可以_提高_性能时

如前一段所述,计算属性的延迟评估通常是一件好事,尤其是对于很耗性能的操作:它确保仅在实际需要结果时才进行评估。

这意味着如果那个时候你的代码的任何部分都不会读取和使用过滤的结果,那么过滤大列表之类的事情将被简单地跳过。这是一个示例:

代码语言:javascript
复制
<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>
复制代码

请参阅此代码在SFC Playground[1]上运行[2]

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

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

3. 当惰性求值会_降低_性能时

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

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

为什么这会成为问题?

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

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

如果这些是很耗性能的操作,即使您的计算属性返回与以前相同的值,您也可能触发了耗性能的重新计算,因此这里是没必要重新计算的。

证明问题

这是一个简单的示例:假设我们有一个项目列表和一个用于增加计数器的按钮。一旦计数器达到 100,我们想以相反的顺序显示列表(是的,这个例子很愚蠢。干它)。

(你可以在这个SFC playground[3]上玩这个例子)</p>

代码语言:javascript
复制
<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>
复制代码

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

得到你的答案了吗?你确定?

答: 它将重新渲染101 次

我怀疑你们中的一些人可能期望得到不同的答案,例如:“一次,在第 101 次点击时”。但这是错误的,其原因是计算属性的惰性计算。

有点困惑?我们逐步分析一下正在发生的事情:

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

真正的罪魁祸首是isOver100——它是一个经常更新的计算,但通常返回与以前相同的值,而且最重要的是,它是一个廉价的操作,并没有真正从缓存计算属性中获益。我们只是使用了计算机,因为它感觉符合人体工程学,它“很好”。

当在另一个耗性能的计算(它从缓存_中_受益)或模板中使用时,它会触发不必要的更新,这会根据场景严重降低代码的性能。

本质上是这样的组合:

  1. 一个耗性能的计算属性、观察者或模板取决于
  2. 另一个经常重新计算为相同值的计算属性。

4. 当你遇到这个问题时如何解决它

现在你可能有两个问题:

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

所以首先:冷静。通常,这不是什么大问题。Vue 的反应系统通常非常高效,重新渲染也是如此,尤其是现在在 Vue 3 中。通常,这里和那里的一些不必要的更新仍然会比默认情况下重新渲染_任何状态_的 React 对应物表现得更好_随便改_。

因此,该问题仅适用于在一个地方混合了频繁状态更新的特定场景,这会在另一个耗性能的地方(非常大的组件、计算量很大的计算属性等)触发频繁的不必要更新。

如果你遇到这样的情况,幸运的是你有不同的解决方法:

  1. 使用普通函数而不是独立的计算属性
  2. 在对象上使用 Getter 而不是计算属性
  3. 使用自定义的 "eagerly computed" 属性

普通函数

如果我们的计算属性的操作是一个廉价的单线操作,我们可以使用一个函数来代替:

代码语言:javascript
复制
// computed 写法
const hasOpenTodos = computed(() => !!openTodos.value.length)
// usage
if (hasOpenTodos.value) {
// list open todos
}

// 普通函数写法
const hasOpenTodos = () => !!openTodos.value.length
// Usage
if (hasOpenTodos()) {
// list open todos
}
复制代码

两种方式都提供了描述性命名,但第二种方式可能对整体性能更好一点,因为一个简单的函数在内存和 CPU 使用率上比计算属性更轻,而且它的操作——读取数组的长度——非常便宜计算的缓存行为不会为此提供任何好处。

一个简单的函数不会有惰性求值,所以我们不会冒险触发模板/渲染函数、观察者或其他计算属性的不必要的效果运行。

现在,在大多数情况下,这可能不会产生很大的影响,但在某些情况下,它可能会产生影响。想象一下,一个组件使用了几个这种计算属性,并且_在一个大列表中被多次渲染——在这里,使用函数而不是计算属性肯定可以节省一些内存。

我想说,在几乎所有情况下,单独使用计算属性仍然可以。如果你更喜欢计算属性的风格而不是简单的函数,那么就做你喜欢的。

Getters

我还看到过这样一种使用方式:

代码语言:javascript
复制
import { reactive, computed } from 'vue'
const state = reactive({
name: 'Linusborg',
bigName: computed(() => state.name.toUpperCase())
})
复制代码

如果您想要一个对象的某些属性从其他属性派生出来,这会很方便。

但实际上,在这个例子中,计算属性是多余的。Javascript 有自己的方法来为对象属性派生状态 - 称为Getters[4]。它没有缓存或惰性计算,但在这里刚好合适。

代码语言:javascript
复制
import { reactive } from 'vue'
const state = reactive({
name: 'Linusborg',
get bigName() { return state.name.toUpperCase() )
})
复制代码

问题解决了。

自定义eagerComputed助手

普通函数和 getter 很好,但对于我们这些习惯了 Vue 做事方式的人来说,计算属性可能会感觉更好。幸运的是,Vue 的的响应式系统为我们提供了所需的所有工具来构建我们自己的版本的 computed(),一个用于计算_急切,不_惰性_的情况。

让我们称之为 eagerComputed()

代码语言:javascript
复制
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)
}
复制代码

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

查看此 SFC Playground[5]上的固定示例[6]

你什么时候用computed(),什么时候用eagerComputed()

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

注意:请记住,这仍然会增加一些开销,因为它使用了一堆响应式 API - 在_非常_敏感的场景中,一个简单的函数通常会更有效。

参考文章:dev.to/linusborg/v…[7]

感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,请不要吝啬你的赞

作者:gyx_这个杀手不太冷静
https://juejin.cn/post/7005336858049642527

代码语言:javascript
复制
推荐阅读:
使用 Performance 看看浏览器在做些什么从 ESLint 开始,说透我如何在团队项目中基于 Vue 做代码校验
史上最全 Vue 前端代码风格指南
2021, 九款值得推荐的VUE3 UI框架
推荐 130 个令你眼前一亮的网站,总有一个用得着深入浅出 33 道 Vue 99% 出镜率的面试题
VUE中文社区 编程技巧 · 行业秘闻 · 技术动向