相见恨晚 — 手把手解析计算属性的实现原理

648 阅读6分钟

简单实现Vue.js中的计算属性

引言

计算属性作为Vue.js的响应式核心之一,在项目开发中被频繁使用。可是,在这个竞争压力越来越大的年代,仅止步于熟练使用是万万不行的,你只有掌握它的实现原理才能真正立于不败之地。

在参看本文之前,建议读者对Vue.js数据响应式原理有一定的了解,推荐转阅笔者前置读书笔记:

计算属性的类型及简单使用

在IT领域,我觉得学习一个新事物最好的方式就是去看它的官网文档。在Vue.js 3官方文档中对计算属性computed()进行了简单的类型及使用介绍,如下:

  • Type

carbon (72).png

  • Example

carbon (76).png

至此,即使你从没有用过computed(),现在也应该对它有了一个初步的印象:计算属性分为只读可重写两类。以只读为例,用户可以通过向computed()传入一个getter函数(getter:将对象属性绑定到查询该属性时将被调用的函数),从而获得经过自定义逻辑计算的返回值,如果计算结果依赖响应式数据,那么每当依赖的响应式数据更新时,计算属性也会重新执行、更新。

接下来让我们以只读计算属性为例回溯一下计算属性的实现原理。

修改effect为懒执行

既然计算属性也具备数据响应式的特性,那么存不存在一种可能:计算属性的设计者当初在实现计算属性的时候,就参考了数据响应式的实现原理或者说就是在数据响应式的基础上做了些许的微调呢?这完全是有可能的。

假设我们把传入effect的副作用函数fn看作是一个getter,那么每次副作用函数的执行都会执行一次getter,进而,如果我们能在副作用函数调用的时候返回这个值,是不是就有点像计算属性了呢?你看,此时let value = computed(getter)let value = effect(getter)是不是就有点像?基于以上思路,如果我们还能够控制effect函数的执行时间,不让effect函数立即执行,而是在需要的使用调用,不就简单地实现了计算属性computed()了吗?

进一步来看,为了实现不立即执行副作用函数,可以向effect的选项参数options传入一个lazy布尔值属性,当lazy === true,副作用函数就不会不立即执行,而是作为返回值暴露出来,此时effect函数实现代码如下:

carbon (81).png

现在,我们实现了能够懒执行的副作用函数,并且能够拿到副作用函数的执行结果,拿上文的Vue.js官网示例打个比方,就相当于拿到了一个返回const plusOne = computed(() => count.value + 1)() => count.value + 1的值的函数,接下来只要创建一个computed()函数,实现对plusOne的赋值,从形式上就好像简单实现了计算属性,实现代码如下:

carbon (79).png

性能优化-添加缓存功能

上一小节中我们已经简单实现了计算属性computed(),但是还有待完善:分析实现的代码,你会发现每次读取obj.value的值都会执行一次完整的effectFn副作用函数进行重新计算,而在计算属性的值一直都没有变化的条件下,这种“反复读取就会反复计算”的情况显然是不合理的,因为此时计算属性的值一直都没有变化,也就完全没有反复计算的必要。

其实解决思路也很简单,就像用lazy标记副作用函数是否需要立即执行一样,我们可以用变量dirty来标记计算属性的值是否需要重新计算,同时用变量value储存缓存值:如果dirty的值为true,就调用effectFn重新计算并更新缓存值,同时修改dirty的值为false;而如果dirty的值为false,则直接返回上一次缓存的值就可以了。那什么时候需要修改dirty的值为true呢?当然是计算属性的值变化的时候修改啊。比如在const plusOne = computed(() => count.value + 1)之中,每当count的值变化就需要将dirty的值修改为false,这个功能我们可以利用调度器函数schduler来实现,即每当计算属性所依赖的响应式数据变化时就重置dirty = true,具体代码实现如下:

carbon (80).png

bug解决-嵌套计算属性外层响应性失效

现在,我们设计的计算属性就已经趋近于完美了,但是当出现以下情况时,就暴露出来一个bug:

carbon (84).png

我们期望当obj.foo自增时,能够重新打印sumRes的值,就像在Vue.js中一旦计算属性的值发生变化就会重新渲染,但是事实上sumRes的值并没有重新打印。现在,让我们来试着分析一下:

  • 当执行const sumRes = computed(() => obj.foo + obj.bar)时,obj.fooobj.bar会分别与该副作用函数(计算属性赋值表达式)建立联系,即每当obj.fooobj.bar的值发生变化时都会重新赋值sumRes。有问题吗?没有啊
  • 再看effect(() => { console.log(sumRes.value) }),要知道:sumRes虽然是个计算属性,但是此时还并没有做数据代理,它本身并不是一个响应式数据,所以虽然sumRes的值变化了,但是并不能重新触发console.log(sumRes.value)的执行,这就是问题所在

既然问题的根源找到了,那现在就让我们来想一想如何解决:已经知道是因为sumRes不具备响应性,所以每次sumRes发生变化时才不能自动触发相关依赖的副作用函数执行。那么,我们可不可以手动模拟一个Proxy()对象代理:当读取sumRes.value时,手动调用track函数进行追踪;当sumRes.value发生变化时,手动调用trigger函数触发相关副作用函数的执行。好像行得通哎,动手实现试试?

实现代码如下:

carbon (85).png

总结

计算属性实现的关键:

  • 利用getter函数响应计算属性的读取
  • 利用tracktrigger函数实现计算属性的数据响应性

附实现计算属性相关完整代码:

carbon (86).png

声明

  • 本文属于读书笔记一类,是作者在拜读 霍春阳 大佬的新作《Vue.js设计与实现》途中,以书中内容为蓝本,辅以个人微末的道行“填写”完成,推荐购书阅读,定有收获
  • 欢迎大佬斧正
  • 日更

参考资料