computed 可能成为一个错误的工具(译文)
前言
起因是在看 vueuse/core
里面的 eagerComputed
函数时,在文档下面有一篇文章
看完发现相当的有意思,所以挑一些段落来翻译,随便写写
正文
如果你是一个 Vue
玩家,你大概知道计算属性,并且和我一样,认为它太棒了,就跟它的名字一样理所当然
对于我来说,计算属性是处理派生状态(派生状态可以理解为由其他的状态生成的状态,这里的其他状态可以理解为这个派生状态的依赖 dependencies
)的一种非常符合人体工程学以及优雅的方式。但是在一些场景下,有可能降低性能,我意识到许多人可能不知道,所以尝试写下这篇文章来解释为什么计算属性可能会降低性能
当我们在 Vue
中讨论计算属性 computed properties
时,为了搞清楚我们在讨论什么,这里使用下面的例子来说明:
1 | const todos = reactive([ |
上面的代码中,openTodos
派生自 todos
, hasOpenTodos
又派生自 openTodos
。看起来非常的 nice
,因为现在我们传递和使用这些响应式对象,每当它们依赖的(响应式)对象发生改变的时候它们就会自动地更新。
如果我们使用这些响应式对象在一个响应式的上下文中,比如一个 Vue
的模板,一个渲染 render
函数或者是一个 watch
函数,这些操作也会对计算属性的改变而反应,然后更新,这就是我们熟悉的 Vue
核心的一种魔力。
注意:我使用了组合式(composition
)的 API 是因为刚好这几天我在使用它。文章中描述对于计算属性的行为同样适用于配置式(options
)的 API ,毕竟使用的是相同的响应式系统。
计算属性特殊的地方
有两点使得计算属性变得特殊,并且这两点和本文的要点相关:
- 计算属性的结果是缓存的,只有它依赖的响应式对象发生了改变,它才就会重新地计算
- 获取结果的计算过程是惰性的
缓存
计算属性的结果是缓存的。在上面的例子中,只要不改变 todos
数组, 多次调用 openTodos.value
都会返回相同的值,这个值是不用重新调用 filter
方法来计算的。对于昂贵的任务来说这特别的棒,因为这确保了任务只在必须的时候才被重新执行,这里的“必须”可以理解为它依赖的响应式对象发生了改变。
惰性计算
计算属性也是惰性计算的,但真的是这样吗?
惰性计算可以理解为一旦计算属性的值被读取(初始化或者由于它的依赖发生改变而被标记为更新),计算属性的回调函数会执行。
所以如果一个带有昂贵计算的计算属性在任何地方都没被使用,这个昂贵的操作不会被执行,这是处理大量数据时的另一个性能优势。
惰性计算何时会提高性能
根据文章先前部分的解释,计算属性的惰性计算通常来说是一个优点,特别是对于昂贵的(计算代价大)操作:它确保了真的需要结果的时候才会去计算。
这意味着某些操作,比如过滤一个大的列表,如果过滤的结果不被你写的代码的任何一部分读取和使用的话,会简单地跳过这个计算过程。
1 | <template> |
可以戳这里查看在线代码 SFC Playground
当 showList
初始化为 false
的时候,模板或者渲染函数不会读取到 openTodos
,相应地,过滤的操作不会发生,无论是初始或者添加了一个新的 todo
,并且 todos.length
发生了改变。只有在 showList
设置为 true
之后,这些计算属性才会被读取,然后才会触发它们的计算。
当然在这个简单的例子中,过滤操作的工作量很小,但是你可以想象一下更加昂贵的操作,这是一个巨大的优势。
惰性计算何时会降低性能
不利的一面:如果计算属性返回的结果只能在使用它之后才能知道,这意味着 Vue
的响应式系统无法事先知道这个返回值。
换句话说,Vue
可以意识到一个或者多个计算属性的依赖发生改变,所以可以在下次值被读取的时候重新计算,但是 Vue
无法知道,在那个时刻,计算属性返回的值是否是不同的。
为什么这会成为一个问题?
你的代码的其他部分可能依赖了这个计算属性,或者可能是另一个计算属性,可能是一个 watch
,可能是一个模板或者渲染函数。
所以 Vue
没办法,只能把这些依赖也标记为更新的,防止返回值不同。
如果这些依赖这个计算属性的过程存在一些昂贵的操作,可能就会触发昂贵地重计算,尽管被依赖的计算属性的返回了和上一次相同值,即重计算是没有必要的。
复现问题
下面是一个简单的例子:想象一下,我们有一个列表,有一个按钮用来增加次数。一旦次数达到 100
,我们逆序去展示这个列表(是的,这个例子有点蠢。)
1 | <template> |
提问:点击了 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
,它的计算是立即的,非惰性的。
我们称这个自定义 computed
为 eagerComputed
1 | import { watchEffect, shallowRef, readonly } from 'vue' |
我们可以像使用计算属性一样使用这个 eagerComputed
,但是行为上不同的地方是 eagerComputed
的更新是立即的,非惰性的,避免了不必要的更新。
点击查看修复之后的例子 SFC Playground
什么时候该使用 computed
,什么时候又该使用 eagerComputed
呢?
- 当你需要复杂的计算需要执行,可以真正地从缓存和惰性计算中获益,并且只在必要的时候重新计算时,使用
computed
。 - 当你有一个简单的操作,这个操作很少改变返回值,并且经常是一个
boolean
值,使用eagerComputed
。
注意:记住这个 eagerComputed
使用一个同步的观察者,意味着对于每一个响应式的改变,计算是同步且立即的,如果一个响应式依赖改变了 3
次,那么计算过程会重新计算 3
次。所以它应该被用于简单且便宜的操作。
后记
相当有意思的一篇文章,总结一下就是:
由于 computed
是惰性计算取值的,响应式系统无法第一时间知道返回的值是否改变,那么只能理解为改变了,这样才能保持结果和预期一致,如果理解为没改变,从而依然使用缓存的值,但是实际的结果发生了改变的话,就会和预期的结果不一致,造成程序错误。
很多时候很容易写出如下的代码:
1 | const isXXXEmpty = computed(() => XXXList.length === 0) |
然后使用一个 watchEffect
来判断
1 | watchEffect(() => { |
每次我们往非空的 XXXList
里面添加一条数据,体感上 watchEffect
是不应该运行的,但是实际上每次对 XXXList
添加元素都会执行这个 watchEffect
。
不过一般我们都不会去在意这种小的消耗,因为 watchEffect
内部只在 isXXXEmpty.value
为 false
时才执行一段昂贵的逻辑。
而使用 watchSyncEffect
来模拟 computed
,使得取值是非惰性的,这样响应式系统能够立即知道本次的值和上次的值是否不同,进而触发依赖它的其他响应式对象是否进行更新操作。
1 | const isXXXEmpty = eagerComputed(() => XXXList.length === 0) |
颇有一种薛定谔的猫的感觉,你无法知道 computed
返回的值是否发生了改变,我们称之为“薛定谔的 computed
返回值” (不是
如果还是不明白的话,可以看看 computed
以及 ref
的实现方式,配合文章食用更佳~
PS:惨,本来可以早点发的
这两天睡眠有点不足,感觉疲惫,加上原来帖子的地址突然不能访问了…
小摆了几天😂