Vue3.2 响应式原理解析(四):响应式API的实现原理分析

2.jpg

前言

今天这篇文中是对vue3中所有的响应式API做一个原理解析,其中有一些已经前面的几篇文中讲解了,reactiveshallowReactivereadonlyshallowReadonly的解析在Vue3.2 响应式原理解析 (二)ref的解析在# Vue3.2 响应式原理解析(三),剩下的API在这篇文章一一解析。

计算属性

computed可以接受一个getter 返回一个不可写的ref

const count = ref(0)
const double = computed(() => {
    return count.value * 2
})
double.value = 4 // 报警告
复制代码

也可以接受一个配置对象 里面有settergetter,返回一个可以写的ref

const count = ref(1)
const plusOne = computed({
  get: () => count.value + 1,
  set: val => {
    count.value = val - 1
  }
})

plusOne.value = 1
console.log(count.value) // 0
复制代码

但是一般用的最多的是它的getter,computed的源码位置在 vue-next/packages/reactivity/src/computed.ts,实现具体如下:

image.png

声明两个变量来存储用户传递进来的方法, onlyGetter结果是判断用户是不是只传递了一个getter函数,根据这个结果,开始对变量进行复制,但是有的用户传递的是一个配置对象,里面只有getter函数,在进入核心逻辑之前,会再一次判断,如果setter变量的值取反后传入,是false代表不是只读,否则就是只读。

image.png

先介绍几个属性:

dep:很常见了,是依赖这个数据的副作用函数,

_value:计算属性在每次变化的缓存的值,如果和这个值没有区别,表示没有发生变化

_dirty:防止多次触发,在链式调用的情况下

effect:这个计算属性的副作用

__v_isRef:这是一个ref,且是一个可计算的ref

最后一个标记这个计算属性是否可以改变

构造函数

image.png

构造函数的主要目的是为这个计算属性产生一个副作用,getter是用户传递进来的,而调度函数一般都是修改计算属性依赖的响应式数据的过程中在triggerEffects中被调用,调度函数进行if判断,是为了防止在链式调用computed的时候,后续的微任务多次执行triggerRefValue,也就是一次修改,要等所有的处理完毕才能进行下一次修改

获取与更新

image.png

在值的获取和更新这方面,和ref做的很类似,都是用了存取描述符,不同点在于,ref是可以直接从自己身上拿到值进行返回,computed需要执行用户传递进来getter方法,才能获取最新的数据,或者缓存的值是最新,就把缓存值返回,更新ref是可以直接往自己身上获取值,computed需要用户传递了setter方法(传了是可写的,没有传就是不可写ref,只读的),才可以更新值,否则报警告。

在get方法中,做了一些处理,计算属性的值可能会被其他东西代理,必须要通过toRaw()才拿到原始的值,正常的收集依赖, 下面的if判断是为了防止在链式调用中,在获取值的时候,都是去执行getter方法,如果值未发生变化,缓存都是最新,将缓存返回就可以了

其他补充

image.png 在执行完核心逻辑之后,在开发模式 给一个调试computed的机会,最后将可计算的ref返回

可能存在的延迟计算属性API

在vue3.2中,更新了一个新的响应式APIdeferredComputed,但是这个API还不能使用,但是我们可以简单来看一下它的实现,看看它与computed到底有什么区别,具体实现在:vue-next/packages/reactivity/src/deferredComputed.ts,

image.png 入口十分简单,可以看出来,核心逻辑都在DeferredComputedRefImpl中,而且只能传递getter方法,看来延迟计算的数据是不可以修改,下面看看核心逻辑

image.png

先看属性,其实和computed差不多,直接跳过,

image.png

相对computed直接计算,deferredComputed在触发其调度函数之后,并不会马上执行,而是放入队列等待未来的某个时刻(它依赖的执行完毕,就会来调用队列中的调度函数)有人来执行它,且为了防止多次执行自身的triggerRefValue,会进行一个验证,最新的值和缓存的值要一样才会执行。

