Vue3.2x源码解析(四):异步更新队列
Vue3.2x源码解析(四):异步更新队列
本节将深入理解Vue3的异步更新队列。
官方描述:当你在 Vue 中更改响应式数据时,最终的 DOM 更新并不是同步生效的,而是由 Vue 将它们缓存在一个队列中,直到下一个“tick”才一起执行。这样是为了确保每个组件无论发生多少状态改变,都仅执行一次更新。
下面我们开始分析这一整个更新流程。
1,调度程序
在解析异步更新队列之前,我们首先要认识一个重要的概念:scheduler调度程序。
在Vue3中scheduler是一个非常重要的概念,称为调度程序或者调度器。通过调度程序scheduler来调度任务job,或者说决定如何执行job任务,来保证Vue中的相关API及生命周期钩子函数,组件渲染过程的正确性。
在Vue3中,存在着非常多的异步回调API。比如watch/watchEffect的回调,组件的生命周期mounted/updated回调,响应式数据变化触发的组件更新回调。这些回调函数都不是立即执行的,而是作为Job任务由调度程序规划添加到不同的队列,按照不同的时机去执行。
在Vue3中,scheduler调度程序主要通过三个队列来实现Job任务的调度:
- Pre队列:组件更新前置任务队列。
- queue队列:组件更新时的任务队列。
- Post队列:组件更新后置任务队列。
三个队列的对比:
Pre队列 | queue队列 | Post队列 | |
---|---|---|---|
队列作用 | 执行组件更新之前的任务 | 执行组件更新时的任务 | 执行组件更新之后的任务 |
出队方式 | 先进先出 | 允许插队,按任务id从小大排列执行 | 按任务id从小大排列执行 |
在Vue3中,scheduler调度程序主要控制的是任务的入队方式。三个队列就有三个对应的入队方法:
- queuePreFlushCb:加入pre队列。
- queueJob:加入queue队列。
- queuePostFlushCb:加入Post队列。
备注:queuePreFlushCb方法在3.2.45源码中是不存在的,pre队列也不是实际的存在【也许之前的版本存在,但是目前的版本已经不存在了】,在这里继续这样称呼只是为了让我们更好的理解区别,也许后续的版本会删除pre相关的内容。
接下来,我们逐个分析每个方法及使用场景:
pre队列
pre队列属于组件更新前置任务队列。
在vue3.2x版本源码中,只有一个pre队列的冲刷方法flushPreFlushCbs,我们可以根据这个冲刷方法来解析pre队列的入队时机以及调用时机,首先查看flushPreFlushCbs方法源码:
# 冲刷pre队列
export function flushPreFlushCbs(
seen?: CountMap,
// if currently flushing, skip the current job itself
i = isFlushing ? flushIndex + 1 : 0
) {
# 借用了queue队列,存储组件更新之前的需要执行的cb回调任务
for (; i < queue.length; i++) {
const cb = queue[i]
# 判断必须是pre类型任务
if (cb && cb.pre) {
// 从队列中删除这个任务
queue.splice(i, 1)
i--
# 执行cb回调任务
cb()
}
}
}
通过源码分析:我们可以看见pre队列并没有属于自己的队列,而是复用的queue队列。所以pre队列也就没有自己的入队方法,也是复用的queueJob方法,pre队列和queue队列的任务共用了一个队列queue,任务的是通过pre属性来区分的,并且flushPreFlushCbs方法在执行pre任务的时候,都会从queue队列中进行删除,不会影响后续在组件更新时执行的queue队列任务。
我们首先全局查询一下有哪些pre类型的任务:
// 侦听器
function doWatch() {
...
let scheduler: EffectScheduler
if (flush === 'sync') {
scheduler = job as any // the scheduler function gets called directly
} else if (flush === 'post') {
scheduler = () => queuePostRenderEffect(job, instance && instance.suspense)
} else {
// default: 'pre'
# watch和watchEffect的回调默认是pre任务
job.pre = true
if (instance) job.id = instance.uid
scheduler = () => queueJob(job)
}
...
}
通过查询结果发现:原来在Vue3中只有watch和watchEffect方法的回调是pre任务【即组件更新前置任务】。
然后我们再看一下flushPreFlushCbs方法的执行时机:
通过源码全局查询此方法,总共发现有两个地方调用了此方法:
// 1,在vue应用根组件渲染时调用了一次此方法,
const render: RootRenderFunction = (vnode, container, isSVG) => {
if (vnode == null) {
if (container._vnode) {
unmount(container._vnode, null, null, true)
}
} else {
patch(container._vnode || null, vnode, container, null, null, null, isSVG)
}
// 刷新调度任务 根组件中一般不会做具体的逻辑操作,也就不会存在watch,所以我们主要关注第二个场景
flushPreFlushCbs()
flushPostFlushCbs()
container._vnode = vnode
}
// 2 更新组件之前的操作
const updateComponentPreRender = (
instance: ComponentInternalInstance,
nextVNode: VNode,
optimized: boolean
) => {
nextVNode.component = instance
const prevProps = instance.vnode.props
instance.vnode = nextVNode
instance.next = null
updateProps(instance, nextVNode.props, prevProps, optimized)
updateSlots(instance, nextVNode.children, optimized)
pauseTracking()
// props update may have triggered pre-flush watchers.
// flush them before the render update.
# props的更新,可能会触发子组件内pre watch的回调,需要在子组件更新之前执行回调
flushPreFlushCbs()
resetTracking()
}
我们继续跳转updateComponentPreRender方法的调用,通过查询发现此方法也有两个地方在调用:
- 第一个是SUSPENSE组件更新时调用。
- 第二个是常规组件更新时调用【具体来说是父组件的更新触发的调用】。
我们主要解析第二个场景:
// 组件更新
const componentUpdateFn = () => {
if (!instance.isMounted) {
...
} else {
let { next, bu, u, parent, vnode } = instance
if (next) {
// parent calling processComponent (next: VNode)
// 父组件触发的子组件更新
next.el = vnode.el
updateComponentPreRender(instance, next, optimized)
}
}
}
根据以上的分析,我们对pre队列进行一个总结:pre队列中只有两种任务的回调:watch回调任务和watchEffect回调任务,并且根据它们的触发情况来看【主要场景】:由父组件引起子组件更新的情况,即props变化,并且子组件有watch监听了props数据或者watchEffect的回调函数中引用了props数据的情况下,props的数据变化就会触发watch依赖,执行调度程序:
scheduler = () => queueJob(job)
将watch和watchEffect的job任务推送到queue队列。这时在子组件更新之前,就会优先处理这些pre任务,即调用flushPreFlushCbs方法,冲刷执行组件更新的前置任务【这就是pre队列最常见的使用场景】。
注意:父组件引起子组件更新的情况:父组件是自身状态变化,组件更新是通过queue队列执行flushJobs冲刷函数,最终执行componentUpdateFn钩子函数开始更新的,然后在更新的过程中,调用了patch方法,在遇到子组件时,就会执行processComponent组件进程方法,这时候子组件也是更新逻辑,就会进入updateComponent方法,执行instance.update(),即开始进行子组件的更新【也是执行componentUpdateFn】。所以在这种情况下,子组件的更新并非是走的queue,而是直接由父组件触发更新。也就是说只有是组件自身状态变化引起的组件更新,才会被推入到queue队列,走冲刷函数进行更新。下面测试案例会进行验证。
测试案例:
// father.vue
<template>
<div>
<child :count="obj.count"></child>
<button @click="handleClick">修改数据</button>
</div>
</template>
<script setup>
import child from './child.vue'
const obj = reactive({count: 0})
function handleClick() {
console.log('修改count')
obj.count = 1
}
</script>
// child.vue
<template>
<div>header</div>
<!-- <div>{{props.count}}</div> -->
</template>
<script setup>
// 使用props
const props = defineProps({
count: Number
})
// 监听porps
watch(()=>props.count, ()=>{
console.log(props.count)
})
</script>
然后我们在源码中添加log日志:
// 1
const componentUpdateFn = () => {
if (!instance.isMounted) {
...
} else {
let { next, bu, u, parent, vnode } = instance
if (next) {
next.el = vnode.el
# console.log('next update')
updateComponentPreRender(instance, next, optimized)
}
}
}
// 2
const updateComponentPreRender = () => {
...
// 更新props 会触发依赖,执行scheduler = () => queueJob(job)
updateProps(instance, nextVNode.props, prevProps, optimized)
updateSlots(instance, nextVNode.children, optimized)
pauseTracking()
// props update may have triggered pre-flush watchers.
// flush them before the render update.
# console.log('父组件引发的子组件更新刷新pre')
flushPreFlushCbs()
resetTracking()
}
// 3
function flushPreFlushCbs(seen,
// if currently flushing, skip the current job itself
i = isFlushing ? flushIndex + 1 : 0) {
if ((process.env.NODE_ENV !== 'production')) {
seen = seen || new Map();
}
console.log(queue)
for (; i < queue.length; i++) {
const cb = queue[i];
if (cb && cb.pre) {
# console.log('pre执行')
if ((process.env.NODE_ENV !== 'production') && checkRecursiveUpdates(seen, cb)) {
continue;
}
queue.splice(i, 1);
i--;
cb();
}
}
}
打印结果:
编辑切换为居中
添加图片注释,不超过 140 字(可选)
解析一下打印结果:
- 修改count,触发father组件的renderEffect。
- 将effect.run推送到queue队列,然后冲刷flushJobs,执行componentupdate钩子函数,进行father组件更新。
- 在father更新过程中,会调用patch更新father组件模板里面的内容,在遇见子组件时,就会走processComponent方法处理组件。
- 子组件也是更新逻辑,会进入updateComponent方法,最终执行instance.update()【即子组件的componentupdate】。
- 所以子组件的更新是由父组直接触发的,并非是通过queue队列【下面的断点调试结果印证】。
编辑切换为居中
添加图片注释,不超过 140 字(可选)
- 然后在子组件自身的更新过程中,因为是由父组件引发的,所以需要执行pre队列。
编辑切换为居中
添加图片注释,不超过 140 字(可选)
编辑切换为居中
添加图片注释,不超过 140 字(可选)
编辑切换为居中
添加图片注释,不超过 140 字(可选)
最终在执行pre后,子组件继续执行patch,因为子组件没有什么内容,所以到这里子组件更新完成,然后father组件更新完成。
queue队列
通过前面pre队列的了解,其实我们也或多或少了解了一些quque队列。
下面我们正式解析组件更新时的任务队列queue。
首先看quque队列的定义:
// 一个存储调度任务job的数组
const queue: SchedulerJob[] = []
我们在看调度任务SchedulerJob的类型定义:
# 调度任务job类型
export interface SchedulerJob extends Function {
id?: number // 用于对队列中的 job 进行排序,id 小的先执行
pre?: boolean // 前置任务
active?: boolean // job任务状态
computed?: boolean // 是否为计算属性job,即getter
allowRecurse?: boolean
ownerInstance?: ComponentInternalInstance // 如果为组件更新job,存储对应组件实例
}
可以看出queue队列存储的各种任务job,而任务job的类型为函数,但是拥有一些额外的属性,这些属性都比较重要,用于区分job任务的类型以及不同的执行时机。
比如常见的组件更新job任务为一个匿名函数:内容为执行effect.run方法。
// 组件更新job
instance.update = () => effect.run()
update.id = instance.uid
我们再看queueJob方法,这个方法是queue队列的入队方法。
// packages/runtime-core/src/scheduler.ts
# 入队方法
function queueJob(job: SchedulerJob) {
// 如果队列没有任务 或者 队列中不存在当前任务job :才会添加任务
if ( !queue.length || !queue.includes(job, isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex)){
if (job.id == null) {
# 向队列添加新的job任务 ?没有id的是哪种任务?计算属性job好像不会被推入队列
queue.push(job)
} else {
# 插队 // renderEffect和watchEffect的job
queue.splice(findInsertionIndex(job.id), 0, job)
}
queueFlush()
}
}
注意,入队时有重复校验:
# 相同的job只会添加一次,比如同一个组件更新的update= ()=> effect.run()
queueJob(update)
我们再去看看那些地方执行了入队方法queueJob()。
全局查询发现有了几个地方使用了这个方法,我们逐个了解一下:
// 1
function defineAsyncComponent() {
...
const load() = {}
setup() {
return load().then().catch()
}
}
在使用defineAsyncComponent定义异步组件,组件初始化完成后,执行了一次queueJob(instance.parent.update)更新组件。
// 2
$forceUpdate: i => i.f || (i.f = () => queueJob(i.update)),
$forceUpdate方法强制组件重新渲染:其实就是将该组件的更新job推入到queue队列。
// 3
function doWatch() {
...
{
// default: 'pre'
job.pre = true
if (instance) job.id = instance.uid
scheduler = () => queueJob(job)
}
...
}
侦听器:在使用watch和watchEffect两个方法时,它们的job任务都是通过调度程序推入queue队列。
// 4 组件
instance.effect = new ReactiveEffect(
// 传入的回调为组件更新fn
componentUpdateFn,
() => queueJob(update), // 这个调度程序job 就是执行的effect.run()
instance.scope // track it in component's effect scope
)
组件更新的job任务也是通过调度程序的执行推入到queue队列。
综上所述:虽然有四个场景使用,实际上就只有两类:
-
watch回调任务。
-
组件更新任务。
测试案例:
// father.vue
<template>
<div>
<div>{{obj.count}}</div>
<button @click="handleClick">修改数据</button>
</div>
</template>
<script setup>
import child from './child.vue'
const obj = reactive({count: 0})
function handleClick() {
console.log('修改count')
obj.count = 1
}
// 新增watch监听
watch(()=> obj.count, (val)=> {
console.log(val)
})
</script>
继续使用之前的案例,其实只校验本组件的更新变化更简单。
打印结果:
编辑切换为居中
添加图片注释,不超过 140 字(可选)
有一个注意点:watch的回调在组件更新之前执行,这是因为在执行flushJobs函数时,对queue队列中的job任务进行了排序。
function flushJobs(seen?: CountMap) {
queue.sort(comparator)
}
我们再看一下comparator源码:
// 比较器【job任务id】
const comparator = (a: SchedulerJob, b: SchedulerJob): number => {
const diff = getId(a) - getId(b)
if (diff === 0) {
# 同一组件内的job任务,watch回调会排在组件更新之前
if (a.pre && !b.pre) return -1
if (b.pre && !a.pre) return 1
}
return diff
}
因为一个组件内,组件更新的job使用的是组件的id,而watch的job也是默认使用的组件id,所以在相减后会等于0,但是watch的job任务拥有pre属性,属于pre类型job,所以排序时会放到前面。
其实到这里我们就可以发现:在组件更新时的任务队列queue之中,也会存在pre类型任务。组件前置任务pre队列只有pre类型任务,组件更新时的queue队列不仅有pre任务,还有其他类型任务。即queue队列包含了pre队列中的任务,这也许就是就是两个队列共用了一个队列的原因【准确来说pre队列借用了queue队列的存储及入队方法】。而pre队列自身仅仅只有一个队列的冲刷方法,这也是因为它不同的执行时机,需要属于自己的触发方法。
post队列
post队列属于组件更新后置任务队列。
首先看一下Post队列的定义:
// 存储后置任务
const pendingPostFlushCbs: SchedulerJob[] = []
我们继续看它的入队方法:
# 入队方法
function queuePostFlushCb(cb: SchedulerJobs) {
if (!isArray(cb)) {
// 非数组情况下:如果post队列不存在任务,或者队列中不存在当前任务job :才会添加任务
if (!activePostFlushCbs || !activePostFlushCbs.includes(
cb,
cb.allowRecurse ? postFlushIndex + 1 : postFlushIndex
)
) {
# 向post队列添加job任务,一般为各种回调
pendingPostFlushCbs.push(cb)
}
} else {
// if cb is an array, it is a component lifecycle hook which can only be
// triggered by a job, which is already deduped in the main queue, so
// we can skip duplicate check here to improve perf
# 数组情况下;一般任务为组件的生命周期钩子函数,因为组合式API可以多次调用同一个钩子
pendingPostFlushCbs.push(...cb)
}
// 刷新冲刷任务,将处理jobs的函数flushJobs推送到微任务队列
queueFlush()
}
全局查询,查看有哪些地方调用了post队列的入队方法:
查询结果:除了开发环境下的hmr和SUSPENSE组件,使用的最多的就是常规组件的生命周期钩子函数,比如mounted,updated,activated等等。如果使用的是组合式API,就是以数组的方式传递,如果使用的是选项式API,就是以单个回调任务传递。
// 这里暂时只讨论的常规组件即queuePostFlushCb
const queuePostRenderEffect = __FEATURE_SUSPENSE__? queueEffectWithSuspense : queuePostFlushCb
# 组合式
if (m) {
queuePostRenderEffect(m, parentSuspense)
}
# 选项式
if (__COMPAT__ &&isCompatEnabled(DeprecationTypes.INSTANCE_EVENT_HOOKS, instance)) {
queuePostRenderEffect(() => instance.emit('hook:mounted'))
}
所以在Vue3中,mounted/updated这些钩子函数并非是同步触发的,而是添加到了post队列异步执行。
下面我们再来看一下post队列的冲刷方法:
# post队列冲刷方法
function flushPostFlushCbs(seen?: CountMap) {
// 队列必须存在任务,才会执行
if (pendingPostFlushCbs.length) {
// 数据铺平,因为pendingPostFlushCbs数组中的元素,有Fn又有数组
const deduped = [...new Set(pendingPostFlushCbs)]
# 清空psot队列
pendingPostFlushCbs.length = 0
// #1947 already has active queue, nested flushPostFlushCbs call
// 如果存在activePost队列
if (activePostFlushCbs) {
# 把将要执行后置任务存储到本次post队列
activePostFlushCbs.push(...deduped)
return
}
//
activePostFlushCbs = deduped
# 任务排序
activePostFlushCbs.sort((a, b) => getId(a) - getId(b))
for (
postFlushIndex = 0;
postFlushIndex < activePostFlushCbs.length;
postFlushIndex++
) {
# 循环执行回调任务,比如mounted,updated钩子函数
activePostFlushCbs[postFlushIndex]()
}
# 重置预处理任务队列
activePostFlushCbs = null
postFlushIndex = 0
}
}
最后我们再查看flushPostFlushCbs方法的调用时机:
function flushJobs(seen?: CountMap) {
isFlushPending = false
isFlushing = true
queue.sort(comparator)
try {
for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
// 循环队列:从队列中取出job任务
const job = queue[flushIndex]
// 如果任务存在,并且任务是有效状态
if (job && job.active !== false) {
// 开始处理job任务:job是函数
callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
}
}
} finally {
# queue队列处理完成后:重置任务索引,清空队列
flushIndex = 0
queue.length = 0
# 执行组件更新后置任务队列,冲刷post队列
flushPostFlushCbs()
// some postFlushCb queued jobs!
# 如果在执行Post任务后,队列中又产生了任务,那么则继续执行flushJobs(), 直到任务队列都为空,则本轮dom更新完成
if (queue.length || pendingPostFlushCbs.length) {
flushJobs()
}
}
}
这里我们可以看见flushPostFlushCbs方法是在queue队列任务执行完成之后,即组件更新之后,才执行的冲刷post队列。这也是post队列任务通常的执行时机。在组件更新完成之后,执行一些回调函数,比如我们可以mounted/updated钩子里操作dom。
2,定义响应式数据
有了前面调度程序的理解,下面我们正式开始解析异步更新的过程。
首先我们使用reactive定义一个响应式数据:
<template>
<button @click="handleClick">修改数据</button>
</template>
<script setup>
# 定义一个响应式数据
const obj = reactive({count: 0})
console.log(obj)
// 修改
function handleClick() {
obj.count = 1
}
</script>
打印响应式数据结构:
编辑切换为居中
添加图片注释,不超过 140 字(可选)
对响应式数据设置新的值时,Proxy内部的处理程序对象就会拦截相关操作:
const mutableHandlers: ProxyHandler<object> = {
get,
set, # 触发set
deleteProperty,
has,
ownKeys
}
对Vue3响应式原理不熟悉的可以先看《Vue3.2x源码解析(三):深入响应式原理》。
调用set钩子函数:
function setter() {
...
# 触发依赖
trigger(target, TriggerOpTypes.SET, key, value, oldValue)
}
trigger
继续查看trigger源码:
function trigger() {
const depsMap = targetMap.get(target)
if (!depsMap) {
return
}
# depsMap存在的情况下:
// 新建一个空数组:用于存储目标key对应dep实例
let deps: (Dep | undefined)[] = []
...
if (key !== void 0) {
# 常规对象的key触发都在这里执行:
// 如果存在key,则从depsMap中取出key对应的dep实例,添加到deps数组
deps.push(depsMap.get(key))
}
...
# 触发之前,判断deps有没有数据
// 大部分响应式数据都会走这里,只有deps数组长度大于1会走else分支,哪种情况会大于1呢?
if (deps.length === 1) {
if (deps[0]) {
# 触发依赖
triggerEffects(deps[0])
}
} else {
// 创建一个effects数组
const effects: ReactiveEffect[] = []
for (const dep of deps) {
if (dep) {
// 取出deps中的deps实例,添加到effects
effects.push(...dep)
}
}
# 触发依赖
triggerEffects(createDep(effects))
}
}
重点注意:只有在响应式数据存在依赖的情况下,才会触发后续的更新逻辑。如果一个响应式数据不存在依赖,那对响应式数据的修改不会造成任何的副作用。
也就是说上面的两个变量值都必须存在,才说明该响应式数据存在依赖。
// 1
const depsMap = targetMap.get(target)
// 2
depsMap.get(key)
而在Vue中只有以下三种情况,响应式数据才会存在依赖:
- 响应式数据被template模板引用了。
- 响应式数据被计算属性引用了。
- 响应式数据被watch引用了。
<template>
<div>{{obj.count}}</div>
</template>
所以这里我们需要让响应式数据在模板中使用,让它在get拦截中能够创建一个Dep实例来收集组件的renderEffect。注意区分一下:vue2的dep属性挂在响应式数据身上,而Vue3响应式数据的依赖是存储在targetMap变量中,它是一个WeakMap数据结构,存储了项目中所有响应式数据的依赖。
// 1,存储目标对象与值:到targetMap
targetMap.set(target, (depsMap = new Map()))
// 2,存储key与对应的dep实例 到depsMap结构
# depsMap也是一个map结构
depsMap.set(key, (dep = createDep()))
打印targetMap结构:
编辑切换为居中
添加图片注释,不超过 140 字(可选)
上面我们在template模板中使用了obj.count,在触发get时就能进行正确的依赖收集,然后在我们修改count属性值时,才能正确的触发set中的依赖逻辑。
继续执行触发逻辑:
# 注意:在模板中使用的是obj.count这个属性,最终的依赖也会存储count属性上,触发的时候也是从count属性上取出,
triggerEffects(deps[0])
triggerEffects
继续查看triggerEffects源码:
function triggerEffects(dep: Dep | ReactiveEffect[]) {
# dep实例是一个set结构 [...dep] = [ effect ]
# 这里要明确一下dep只是一个依赖收集容器,真正的依赖是effect实例,vue2中是watcher
const effects = isArray(dep) ? dep : [...dep]
// 循环依赖列表
for (const effect of effects) {
// 优先触发计算属性的effect
if (effect.computed) {
triggerEffect(effect, debuggerEventExtraInfo)
}
}
# 我们这里dep中的effect实例,并非计算属性的effect,所以会走下面这个逻辑
for (const effect of effects) {
// 触发非计算属性的effect
if (!effect.computed) {
triggerEffect(effect)
}
}
}
triggerEffect
继续查看triggerEffect源码:
function triggerEffect(effect: ReactiveEffect) {
if (effect !== activeEffect || effect.allowRecurse) {
if (effect.scheduler) {
# 默认都会走异步更新
# 执行effect的调度任务,添加任务到队列
effect.scheduler()
} else {
// 或者执行run方法
effect.run()
}
}
}
重点:当前在执行effect.scheduler时,这里的effect是组件的renderEffect。
展开查看count属性对应的dep实例中的effect实例:
编辑切换为居中
添加图片注释,不超过 140 字(可选)
可以看出这个effect实例的fn回调函数名称是componentUpdateFn,这个effect实例就是组件初始化挂载时创建的renderEffect。
# 创建组件的effect【类似于vue2的renderWatcher】
const effect = (instance.effect = new ReactiveEffect(
// 传入的fn回调函数为组件更新方法
componentUpdateFn,
() => queueJob(update), // 这个调度程序job 就是执行的effect.run()
instance.scope // track it in component's effect scope
))
到这里我们就可以总结一点:一个响应式数据如果在template模板中使用了,那么它对应的dep容器中一定会存在自身组件的renderEffect。注意必须是在模板中有引用,如果是computed/watch的引用,虽然会存在computedEffect/watchEffect,也会触发相关依赖回调,但是没有renderEffect就不能触发组件重新渲染的回调。
3,执行调度任务
我们继续分析上面的执行逻辑:
# 这里的调度程序就是() => queueJob(update)
if (effect.scheduler) {
# 执行effect的调度任务,添加任务到队列
effect.scheduler()
}
所以这里执行的调度任务就是:
queueJob(update)
queueJob
查看queueJob源码:
// packages/runtime-core/src/scheduler.ts
# 添加任务到queue队列,和vue2的queueWatcher一样
function queueJob(job: SchedulerJob) {
// 如果队列没有任务 或者 队列中不存在当前任务job :才会添加任务
if ( !queue.length || !queue.includes(job, isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex)){
if (job.id == null) {
# 向队列添加新的job任务
queue.push(job)
} else {
# 插队
queue.splice(findInsertionIndex(job.id), 0, job)
}
# 第一次执行queueJob,添加任务到queue队列时,就会执行一次queueFlush方法
queueFlush()
}
}
这里有一个重点:只有queue队列中不存在当前任务job,才会添加到队列。
# 相同的job只会添加一次,比如同一个组件更新的update= effect.run(), 即使被多次执行推入,最终也只会存入一个job任务
queueJob(update)
queueFlush
继续查看queueFlush源码:
// packages/runtime-core/src/scheduler.ts
# 刷新队列
function queueFlush() {
if (!isFlushing && !isFlushPending) {
# 冲刷等待,锁住queueFlush
isFlushPending = true
// 将冲刷jobs的函数推送到微任务队列
currentFlushPromise = resolvedPromise.then(flusobs)
}
}
首先可以看见queueFlush方法需要两个变量的状态同时满足,而在第一次执行queueFlush()方法后:
isFlushPending = true
queueFlush方法的执行条件将不再满足,即queueFlush方法在第一次执行后被锁住了,所以即使我们同时修改了多个响应式数据,执行了多次queueJob()方法,而queueFlush也只会执行一次。
我们再看queueFlush方法只执行一次,那这一次它做了什么?
# 将flusobs方法添加到微任务队列
resolvedPromise.then(flusobs)
到这里我们发现queueFlush方法唯一的作用:就是使用Promise.then将flusobs方法添加到了微任务队列,这也是Vue实现异步更新的关键所在。
下面我们再看这个flusobs方法到底是做什么的?
flusobs
继续查看flusobs源码:
// packages/runtime-core/src/scheduler.ts
# 冲刷jobs任务
function flushJobs(seen?: CountMap) {
# 重置状态,解锁queueFlush方法
isFlushPending = false
isFlushing = true
# 通过任务id比较器,将queue队列中的jobs排序
// 确保:1,从父级更新到子级;2,父组件更新过程中,卸载了组件,可以跳过子组件的更新
queue.sort(comparator)
try {
for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
// 循环队列:从队列中取出job任务
const job = queue[flushIndex]
// 如果任务存在,并且任务是有效状态的
if (job && job.active !== false) {
# 执行job任务函数 【比如组件更新回调/computed/watch回调】
callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
}
}
} finally {
# 处理完成后:重置任务索引,清空队列
flushIndex = 0
queue.length = 0
# dom渲染完成后,冲刷post队列: mounted/updated钩子函数会在这里面执行
flushPostFlushCbs(seen)
// 重置调度任务状态
isFlushing = false
currentFlushPromise = null
// some postFlushCb queued jobs!
# 如果在执行post冲刷任务的过程后,queue/post队列又被添加了job任务,那么则继续执行flushJobs,直到本轮更新完成
if (queue.length || pendingPostFlushCbs.length) {
flushJobs(seen)
}
}
}
可以看见flushJobs是真正执行Jobs任务的方法【和vue2中的flushSchedulerQueue方法一样】,组件更新的componentUpdateFn回调、计算属性回调、watch回调最终都是在这里执行的。这个方法执行一次后,才会重新解锁queueFlush方法。
解析到这里,我们可以对Vue的异步更新队列进行一个总结:
在vue的组件中,我们可以定义多个响应式数据,一个响应式数据的dep依赖容器可以收集到三类依赖实例:computedEffect、watchEffect、renderEffect。这其中只有在template模板中使用过的响应式数据才会收集到renderEffect,所以只被computed/watch引用的响应式数据的dep容器无法收集到renderEffect。因此只要在template模板中被引用过的任何一个响应式数据发生变化就会触发renderEffect.scheduler(),然后执行queueJob(update)将组件更新的job推入到queue队列之中,并且因为同一组件内的响应式数据收集的renderEffect都是同一个effect实例,所以在推入去重校验后queue队列只会存在一个当前组件的job。
同理:如果一个计算属性里引用了两个响应式数据,那么这两个响应式数据的dep容器都能够收集到这个computedEffect实例,同时两个响应式数据都发生了变化,但是queue队列中最终也只会存在一个计算属性的job。
经过上面分析我们都知道,queueJob(update)在本轮dom更新的第一次执行就会调用queueFlush(),这个方法内部就会使用Promise.then()将负责冲刷jobs任务的flusobs函数添加到微任务队列,同时queueFlush方法调用一次之后就会锁住,所以在本轮dom更新中,无论后续还有多少响应式数据发生变化,resolvedPromise.then(flusobs)都只会执行一次,微任务队列中也只会存在一个flusobs函数。当本次宏任务中的同步代码执行完成后,就会检测微任务队列,进而执行flusobs函数冲刷jobs任务,循环处理queue队列之中的所有任务,最后computed回调、watch回调,组件更新的componentUpdateFn回调都在这里执行完成,实现最终的dom更新渲染。
扩展:使用nextTick需要放在修改响应式数据之后,就是为了让nextTick的回调添加到微任务队列时,排在flusobs方法之后,保证能够在nextTick回调中拿到最新的dom。
转载自:https://juejin.cn/post/7202796308607795259