computed 可能成为一个错误的工具(译文)

前言

起因是在看 vueuse/core 里面的 eagerComputed 函数时,在文档下面有一篇文章

看完发现相当的有意思,所以挑一些段落来翻译,随便写写

文章地址:Vue: When a computed property can be the wrong tool

正文

如果你是一个 Vue 玩家,你大概知道计算属性,并且和我一样,认为它太棒了,就跟它的名字一样理所当然

对于我来说,计算属性是处理派生状态(派生状态可以理解为由其他的状态生成的状态,这里的其他状态可以理解为这个派生状态的依赖 dependencies)的一种非常符合人体工程学以及优雅的方式。但是在一些场景下,有可能降低性能,我意识到许多人可能不知道,所以尝试写下这篇文章来解释为什么计算属性可能会降低性能

当我们在 Vue 中讨论计算属性 computed properties 时,为了搞清楚我们在讨论什么,这里使用下面的例子来说明:

1
2
3
4
5
6
7
8
9
10
11
12
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 派生自 todoshasOpenTodos 又派生自 openTodos 。看起来非常的 nice ,因为现在我们传递和使用这些响应式对象,每当它们依赖的(响应式)对象发生改变的时候它们就会自动地更新。

如果我们使用这些响应式对象在一个响应式的上下文中,比如一个 Vue 的模板,一个渲染 render 函数或者是一个 watch 函数,这些操作也会对计算属性的改变而反应,然后更新,这就是我们熟悉的 Vue 核心的一种魔力。

注意:我使用了组合式(composition)的 API 是因为刚好这几天我在使用它。文章中描述对于计算属性的行为同样适用于配置式(options)的 API ,毕竟使用的是相同的响应式系统。

计算属性特殊的地方

有两点使得计算属性变得特殊,并且这两点和本文的要点相关:

  • 计算属性的结果是缓存的,只有它依赖的响应式对象发生了改变,它才就会重新地计算
  • 获取结果的计算过程是惰性的

缓存

计算属性的结果是缓存的。在上面的例子中,只要不改变 todos 数组, 多次调用 openTodos.value 都会返回相同的值,这个值是不用重新调用 filter 方法来计算的。对于昂贵的任务来说这特别的棒,因为这确保了任务只在必须的时候才被重新执行,这里的“必须”可以理解为它依赖的响应式对象发生了改变。

惰性计算

计算属性也是惰性计算的,但真的是这样吗?

惰性计算可以理解为一旦计算属性的值被读取(初始化或者由于它的依赖发生改变而被标记为更新),计算属性的回调函数会执行。

所以如果一个带有昂贵计算的计算属性在任何地方都没被使用,这个昂贵的操作不会被执行,这是处理大量数据时的另一个性能优势。

惰性计算何时会提高性能

根据文章先前部分的解释,计算属性的惰性计算通常来说是一个优点,特别是对于昂贵的(计算代价大)操作:它确保了真的需要结果的时候才会去计算。

这意味着某些操作,比如过滤一个大的列表,如果过滤的结果不被你写的代码的任何一部分读取和使用的话,会简单地跳过这个计算过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<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

showList 初始化为 false 的时候,模板或者渲染函数不会读取到 openTodos ,相应地,过滤的操作不会发生,无论是初始或者添加了一个新的 todo ,并且 todos.length 发生了改变。只有在 showList 设置为 true 之后,这些计算属性才会被读取,然后才会触发它们的计算。

当然在这个简单的例子中,过滤操作的工作量很小,但是你可以想象一下更加昂贵的操作,这是一个巨大的优势。

惰性计算何时会降低性能

不利的一面:如果计算属性返回的结果只能在使用它之后才能知道,这意味着 Vue 的响应式系统无法事先知道这个返回值。

换句话说,Vue 可以意识到一个或者多个计算属性的依赖发生改变,所以可以在下次值被读取的时候重新计算,但是 Vue 无法知道,在那个时刻,计算属性返回的值是否是不同的。

为什么这会成为一个问题?

你的代码的其他部分可能依赖了这个计算属性,或者可能是另一个计算属性,可能是一个 watch ,可能是一个模板或者渲染函数。

所以 Vue 没办法,只能把这些依赖也标记为更新的,防止返回值不同。

如果这些依赖这个计算属性的过程存在一些昂贵的操作,可能就会触发昂贵地重计算,尽管被依赖的计算属性的返回了和上一次相同值,即重计算是没有必要的。

复现问题

下面是一个简单的例子:想象一下,我们有一个列表,有一个按钮用来增加次数。一旦次数达到 100 ,我们逆序去展示这个列表(是的,这个例子有点蠢。)

SFC playground

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<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(() => {
// 想象这是一个昂贵的操作
return isOver100.value ? [...list].reverse() : [...list]
})

onUpdated(() => {
// 在更新组件时打印
console.log('component re-rendered!')
})
</script>

提问:点击了 101 次按钮,组件会重渲染多少次?

脑袋里已经浮现出答案了吗?确定吗?

答案: 组件会重新渲染 101

我猜有些人可能会猜想一个不同的答案,比如:“组件渲染一次,即使点击了 101 次按钮”。但这是错误的,原因是计算属性的惰性计算。

很疑惑?我们一步步地梳理下,看看到底发生了什么:

