Vue3.2x源码解析(四):异步更新队列

440 阅读23分钟

系列文章:

本节将深入理解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中只有watchwatchEffect方法的回调是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();
        }
    }
}

打印结果:

解析一下打印结果:

  • 修改count,触发father组件的renderEffect。
  • 将effect.run推送到queue队列,然后冲刷flushJobs,执行componentupdate钩子函数,进行father组件更新。
  • 在father更新过程中,会调用patch更新father组件模板里面的内容,在遇见子组件时,就会走processComponent方法处理组件。
  • 子组件也是更新逻辑,会进入updateComponent方法,最终执行instance.update()【即子组件的componentupdate】。
  • 所以子组件的更新是由父组直接触发的,并非是通过queue队列【下面的断点调试结果印证】。

  • 然后在子组件自身的更新过程中,因为是由父组件引发的,所以需要执行pre队列。

最终在执行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>

继续使用之前的案例,其实只校验本组件的更新变化更简单。

打印结果:

有一个注意点: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组件,使用的最多的就是常规组件的生命周期钩子函数,比如mountedupdatedactivated等等。如果使用的是组合式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>

打印响应式数据结构:

对响应式数据设置新的值时,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结构:

上面我们在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实例

可以看出这个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任务函数 【比如组件更新回调/watch回调】
        # 新增重点:queue队列中不会存在computeEffect,因为计算属性也是响应式数据,他的调度任务执行只是为了触发其他两个job
        
        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)
    }
  }
}

扩展:计算属性变量也是响应式数据,他与普通的ref响应式数据有一定的区别,计算属性变量的变化是由内部响应式数据变化触发的。比如内部ref数据更新,就会触发计算属性变量对应的computedEffect,然后就会执行调度程序,就会触发计算属性变量的依赖即上面两种job,推入到queue队列,这两种job任务在执行回调函数时就能获取到最新的计算属性变量值。所以计算属性的变化是由内部触发的,调度函数只是为了触发其他的回调,并不会推入到queue队列。

可以看见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。