引言
提到vue3的响应式更新,想必大家都不陌生。无非就是就是通过setter的拦截,在响应式状态发生改变时执行用户注册的副作用函数以及进行页面的渲染。那你知道vue框架对这些副作用函数的调度策略是怎样的吗?本文将结合源码告诉大家,响应式状态发生改变后,vue做了哪些事情。
案例
话不多说,直接上代码。
html
<template>
<div>
<p>count: {{ count }}</p>
<button @click="loopAdd">开始更新count</button>
</div>
</template>
<script setup lang='ts'>
import {ref, reactive, onUpdated} from 'vue'
const count = ref(0)
let updateCount = 0
const loopAdd = (): void => {
for(let i = 0; i < 10; i++){
count.value++
}
}
onUpdated((): void => {
console.log(++updateCount)
})
</script>
代码逻辑非常简单,就是点击按钮之后循环的更新count的值,并统计updated
钩子执行的次数。让我们来看看执行结果,点击一次按钮后,页面上的count更新为10,控制台却只有一次输出。
可能你会好奇,不是更新了10次数据吗,怎么页面只触发了一次updated的回调呢?这就不得不提到本期的主角queueJob
了,但是在聊这哥们之前,我们来补充一下前置的知识。
前置知识点
副作用函数
副作用函数是指会产生副作用的函数,光说概念可能不是很清楚,看一段代码大家就理解了。
js
function effect(){ document.body.innerText = 'hello world!' }
function get(){ return document.body.innerText }
在effect函数中,设置了document.body中的内容,此时我们再通过get函数去获取body中的值,得到的就是hello world!
,也就是说,effect的执行影响了get的执行结果。当然,直接修改全局变量的值也会产生副作用,像这样直接或者间接的影响到其他函数的运行的函数,我们称之为副作用函数。
在vue中,组件的更新,用户自己注册的生命周期钩子函数,包括watch和computed的回调函数,都被vue视为副作用函数,因为在这些函数执行的过程中可能会改变响应式状态,也可能会更新页面。
副作用的收集与触发
既然组件的更新也是一个副作用函数,那么他是什么时候被触发的呢?
其实,vue中的响应式是通过proxy
来实现的。proxy的作用是修改某些操作的默认行为,大家可以暂且理解为可以监听对象对其属性的读取和修改,并覆盖默认的读取或修改行为。proxy中有很多拦截器,现在要向大家介绍的是其中的getter
和setter
拦截器,其中getter拦截器拦截了属性的读操作,而getter拦截器拦截了属性的写操作。
在vue中,对响应式数据的拦截是这样的。
- 执行副作用函数,并保存当前正在执行的副作用函数
- 执行到get操作时,为当前读取的响应式状态收集依赖(建立响应式状态与副作用函数的联系)
- 副作用函数执行完毕,停止依赖的收集,此时get操作不会触发依赖收集
- 触发set操作时(响应式数据被改变时),执行所有的响应式依赖,即依赖收集过程中保存的副作用函数
以下面这段代码为例
js
const text = ref('hello world!')
effect(()=>{
document.body.innerText = text.value
})
text.value = 'hello vue!'
effect函数的作用是一边执行副作用函数,一边为其中的响应式状态收集依赖。本例中text是一个响应式数据,在收集依赖的过程中,会触发text的getter拦截器将这个副作用函数收集为text的一个依赖。后面执行到text.value = 'hello vue!
的时候,就会被setter拦截器拦截,重新执行副作用函数,更新页面。
这里由于篇幅原因,对响应式的原理就讲到这里。大家如果感兴趣的话,可以自己搜索相关的资料进一步学习,或者看一下reactive的源码,源码在packages\recativity\src\reactive.ts。
vue的任务调度
回到正题,既然进行了多次改变响应式状态的更新,最终只触发了一次组件的更新,那肯定说明vue对组件的更新操作进行了特殊的处理。
直接找到vue对组件更新的副作用是怎么处理的,代码节选自setupRenderEffect
函数,该函数的作用就是为组件的渲染注册副作用,它会在组件的首次渲染时执行。
源码在packages\runtime-core\src\renderer.ts
上面的代码做了这两件事
- 为组件的渲染注册副作用函数
- 一边执行一边收集依赖
先来看ReactiveEffect
,它有三个构造参数,其中前两个对我们来说比较重要。第一个参数是更新时要执行的更新操作(也就是副作用函数),我们熟悉的diff算法就包含在这个函数的逻辑中。第二个则是副作用函数的调度器。在响应式状态发生改变时,会判断当前执行的副作用有没有注册调度器,如果没有,则直接执行副作用函数,如果有,则将副作用函数交给调度器来执行。
也就是说,对于注册了调度器的update
来说,组件的每次更新都是交给() => queueJob(update)
这个函数来执行的。所以说,今天真正的主角,就是这位queueJob
调度器。
queueJob与任务队列
我们直接来看看queueJob
干了什么。
看到这里你可能就明白了,在vue中维护了一个任务队列queue
,每次调用queueJob
向队列中添加任务时,都会搜索队列中是否已经存在即将添加的这个任务。如果存在则直接退出,如果不存在,则会把任务添加到队列中,然后执行queueFlush
。
你可能已经注意到了搜索的开始位置是由job.allowRecurse
和flushIndex
控制的,flushIndex
表示当前正在执行的副作用函数在队列中的位置。allowRecurse
是一个布尔值,表示副作用函数是否支持递归执行,在组件的update
副作用中,他的值为false
。在update函数执行的过程中,如果响应式状态再次被重置,会从下标为flushIndex
的任务开始搜索,也就是update自身,此时就会直接退出,防止递归调用副作用函数。
不是所有行为产生的副作用都具有这样的行为,比如watch产生的副作用,它的
allowRecurse
属性为true
,如果在watch的回调中改变它监听的响应式状态的值,它就会递归的调用自身。
如果job
拥有id
属性,则会根据它的id寻找插入的位置,接下来我们来看findInsertionIndex
方法。
可以看到,这个方法做的就是根据id的值进行二分搜索,找到任务应该插入的位置,保持任务队列中以id升序排列。某些任务的pre
属性值为true
,这代表着这些任务应该在组件更新前执行,所以在插入的时候将他们放在前面,如computed,watch产生的副作用,他们的pre
属性值为true
。
在注册副作用函数时,任务的id被设置为与组件实例的uid相同。而组件实例的创建顺序,是类似dom树的构造顺序的递归构造,他们的uid值是由组件实例创建的先后顺序决定的。所以在执行更新时按照id升序排列,这样能够保证组件更新的顺序一定是由父组件到子组件。因为父组件在更新的过程中可能会影响到子组件的状态,这样的设计可以减少组件的更新次数。
看到这里,似乎就可以解释为什么updated
钩子只被执行了一次的问题了。不过你可能会好奇,update
任务不是在第一次响应式状态发生改变的时候就被放到执行队列里面去了吗,怎么执行后还是得到了十次更新后的结果呢?如果你想到了这点,那就不难猜到,vue是如何执行这些任务的。
答案就是异步。
我们来看看queueFlush
这个函数。
果然和我们想的一样,在这个函数里只有一个操作,那就是将flushJobs
放入微任务中,等待所有的状态更新完成之后执行。
flushJobs异步任务调度器
还是直接看代码。
先对任务队列按照id声讯进行排序,id相等的情况下将pre
属性值为ture
的任务放在前面优先执行。
由于副作用函数可能是由用户编写的,所以放在try...catch
代码块中执行。这里check函数的作用是在开发环境下判断副作用函数是否出现了递归调用,判断依据是同一个副作用函数递归调用是否超过100次。如果超过100次调用,则直接返回并抛出警告。这部分代码并不复杂,感兴趣的话可以自行阅读,源码地址packages\runtime-core\src\scheduler.ts
最后在finally
块中,执行flushPostFlushCbs
,这个函数的作用是对更新的后置任务进行循环执行,逻辑与flushJobs
相似,就不过多解释了。updated
钩子产生的副作用就存在后置任务队列中,也就是说updated
钩子就是这个阶段被执行的。
清空后置任务队列后,再检查任务队列中是否有新加入的任务,如果有则进行递归调用,直到清空所有的任务。
nextTick的执行时机
看完了vue对异步任务的调度方案,你能猜到nextTick的实现方式吗?其实nextTick的源码非常简单,直接上图。
没错,就是直接放到currentFlushPromise
的回调里去。还记得currentFlushPromise
是什么吗?在将flushJobs
放到异步任务中的时候,就将它的promise对象赋值给了currentFlushPromise
,所以我们就得到结论,nextTick的执行时机就是在所有的副作用函数执行完毕之后。
总结
本文向大家介绍了vue中一个非常重要的机制,任务调度器 。当组件的副作用被触发时,拦截器会将副作用函数交给调度器来执行,调度器会判断当前的任务对列中是否已经存在当前任务,并决定是否要将任务添加到队列中执行。整个任务队列被调度器通过Promise.resolve.then()
的方式加入微任务队列中。等待同步代码执行完毕之后,再对组件进行更新。
最后欢迎大家关注我的掘金账号,我会不定期在这里和大家分享前端相关的技术内容。