Skip to content

vue 计算属性的思考

误区1: computed 内部优化机制

导言

对于初学者而言,计算属性是一个很模糊的东西。我们在学习过程中,也许会经常听到别人说类似这样的言论:

计算属性只有在所用到的响应式变量值改变的时候才会重新计算.

却殊不知,这句话实际上是非常错误的、不准确的。

这种不正确的言论,干扰了 vue 玩家的正常思维。之所以出现这样的错误言论,可能是因为官方文档并没有特别强调和明确相关的说法,文档的翻译虽然很好,但终究是翻译,一些地方虽然非常妥善、非常准确的译到了中文,却反而造了成一些误解。

论证

我们知道,计算属性副作用内部关联的响应式变量的值变动,会触发计算属性副作用。“计算属性用到的响应式变量改变”这件事,意味着计算属性对应的 getter 副作用被触发,这最终导致它的 scheduler被立即调用。在 v3.4 之前,该副作用 scheduler,会做一件事,就是 trigger。这导致所有用到该计算属性的 effect ,都将被立即重新计算

所谓的优化机制,指的是立即重新计算的这个动作。当所有用到该计算属性的 effect 被重新调用执行的时候,他们理所当然的会去获取计算属性的值。对,它就是 computedget value()。当它被调用的时候:

  • 它将率先检查本 computeddirty 是否为 true。如果为假值,他将直接返回本 computed 内部记录过的 value 值。
    • 这意味着,是否如果 dirty 为假值,不会产生任何额外计算开销。
  • 如果是真值,它将调用一次 getter 。这导致该副作用被重新执行,副作用和关联的响应式变量重新建立映射关系,产生一系列计算开销,最后返回新的值,并返回。
    • 返回后,computed 更新内部记录的 value 值。

通过观察整个流程我们可以知道,实际上这层优化机制很简单,就是 dirty 是否 true 来判断是否重新计算。那么谁影响 dirty 呢?

  • 前面提到,计算属性副作用内部的响应式变量的值变动的时候,计算属性对应的副作用的 scheduler 被调用。dirty 就是在此被变为 true
    • 所以,总结就是,在 v3.4 之前,dirty 变为真值的瞬间,标志着计算属性副作用用到的响应式变量发生了改变,也意味着 scheduler 将会进行 trigger 去调用所有用到计算属性的副作用进行更新。
  • v3.4 及之后,计算属性 getter 副作用用到的响应式变量更改后,触发了计算属性副作用,调用了 scheduler,该 scheduler 不会立即去 trigger 了。他会先执行一遍副作用 getter,拿到新值(所以新值还是立即重新计算出来了!),然后比对新值和旧值:
    • 如果新的值和旧的值不同,那么就和原来一样正常的触发 dirty 变为 true、 触发 trigger
    • 如果相同,那么就直接 return
    • 所以,总结就是,在 v3.4 及以后,dirty 变为真值的瞬间,标志着计算属性的值受到它用到的响应式变量影响而发生了改变,也意味着 scheduler 将会进行 trigger 去调用所有用到计算属性的副作用进行更新。

误区2: computed 作为 prop

复现

响应式变量的更新一定会触发 effect,也就是重渲染。但是渲染过程中,新旧变量的比对却有时候不尽人意,尤其是响应式变量用作 prop 的时候。

  • 我们先假设一个深层的响应式变量作为 prop

当该变量作为 prop 的时候,它被子组件接收,被 watch

vue
<Component :myRef="myRef">
// child
const props = defineProps({ myRef })
watch(props.myRef, () => {...})

prop.myRef 是一个 reactive。所以他能够被正常的 watch.

  • 我们使用一个 computed 计算属性作为 prop:

当该变量作为 prop 的时候,它被子组件接收,被 watch

vue
// father
<Component :myComputed="computedRef">
// child
const props = defineProps({ myComputed })
watch(props.myComputed, () => {...})

这里出问题了: prop.myComputed 是一个普通的变量了。.value 的解构,导致它成了一个普通变量。

所以我们可以这样写:

vue
// father
<Component :myComputed="computedRef">
// child
const props = defineProps({ myComputed })
watch(() => props.myComputed, () => {...})

vue 中,props 其实是一个 shallowRective。这是官方文档没有提到的另一个比较有用的信息。

我们在 watcheffect 里使用到了 prop,监听 myComputed 变化。这是我们的惯用写法。但是问题就出现在这里。