image.png

剩下的获取值的方法就和computed一模一样了。我个人认为,这个API可能是为了那些依赖于异步数据的computed的,必须要延迟计算。在vue3.0版本中,这两个API的代码其实在一起的。这或许是为了区分两个,才把他们给分开。

监视属性

vue3能够监视数据的API有很多,如常见的watch,还有vue2的watch配置、watchEffecteffectthis.$watch都可以监视数据,且watchEffect还有两个衍生APIwatchPostEffectwatchSyncEffect,如果看他们各自的实现可以发现,其实他们其实调用的都是一个方法doWatch,根据传递实参的不同达到不同的效果。源码地址在:vue-next/packages/runtime-core/src/apiWatch.ts

watchthis.$watch是这样调用的:doWatch(source as any, cb, options)watchEffect是这样调用的:return doWatch(effect, null, options),先看看doWatch的实现。函数比较庞大,我们一部分一部分看

image.png

doWatch接受三个参数,分别是source:监视的数据,cd:数据变化之后执行副作用函数,options监视数据的一些配置选项,有immediate:立即执行,deep:深度监视,flush:是异步执行还是同步执行,至于onTrackonTrigger是在开发的过程中,调试用的。

进入doWatch,声明了几个变量,instance当前实例 getter在数据变化的时候重新获取数据,forceTrigger:数据变化后一定会执行更新,isMultiSource:有多个数据源,

code.png

获取数据

第一部分是对获取数据的getter方法进行处理,主要是依靠source进行处理,关键点是不是函数,watch支持() => data的写法,也可以直接写数据名,如果是数据名就不需要继续处理。

如果是函数需要进一步判断,watchEffect传递的也是函数,这个时候就需要对doWatch的第二个参数进行判断了,watchwatchEffect调用doWatch传递的第二参数一个是函数一个是null,根据这个条件分别处理。还有一种情况就是,传递进来的是一个数组,这代表需要监视的数据有多个。

最后一个else是啥都没传,默认给一个NOOP,防止报错,也可能在未来的某个时刻替换成其他函数。

code.png

其他处理

第二部分是一些处理,如:vue2数组watch兼容,深度监视处理,提供一个函数给用户取消副作用中挂起的异步函数(异步请求)。还有服务端渲染。

image.png

在进行深度监视处理时,调用了traverse,函数作用是为了在监视的时候能够更加快速的找到数据且监视数据的变化。内部自己维护一个变量seen,在第一次获取数据时会在seen中缓存,在修改数据的时候也会缓存在这里,就不会每次找数据都要找一遍,提高了不少性能。

code.png

异步调度函数

前面已经生产了一个getter,是在数据变化的时候立即执行,但有的时候不需要立即执行,而是进渲染队列,等待渲染完成才执行,就需要生产一个调度函数。job函数的主要逻辑就是执行用户传递的cb函数,在过程中,第一次不会执行用户注册的取消函数,从第二次开始就会执行,到后面通过包装成一个调度函数。

调度函数有三种行为(vue2只有一种),sync:同步直接执行 post:放入渲染队列中,等待渲染完成再执行,pre:默认行为,如果组件没有挂载丢入队列等待挂载完成再执行,不然就直接执行。而watchEffect的两个衍生方法就是这样来的。

image.png

最后就是把卸载监视的函数返回,doWatch函数分析完毕。

this.$watch的实现

code.png

this是虚拟参数,不用看,剩下的三个参数是:source监视的数据源,value:可能是函数,如果不是就是配置对象,options:配置对象。

对于getter的处理一共有三种情况:1. 直接就是数据的名称,2. 也支持函数 () => xxx 3. 如果是xxx.ccc.aaa啥的,多半是一个对象,createPathGetter就是处理这种情况的,

image.png