1.当我们点击了按钮, count 增加了。组件不会重渲染,因为我们没有在模板中使用 count
2.但是当 count 改变,计算属性 isOver100 被标记为 dirty 脏的,这里的 dirty 意思是该计算属性的一个响应式的依赖发生了改变,所以它的返回值必须重新计算。
3.由于惰性计算,重新计算只有在读取 isOver100.value 的时候才会发生,在发生前,我们(以及 Vue )无法知道计算属性会返回 false 还是改变成 true
4.然而 sortedList 依赖了 isOver100 ,所以 sortedList 也被标记为 dirty 脏的。同样,它不会被重新计算,只有被读取时候,才会触发计算过程。
5.因为模板依赖了 sortedList ,同样也被标记为 dirty (有可能发生变化,需要重新计算),然后组件就重新渲染了。
6.渲染的过程中,读取到了 sortedList.value
7.sortedList 现在重新计算了,由于读取到了 isOver100.value ,所以 isOver100 也重新计算了,但是返回的值仍然是 false
8.所以现在我们重新渲染了组件,重新计算了“昂贵”的 sortedList 计算属性,尽管所有的操作都是没有必要的,返回的新的虚拟 DOM 或者模板看起来都和先前的一样。

真正的罪魁祸首是 isOver100 , 这是一个经常更新的值, 但通常返回和先前相同的值,最重要的是,这是一个便宜的操作,并不能从计算属性的缓存特性中获益。我们只是觉得这样使用符合人体工程学,看起来很 “nice” 。

当这样的计算属性(指类似 isOver100 的计算属性)被另一个带有昂贵计算的计算属性(能够从缓存特性中获益)或者模板依赖时,可能会触发不必要地更新,进而严重降低代码的性能。

这种情况本质上可以理解为下面行为的组合:

1.一个昂贵的计算属性,被观察者或者模板依赖
2.另一个经常重新计算返回相同值的计算属性

当遇到了时候,如何解决?

看到现在,你可能有两个问题:

1.哇,真的很糟吗?
2.如何避免?

第一个问题:冷静下来,这不是一个很糟的问题。

Vue 的响应式系统通常非常的高效,重渲染也一样,特别是现在的 Vue3 版本。通常,零星的不必要的更新也不会使得性能变得很差,即使默认情况下任何状态的更新都会导致重渲染。

所以这个问题只适用于频繁更新状态,进而在另一个地方触发频繁的,不必要的,昂贵的(非常大的组件,计算量很大的计算属性等)更新。

如果你遇到这样一种场景,可以使用一个自定义的工具函数来解决它。

自定义的 eagerComputed 工具函数

Vue 的响应式系统导出了我们所需的工具函数,使得我们可以构建一个个人版本的 computed ,它的计算是立即的,非惰性的。

我们称这个自定义 computedeagerComputed

1
2
3
4
5
6
7
8
9
10
11
12
import { watchEffect, shallowRef, readonly } from 'vue'
export function eagerComputed(fn) {
const result = shallowRef()
watchEffect(() => {
result.value = fn()
},
{
flush: 'sync' // 立即执行获取更新后的值
})

return readonly(result)
}

我们可以像使用计算属性一样使用这个 eagerComputed ,但是行为上不同的地方是 eagerComputed 的更新是立即的,非惰性的,避免了不必要的更新。

点击查看修复之后的例子 SFC Playground

什么时候该使用 computed ,什么时候又该使用 eagerComputed 呢?

  • 当你需要复杂的计算需要执行,可以真正地从缓存和惰性计算中获益,并且只在必要的时候重新计算时,使用 computed
  • 当你有一个简单的操作,这个操作很少改变返回值,并且经常是一个 boolean 值,使用 eagerComputed

注意:记住这个 eagerComputed 使用一个同步的观察者,意味着对于每一个响应式的改变,计算是同步且立即的,如果一个响应式依赖改变了 3 次,那么计算过程会重新计算 3 次。所以它应该被用于简单且便宜的操作。

后记

相当有意思的一篇文章,总结一下就是:

由于 computed 是惰性计算取值的,响应式系统无法第一时间知道返回的值是否改变,那么只能理解为改变了,这样才能保持结果和预期一致,如果理解为没改变,从而依然使用缓存的值,但是实际的结果发生了改变的话,就会和预期的结果不一致,造成程序错误。

很多时候很容易写出如下的代码:

1
const isXXXEmpty = computed(() => XXXList.length === 0)

然后使用一个 watchEffect 来判断

1
2
3
4
5
watchEffect(() => {
if (isXXXEmpty.value) {
// 昂贵的操作
}
})

每次我们往非空的 XXXList 里面添加一条数据,体感上 watchEffect 是不应该运行的,但是实际上每次对 XXXList 添加元素都会执行这个 watchEffect

不过一般我们都不会去在意这种小的消耗,因为 watchEffect 内部只在 isXXXEmpty.valuefalse 时才执行一段昂贵的逻辑。

而使用 watchSyncEffect 来模拟 computed ,使得取值是非惰性的,这样响应式系统能够立即知道本次的值和上次的值是否不同,进而触发依赖它的其他响应式对象是否进行更新操作。

1
2
3
4
5
6
7
8
9
const isXXXEmpty = eagerComputed(() => XXXList.length === 0)

watchEffect(() => {
// 每次对 XXXList 添加元素已经不会再触发该 effect 了
// 只有真的发生改变才会触发,比如从 true 变为 false ,或者从 false 变为 true 。
if (isXXXEmpty.value) {
// 昂贵的操作
}
})

颇有一种薛定谔的猫的感觉,你无法知道 computed 返回的值是否发生了改变,我们称之为“薛定谔的 computed 返回值” (不是

如果还是不明白的话,可以看看 computed 以及 ref 的实现方式,配合文章食用更佳~

PS:惨,本来可以早点发的

这两天睡眠有点不足,感觉疲惫,加上原来帖子的地址突然不能访问了…

小摆了几天😂