从零实现Vue响应系统(二、computed与watch)
在上一篇我们已经大致理解了 Vue 的响应式系统的基础架构是如何实现的,本篇我们将继续深入,实现 computed 和 watch 这两个重要的特性。
本文中的完整代码地址:https://github.com/nonhana/demo-vue/blob/main/src/js/responsiveData.js
调度执行
有时候我们想要在触发副作用函数重新执行的时候,自己手动操控副作用函数的执行时机、次数以及方式。这个就是所谓的 可调度性。同时,这也是 Vue
中实现 watch
、 computed
等响应式 API 的最重要的基础设施。
给个非常简单的例子:
如果这么写,我们的运行结果会是:
但是,如果想要调一下顺序,比如我想把 “运行结束” 放到 0 和 1 的中间,有没有什么办法能够在不调整代码的情况下完成呢?这个时候,响应系统就需要支持手动调度了 。
不能改变实际应用的代码本身,那么我们需要从 effect
函数的参数下手。我们需要设计一个选项参数 options,允许用户指定调度器,这个调度器可以让我们自己写一些对于这个副作用函数的执行逻辑。这里的 fn
参数就是上面这个副作用函数,可以看成是一个回调参数的传入。
我们可以看到这第二个参数是一个对象,它就是我们集中设置调度方式的配置对象。其中有一个 scheduler
,它允许我们指定具体的调度函数。
相应的,我们需要修改 effect
函数使其支持第二个参数:
加上了 options
之后,我们就可以在 trigger
阶段手动调用 scheduler
,使其按照用户想要的方式进行触发:
如果当前的副作用函数存在手动传入的 scheduler,我们就可以把这个副作用函数作为参数传入到 scheduler 当中,然后执行我们自己写的函数执行逻辑就可以了。
好的, 接下来我们就可以自己手动实现这个 scheduler 里面的具体逻辑以完成需求了。
我们可以看到执行结果按照我们的预期输出了:
但是有时候,我们也不一定只是想要控制副作用函数的执行流程,可能还想控制执行次数。很多时候我们会对一个值进行连续的操作:
一般的输出是直接输出 0 1 2
,但是我们只是想要的是最终的结果,也就是输出 0 2
而省略掉中间的过渡状态。
我们也可以同样基于 scheduler 来改写代码,不过需要处理一些额外的数据结构。
这里的实现方法稍微有点复杂。我们可以一点点来分析。
首先是这里的 jobQueue
,也就是任务队列,它是一个 Set,目的是为了 自动去重。
这里的 scheduler 在每次触发的时候,都会将当前的副作用函数添加到 jobQueue
中,并且执行 flushJob
来刷新任务列表。
flushJob
函数利用了 Promise.then
的回调,将 “遍历 jobQueue
并执行任务” 这一工作放到了微任务里面,每次调用这个函数的时候,都会先判断 isFlushing
这个标志位,看需不需要重新执行。
然后我们看实际的代码执行流程。可以看到有两个连续的 obj.count++
,那么 scheduler 实际上会被连续的执行两次,jobQueue
会被压入两个相同的 fn
,但是因为其去重特性最后只会有一个。flushJob
函数也因为 isFlushing
的存在,在执行第一个 obj.count++
的时候 isFlushing
已经被置为 true了,之后的 obj.count++
实际上不会做任何的事情。
而当 flushJob
开始执行时,由于微任务执行顺序在同步代码之后的特性,两个 obj.count++
实际上是会先执行,之后才轮到微任务。不得不说这里的 .then 用的是真巧妙 所以在执行副作用函数前 obj.count
的值已经是 2 了。所以这样就实现了期望的输出。
顺带一提,Vue 中多次连续修改响应式数据(比如 ref
)最后也只会触发一次更新,原理跟这个类似。
计算属性computed与lazy
在我的第一段实习期间,由于我本人 Vue
的基本功还不算特别扎实,甚至还没有所谓的 响应式驱动 的编程思想,如果涉及到某些要根据响应式变量而变化的内容,我的第一反应往往还是直接往 {{ }}
里面写 n 元表达式。经过我的 mt 拷打过后,我才渐渐地养起了 能用 computed
和 watch
绝对不自己造驱动函数 的思想。后来的实践也证明,我的这一做法是对的。
lazy延迟副作用函数执行
其实基于上面的一些实现,我们已经可以初步的自己造一个计算属性 computed
出来了,不过在此之前还是得先稍微铺垫一些背景。
如果是一步步跟到这里的,应该会察觉到目前的 demo 响应系统的 effect
函数实际上是会立即执行一次的。但是很多时候我们肯定不是想让其立即执行的,而是只听我们 trigger
的话执行,类似于 Vue3
里面的 watch
不带 immediate
选项。
这个时候我们就可以往 options 里面塞东西来告诉 effect
函数,说这里面的副作用函数不要立马执行:
接下来我们改一下 effect
以处理这个新的配置项:
这样子可以不立即执行了,并且我们拿到了经过加工后的副作用函数,执行了他就可以进行注册了。
我们可以拿着它到任何我们想要调用的地方进行调用:
基于lazy的computed实现
不过这跟我们的计算属性又有什么关系呢?
实际上,我们平常写的副作用函数可能跟 Rust 里面的表达式语句差不多,它可能仅仅是一个箭头函数,提供一个返回值,然后我们 可以手动调用它并拿到返回值:
看看,是不是跟我们平常写的计算属性差不多?
为了能够做到这一点,我们可以改改 effect
,使其能够保存并返回我们的计算结果:
改完啦,我们先稍微测测看上面的代码:
成功了!接下来我们手写一下计算属性:
这里的 getter
函数实际上就是类似于 () => obj.age + obj.count
这样的返回一个单纯 JS 表达式的函数。把它作为副作用函数,然后创建一个 lazy 的包装后的副作用函数 effectFn
。然后就是创建一个对象,这个对象里面有个 get 访问器属性,在使用 obj.value
调用的时候会自动触发 effectFn
函数,然后这个函数我们刚刚改过了,可以直接返回 getter
执行后的结果。
我们来测!
你将会看到输出结果是很干净的 3
。
不过我们都知道吧,Vue3
里面的 computed
是会对值进行 缓存 的。我们如果把 console.log(sumRes.value)
多写几遍,目前的实现实际上是会每一次访问都执行一遍 effectFn
的,对于复杂的 getter
来说开销是很大很大的。
我们来试试给 computed
塞一个缓存的功能:
我们利用闭包,加了 value
和 dirty
变量。
- value 就是缓存本身,缓存上一次的值,如果不需要重新计算,就直接返回它。
- dirty 是一个标识符,标识在当前的调用中是不是需要重新计算一遍缓存的值。
在 obj.value
这里的逻辑很简单,当 dirty 为 true 的时候说明得重新算,那就直接执行一遍 effectFn
拿到值给 value,然后把 dirty 置为 false,为了下次不重复计算。如果 dirty 就是 false,那就直接 return value
就行了。
不过问题在于,我们需要在 effect
那边注册 getter
的时候,需要传入一个 scheduler 函数来自定义逻辑。我们知道 getter
本身其实就是我们的 副作用函数,因此它里面的响应式变量发生变化的时候是会重新执行一遍 getter
函数的,但是因为 getter
函数的作用只是单纯的计算没有任何副作用,甚至可以说就是一个纯函数,所以在计算函数中它的重新执行可以说是没啥用。而自定义的 scheduler 则为其根据响应式变量变化而重新执行这一行为赋予了意义——重新把 dirty 变为 true 以在下一次调用 obj.value
的时候重新刷新 value 值。
这样子我们的缓存基本上就算实现完了。
计算属性的依赖注册
基于上面的实现,我们目前的计算属性基本可以说是可以使用了,不过还是有一点小瑕疵。我们可以写写这样的代码:
写出这样的代码,我们肯定想的是,这个 sumRes
是计算属性,并且依赖于 obj.num1
和 obj.num2
,那么我改了 obj.num1
之后同样把 sumRes
也改了,肯定会重新触发 console.log(sumRes.value)
吧?但是现在还不行,目前只能打印一次。
问题是什么呢?我们可以回头看看 sumRes.value
干了啥,它被调用的时候实际上是会执行一遍 effectFn
,而 effectFn
本身就是 effect
的执行结果。我们都知道如果给 effect
传了 lazy 的选项,那么它的返回值就是 包装好的副作用函数 fn
,而实际上最开始不传 lazy 的时候,effect
的立即执行也就是这个包装好的副作用函数。所以实际上,在 effect
里面调用 sumRes.value
的时候,执行包装好的副作用函数,在执行的时候 activeEffect
实际上只是传入 computed
里面的 getter
函数而已,而 () => console.log(sumRes.value)
这个副作用函数显然并没有被注册到。所以这实际上是因为 effect
嵌套,外层 effect
没有使用到内层的 effect
的响应式变量,导致外层的这个副作用函数没能注册到内层的响应式变量对应的 key。
为了更直观一点,我们可以直接把 computed
的过程写在 effect
里面:
这样是不是就清楚了?在执行内层的 effect
的时候,我们用到了响应式变量 obj.num1
和 obj.num2
,而外层没用到这两个变量。 如果在外层也用一下实际上就能立马解决这个问题:
看一下结果:
所以解决的办法就是类似于上面的方式,我们可以在调用计算属性的 value 的时候,手动的在依赖列表里面把 obj.value
给注册一下 就可以了。
我们手动使用 track
函数把 obj.value
作为依赖追踪的目标对象和其目标属性。这样子写之后,我们再来执行一遍最开始的那部分代码,实际上是会构筑成这样的数据结构:
这个时候,我们无论重复修改多少次 getter
里面的代码,都能够正常的触发 effect
内部的代码了。
watch 的实现原理
基础实现
当然,Vue
中著名的响应式 API 除了 computed
以外就是 watch
了。watch
的本质就是观测一个响应式数据,当数据变化时通知并执行相应的回调函数。
我们通常在开发中都会这么写:
obj
本身就是一个响应式数据,使用 watch
来对其进行观测,当响应式数据发生变化后,触发回调函数的执行。
根据上面对于 computed
的实现,我们应该都能够猜出来,watch
肯定也是利用了 effect
以及 options.scheduler
来进行实现的。
在之前的例子中,我们都知道了 options.scheduler
是用户手动指定的触发原副作用函数的执行逻辑,它会自动把原副作用函数当作参数传进来,然后执行用户自定义的规则。
不过问题是,它传进来原副作用函数,我们非得执行它吗?不用吧? 实际上这个 options.scheduler
就相当于一个回调函数,我们可以手动的写我们自己的逻辑而完全不用到我们的原来的副作用函数。所以可以利用这个特点,手动写一个 watch
。它需要接受两个参数,一个是响应式数据源,一个是回调函数。
可以看到代码比较简单,watch
由用户手动的指定了回调函数,在 options.scheduler
里面就单纯的执行了一下而已。重点在于依赖的收集。
我们可能传进去的就是一个很单纯的响应式数据对象,而我们可能只改其中的一些字段。因此我们需要将响应式对象里面的所有属性全都注册起来,确保无论修改多深层次的属性都能够被响应。所以这就需要一个递归读取函数 traverse
来实现。
接收getter函数
我们平时在写代码的时候,对于一些非响应式的变量的监听,除了使用 toRefs()
进行包装以外,我们还会使用 getter
函数对其进行包装,然后传给 watch
。
为了适配可能传入一个 getter
函数的情况,我们需要稍微修改一下 watch
接受到参数之后的处理:
我们改用 getter
函数来统一处理传入的 source
,如果为函数,那就直接进行赋值;如果是单纯的响应式对象,那么就调用 traverse
进行递归读取。然后,在 effect
函数内部执行 getter
就可以了。
newValue与oldValue
我们都知道,watch
这个 API 最强大并且有特色的功能就是 能够获取被监视对象的新值和旧值。为了实现这一点,我们要借助 effect
的 options.lazy
选项。
lazy 本身就是为了能够让用户手动去调用副作用函数而生的一个选项,并且如果副作用函数是单纯的 getter
,那么更是能够直接拿到执行后的返回值。因此在这里,每次要监听的数据发生变化后,就会重新执行 options.scheduler
,在执行的时候就可以手动调用 effectFn
拿到最新的值,然后使用 callback
把 newValue
和 oldValue
都传出去。最后就是更新 oldValue
,给下一次做准备。
立即执行的watch与回调执行时机
如果我们想要让 watch
里面的回调函数在响应式数据变化之前按照同步代码的方式立即执行一次,我们通常会往 watch
里面传入第三个参数:{immediate: true}
。
为了实现这一点,我们要稍微修改一下 watch
函数的参数,使其能够接收 options
:
可以看到,为了能够实现传入 options.immediate
的时候可以手动调用这个 scheduler
的内容,我们需要把原来 scheduler
部分的逻辑单独的拆出来给一个 job
函数,之后根据 options.immediate
的具体值决定是否直接调用。
不过除了 options.immediate
以外,Vue3
中还提供了一个 flush
的参数,能够更加精确地指定回调函数的执行时机,可以选择 'pre' | 'post' | 'sync'
三种。
- pre:回调在组件更新之前执行,即数据和 DOM 都已经更新完成,回调才会被触发。如果不传
flush
参数, 这是默认值。 - post:回调在组件更新之后执行,即数据和 DOM 都已经更新完成,回调才会被触发。
- sync:回调在数据变化的同步任务中立即执行,不等待 Vue 的响应式系统批量处理队列,而是立即执行回调。
我们在这里可以试着写一写传入 'post'
参数时的回调执行逻辑。我们在之前实际上有写过类似的 延迟任务执行 的逻辑,那就是 把同步代码推进微任务队列中。在这里的实现逻辑其实也差不多。
不过肯定大家都看得出来,目前我们实现的响应式系统还没有牵扯上真正的 组件,我们目前的实现方式其实是 'post'
和 'sync'
,'pre'
在目前还没办法实现。不过算是稍微明白了 watch
底层是怎么处理的,之后我读到组件那里了我再慢慢填坑吧。
过期的副作用
在上面,我们其实已经实现了一个相对比较完善的 watch
API 了,不过还剩下下一个我们经常写但是下意识忽视掉的一个问题:竞态问题 。
我们平时肯定有写过那些 分页获取数据 的逻辑吧?一般不用那些乱七八糟的缓存请求库的做法是自己写一个 const page = ref(0)
这样的响应式变量,然后通过一些逻辑让它变化,之后通过 watch
来监听它,它发生变化后,能够触发数据的重新获取。
我们来基于上面的实现,自己模拟一下:
是不是看起来非常非常的正常?我们平时都这么写的。但是我们都忽视了一个问题,如果 fetchData
这个异步函数执行的时间很久,在其返回结果之前我们可能就会修改 obj
的数据,这个时候肯定会发起第二次请求,这个时候我们就不知道到底哪个请求先返回值 ,如果第二次快一点,finalData
的值就是第一次请求的值了。
不过按照我们一般的思维,后发的请求肯定是我们期望更新到的最终数据 。所以我们应当把请求 B 视为最新的,而请求 A 则应该被视为 过期的副作用 从而不再起作用,相当于后面触发的请求 B 把 A 覆盖了。
所以我们需要一个让前一个副作用函数过期的手段。在现版本的 Vue3
中,watch
的回调函数实际上是可以接收第三个参数 onInvalidate
。它是一个函数,我们可以用它注册一个回调,使其在当前副作用函数过期的时候执行。
我们可以利用这第三个参数,改写一下 watch
内部的具体逻辑:
我们在回调函数内部注册了一个 expired
来标识当前的这个副作用函数是否过期。当当前副作用函数过期时,会自动调用 onInvalidate
内部的回调函数,把这个标识位置为 true。如果过期了,很明显就不执行了,从而只确保执行没有过期的那个副作用函数。
那么这个 onInvalidate
在 watch
内部到底是怎么实现的呢?同样也是利用 scheduler
,在每次被监听的响应式数据发生变化后,在副作用函数执行之前都自己手动调用一下 onInvalidate
内部传的回调,如果用户有传的话:
这样子我们就能够实现,即便传入 watch
内部的回调是一个需要等待的异步函数并且监听的响应式数据连续不断的发生变化,最后拿到的结果也是最后一次发生变化后触发的回调函数的结果。不过前面的请求,该发的也都会发,只是最后不给 finalData
赋值。 所以后面可能还有优化的空间,不过已经发出去的请求确实收不回来了。
我们可以来测试一下:
看一下输出结果:
可以看到,很准确的只输出了最后一次变更后的结果,obj.page
原本是 1。
这样子,对于 watch
的简单版本实现,我们目前算是告一段落了。