ctx是当前组件的实例代理对象,path传递进来一般都是aaa.bbb.ccc,其数据结构是{ aaa: { bbb: { ccc: 10 } } },返回的就是一个可以循环从代理对象中拿取数据的方法。

再就是对数据变化就执行的函数的处理,提前声明一个变量cb, 如果value就是一个函数,就可以直接赋值给cb,但是如果value直接就是一个对象的话,会是{handler: () => {}, deep: true}这种结果,配置对象和副作用写在同一个对象中,就需要把value.handler赋值给cb,然后把value赋值给options

image.png

先把之前的currentInstance存储一份,再将this(调用$watch的实例)设置为currentInstance,但是再调用完doWatch之后又把之前的currentInstance设置回去了,刚开始我对这波操作也是很疑惑,直到联想上了doWatch,再会用到this(调用$watch的实例),但是为了不破坏之前的,就采取了这波操作。

Effect 作用域

看文档中的介绍:Effect 作用域是一个高阶的 API,主要服务于库作者。关于其使用细节请咨询相应的 RFC。的开发的业务中,我们并不常用,基本就是服务于基于vue二次开发的用户。我们就简单的分析一下。源码地址:vue-next/packages/reactivity/src/effectScope.ts

effectScopen

image.png

作用是:创建一个 effect 作用域对象,以捕获在其内部创建的响应式 effect (例如计算属性或侦听器),使得这些 effect 可以一起被处理。

recordEffectScope

image.png

作用是:给一个effect指定EffectScopen,如果不指定,默认就是当前激活的EffectScope

getCurrentScope

image.png

如果有返回当前的激活的作用域

onScopeDispose

image.png

在当前活跃的 effect 作用域上注册一个处理回调。该回调会在相关的 effect 作用域结束之后被调用。 该方法在复用组合式函数时可用作 onUmounted 的非组件耦合替代品,因为每个 Vue 组件的 setup() 函数也同样在 effect 作用域内被调用。

EffectScope

在上面的几个API中,可以大量的看到两个东西 activeEffectScopeEffectScope,这两个分别是全局的记录着当前激活的Effect作用域,一个是用来产生新的Effect作用域,activeEffectScope只是一个全局变量,方便各个地方使用,主要是看看EffectScope的实现。

属性

image.png effects:当前Effect作用域的effect

cleanups:在当前Effect作用域所有的effect停止的时候执行的所有的函数,

parent:当前Effect作用域的父Effect作用域,

scopens:存储了当前Effect作用域所有的子Effect作用域。

index:当前Effect作用域在父当前Effect作用域中的索引

构造函数

image.png

如果是由创建实例的方法进行实例化 不会进入这个if中 因为每一个组件的副作用作用域是独立不能相互干扰,但如果是在某个组件实例中进行实例化 那么当前的组件实例的副作用作用域就会被作为新产生的作用域的副作用父作用域(在parent属性上存储),且在父作用域上会有一个变量scopes来存储所有归属它的子作用域,子作用域上有一个变量index作为标记。

成员方法

code.png

剩下的方法都是对产生的Effect作用域里所有的effect进行操作,比如,执行当前所有的effect可以调用on方法,执行完毕需要off配合, 如果想要停止所有的effect可以调用stop方法。

补充的点:effect和watchEffect的区别

effectwatchEffect都是可以传递一个副作用函数,且都可以对副作用内部的函数进行追踪,但是经过测试发现,如果在watchEffect传递的函数中有if等条件判断语句,那么在条件判断没有成立的中的数据是不会被追踪的,这叫做更新依赖追踪。相反,effect无论如果条件成立,只要有的数据都会去追踪。

以上就是我对vue3.2的响应式API实现原理的分析,如果有说的不对的或遗漏的,也请各路大佬指出,如果有更好的理解,也希望大佬能在评论区中说明,谢谢。

猜你喜欢

转载自juejin.im/post/7036902115150659615