试想一下,如果 props.myComputed 其实是一个对象的话,会发生什么?如果我们在父组件中更改了某响应式变量的值,它触发了 computed 的副作用 scheduler

  • vue3.4 之前,这将直接导致 trigger,即相关渲染器代码被重新调用,重渲染
  • vue3.4 后,还需新旧值引用比对不同才能导致。

由于它是一个对象,所以会导致两个版本的行为不一致:在 vue3.4 之前,直接导致 trigger;而在新版本中,由于它是一个对象,新旧值引用对比相同,不会造成视图的重新渲染。

所以,首先能得到的警语总结就是:

新版本的computed,应该始终返回一个不一样的对象引用才能触发更新。

但这只是首先。

附加BUG1

我们来看看旧版本(v3.4 之前)的表现,即直接触发 trigger 之后的故事。trigger 被触发后,重渲染被调用,渲染器对比新旧 VDOM,没有发现 prop 变化了(因为新旧 VDOM 持有的 prop 是同一个引用。)。这导致子组件被复用,而不是重新渲染。

好的,到这里,新的问题又产生了:虽然触发了重渲染,但是视图没更新...那么怎么解决呢?

  • 最简单的方案,让 computed 每次返回一个新的对象,这样 renderdom 比对将对新旧 prop 比对为 false
  • 传递一个具有深层响应性的响应式对象。这样,即使 prop 比对失败(其实这个"比对失败"是相对而言的,你说它比对失败,它设计本身就是理所当然的认为,相同的引用就不需要更新...),由于内层组件拿到的仍然一个响应式对象,所以可以随便的进行 watch

附加BUG2

虽然这会给文字的论述增加更多的复杂性,但我还是想要引入 #10159

vue
// 在同一组件内,v3.4 之前
const myComputed = computed(() => { ... }})
watch(() => myComputed.value, () => { cb1 })
watch(() => myComputed.value, () => { cb2 }, {deep: true})

在上述代码中,如果 myComputed.value 是一个普通对象,那么哪个 watch 能监听到 myComputed.value 内部的属性发生了变化?

答案是,cb1 不会调用,cb2 会。但实际上,两个 watch 其实都在 myComputed.value 发生变化的时候被触发了。这是详细解释

在新版本中,如果 myComputed.value 是一个普通对象,那么两个 watch 都不再会触发,因为触发条件变成了严格的计算属性值的变化,而不是计算属性副作用的 secheduler 被重新调用,所以也就不会触发 trigger

vue 内部原理

整个视图模板(.vue 文件),被编译成 js 代码后,实际上是一个返回 VDOM 树的 render 函数。实际的某个组件的渲染操作,实际上是渲染器做的,它调用该组件的 render 函数,拿到 VDOM ,并根据拿到的 VDOM 生成 html。这个操作过程的代码片段,被 effect 副作用包围,这样在响应式变量变化的时候,就会触发渲染器内部分代码片段的重新运作:该响应式变量所在的组件的渲染函数被重新调用,获取新 VDOM,比较之前的 VDOM (如果有的话),然后根据新旧 VDOM 的差异调用对应的所有在 template 中出现的响应式变量,无论它是处在什么位置, 中也好,作为标签的属性也罢,都会因为它的变化,它所在的组件被重新渲染(rerender)。

举个例子来讲,如果一个响应式变量在组件上,那么它的变化会导致对应组件的重新渲染:

  • 首先调用 render 得到的新 VDOM,这个过程的速度取决于该组件内的元素节点的数量。所以 v-for 大量生成,它这一步生成 VDOM 会很卡。
  • 和旧 VDOM 进行对比;这一步往往是很快的,相信 diff 算法。
  • 多退少补地调用相关的 js 来修补好正确的页面。这一步是原生的 js 直接操作 jsDOM,往往会很快。

警语 & 建议

  • computed, refreactive 分别有自己的 tracktrigger 机制,这导致了一些不太直观的问题。仔细地逐字逐句地去阅读 vue 官方中文文档,不要囫囵吞枣地看,不然迟早搬起石头砸自己的脚。
  • 新版本的 computed,应该始终返回一个不一样的对象引用,除非你知道该对象不会再触发其他响应式链。
  • prop 是一个 shallowReactive。如果需要,你可以传递 prop 的时候传入一个深层的响应式对象来实现深层次的响应式。
  • refreactive ,建议尽可能地使用 ref 。因为 .value 作为响应式对象的标识更加醒目,你很清晰的知道自己在用一个 vue 提供的响应式变量。而 reactive 不那么直观。

参考

官方文档:计算属性稳定性

issue#10159

Vue Conf 2021视频录像: 对 vue 用户的一些建议