Vue3.2 调度系统简单分析

398 阅读5分钟

image.png

前言

大家好,我是咸鱼,之前断更了一个月,在这里说声抱歉(ps:前天发的文章太短了,不能算数),今天这篇文章的注意内容是带着大家去了解Vue3中的调度系统,了解调度系统的执行流程,并且能够把之前分析的源码串联起来,能够清楚Vue3整个的执行流程,废话不多说,我们开始吧。

什么是调度

调度这一概念来自于计算机中,负责调度的叫调度器,主要的工作是在计算机所有的资源都在忙碌时合理的分配资源(线程、进程等),以达到最优的执行效率,调度器也分为不同的作用,比如吞吐量最大化、响应时间最小化等,它能够选择下一个处理的任务或下一个执行的进程。

Vue3中的调度是什么

在Vue中,是通过调度算法实现了一个调度器,虽然行为和上面是大同小异,但是目的上却是截然不同,Vue中的调度器不需要去分配计算机资源(系统自己的任务),它只需要是让产生的任务(job)按照一定的规则和合理的顺序执行, 保证DOM更新和API执行顺序正确

Vue中有需要API需要传递回调函数的设计,而这些回调函数并不会立即执行,而是作为任务(job),进入队列中,等到未来合适的时机进行执行,比如DOM更新、组件生命周期函数等,DOM更新需要等到响应式数据更新完毕,而updated需要等到DOM更新完毕之后执行

  • watch函数的回调函数的默认行为
  • DOM更新
  • updated生命周期函数
  • $foreceUpdate实例方法
  • ...

调度算法

前面说Vue3中通过调度算法实现了一个调度器,那么什么是调度算法呢?比较常见的调度算法有两种:

1、 先来先服务(FCFS: First Come First Service):先进入队列先开始执行

image.png

2、 优先级调度算法:正常进入队列,优先级高的会先执行

image.png

调度算法作为整个调度过程的抽象,它无需关系执行的任务是什么,它只需要知道按照什么顺序执行任务

Vue3中的调度算法

在Vue中,有两种调度算法,一种是先来先服务调度算法(First Come First Service),简称FCFS。第二种是优先级调度,根据优先级进行排序后执行。这样子就有点类似客户服务,先来的就先受到服务,但是如果来了一个VIP客户,那么就会优先服务VIP客户。

在整个调度器的设计中,在调度器外执行方法只有三个,它们的作用都是把任务加入到不同的队列中,调度器只负责执行任务而无需关心任务的内容是什么,这样的设计可以极大减少调度器和外部API的耦合,这样就算后面加入新的API,也不用去改变调度器。只需要在API的实现中把想要加入到队列中的任务通过加入队列的方法加入到队列中即可。加入队列的方法有三个:

  • queueJob:加入到queue队列
  • queuePreFlushCb:加入到pre队列
  • queuePostFlushCb:加入到post队列

使用实例代码:

// 创建job1任务
cosnt job1 = () => {
    console.log('子组件更新')
}

// 设置优先级
job.id = 2

// 创建任务job2
const job2 = () => {
    console.log('父组件更新')
}

// 设置优先级
job.id = 1

//加入到队列中
queueJob(job1)
queueJob(job2)

queuePreFlushCb(() => {
    console.log('watch1观察者被触发')
})

queuePostFlushCb(() => {
    console.log('updated生命周期函数触发')
})

/*
打印输出:
watch1观察者被触发
父组件更新
updated生命周期函数触发
*/

子组件的任务因为父组件更新变成失效任务(DOM更新是深度更新,会连带着子组件更新,为了避免重复更新),watch默认行为在组件第一次挂载之前调用,所以使用Pre队列 updated需要的等到DOM更新完成,所以使用post队列

调度细节

什么是队列

队列中保存着Vue在执行过程中产生的任务,在未来合适的时间取出来执行,Vue中有三个队列:pre队列、queue队列、post队列。

  • pre队列:DOM更新前队列,在DOM更新前都会立即执行里面的任务,先进先执行,而后出栈
  • queue队列:DOM更新异步更新队列,里面任务会根据优先级排序,并且允许插队
  • post队列:DOM更新后队列,在DOM更新完成后,会立即执行里面的任务,也会根据优先级进行排序,也允许插队

三个队列的详细区别如下:

pre队列
queue队列
post队列
队列作用执行DOM更新前的任务执行DOM更新任务执行DOM更新后的任务
是否去重去重去重去重
执行顺序先进先出允许插队,根据id进行排队允许插队,根据id进行排队
删除任务不需要特殊情况下需要删除任务不需要
任务有效性任务全部有效组件卸载,其任务失效任务全部有效
是否递归允许递归允许递归允许递归

队列执行顺序如下:

image.png

队列结构:const queue: SchedulerJob[] = []

在真正队列执行中,并不是把任务从队列中取出执行,而是遍历整个任务队列执行,到最后再清空整个队列。

任务的结构

任务的数据结构如下:

export interface SchedulerJob extends Function {
  id?: number // 表示优先级
  active?: boolean // 表示job是否有效 失效的job不会执行
  computed?: boolean
  allowRecurse?: boolean // 表示effect是否允许递归触发自身
  ownerInstance?: ComponentInternalInstance // 仅在开发模式下,用于超出最大递归更新获取组件信息
 }

加入队列

在前面说到了,加入队列靠的是三个函数,每一个方法运用的地方都不同,首先是queueJob函数,它是用于给DOM更新的任务,比如在renderer.ts中,更新器instance.update()就是通过这个函数加入到队列中的。

image.png

queuePreFlushCb函数是给那些需要在DOM更新前执行的任务加入到Pre队列,目前只有一个地方用到了这个函数,在apiWatcch.ts中的watch任务执行会使用。

image.png

最后一个函数queuePostFlushCb函数,这个用到就比较多样化了。大多数都是用于组件生命周期函数,但是有一点特殊,当组件是Suspense时,会使用Suspense内部的实现的方法,虽然最后还是可能会调用这个函数。

image.png

queueCb函数

queuePostFlushCbqueuePreFlushCb函数都是通过queueCb函数实现的,原因是都是加入队列,只是加入的队列不同,需要一个统一的加入队列的方法。

image.png

queueCb函数的实现很简单,判断没有激活的队列或者激活的队列中没有这个任务即可加入队列。核心逻辑就是将任务加入队列。

但是到进来的任务是一个数组,那么就是生命周期函数的任务,它只能是由一个任务触发,并且该任务已经在队列中去重。

function queueCb (job) {
    // 核心逻辑
    pendingQueue.push(job)
}

优先级机制

  • queue队列

    在每一个任务身上都会有一个属性id,这个是优先级标志,正常情况下,任务都是按照队列中的任务顺序执行任务,但是有的时候某些任务需要优先执行。

    比如父子组件同时产生更新,但是父组件创建的比子组件早,用创建组件的序号作为优先级标志,那么父组件一定比子组件的小,而且Vue更新中,通常都是从父组件到子组件的深度更新,所以父组件更新也一定在子组件的前面。

    实现插队的方式也是十分简单,我们只需要根据job.id插入到队列中合适的位置即可,在执行时会进行根据job.id进行排序

    function queueJob() {
        if (job.id == null) {
           queue.push(job)
        } else {
        // 通过二分查找 从队列中找到合适位置将任务插入到队列中
          queue.splice(findInsertionIndex(job.id), 0, job)
        }
    }
    
  • post队列

    mounted、updated生命周期函数传递的回调函数需要在DOM挂载\更新完成之后才执行,watchPostEffect的回调函数通过用户设置之后也可以在DOM更新完成之后执行。还有比较少见的内部任务:更新模板引用

    自从Vue3删除了$childrenref就成了新的常用于获取模板的方法,在有些时候需要去更新它的值,在每次调度patch函数的末尾都会去更新ref的引用值,当ref是非空值时,就会作为任务加入到post队列中,job.id会被设置为-1。使其最先执行。

image.png

为什么要让模板引用更新先执行呢,看一下下面代码:

const App = {
    template: `
    <div v-if="counter%=2" ref="childRef">v-if</div>
    <div v-else="counter%=2" ref="childRef">v-else</div>
    <button @click="changeCounter">changeCounter</button>
    `,
    
    setup() {
      const childRef = ref(null)
      const counter = ref(0)
      const changeCounter = () => {
        counter.value++
      }
      
      return {
        childRef,
        counter,
        changeCounter
      }
    },
    
    updated() {
      console.log(`显示的内容是`,this.$refs.childRef.innerHTML)
    }
}

在每次DOM更新之后,生命周期函数updated都会执行一次输出内容,但是每次更新之后引用的模板是不一样的,需要等到模板引用更新之后再输出才是正确的值,不然输出的就是错误的值

删除任务

在父子组件同时组件同时产生更新时,会产生两个任务,一个是父组件更新任务,一个是子组件更新任务。但是,前面说到了,Vue中DOM更新是深度更新,所以父组件更新时会连带着子组件一起更新,这个时候子组件更新任务就显得有点多余,就会去删除它。流程如下:

image.png

删除任务的实现:是需要把要删除的任务传递进来,根据任务的id进行删除即可

export function invalidateJob(job: SchedulerJob) {
  const i = queue.indexOf(job)
  if (i > flushIndex) {
    queue.splice(i, 1)
  }
}

失效任务

在开始说明什么是失效任务之前,我们先来看一段代码:

const Child = {
    template: `
      <div>{{counter}}</div>
    `,
    setup(props, {attrs, slots, emit}) {

      const counter = ref(1)
      const changeCounter = () => {
        counter.value++
        emit('foo', counter.value)
      }

      return {
        changeCounter,
        counter
      }
      
    }
  }

  const Parent = {
    template: `
      <Child ref="child"  @foo="changeState" v-if="hasChild" />
      <button @click="changeCounter">changeCounter</button>
    `,
    setup() {
      const changeState = () => {
        hasChild.value = false
      }

      const hasChild = ref(true)

      onMounted(() => {
        console.log(hasChild)
      })

      return {
        changeState,
        hasChild
      }
    },
    
    methods: {
      changeCounter: function(){
        this.$refs.child.changeCounter()
      }
    },
    
    components: {
      Child
    }

在上面的代码中,由父组件Parent触发子组件Child的事件,修改Child组件的响应式数据(产生一个任务),并且触发到了Parent的changeState函数,修改了状态hasChild,(产生第二个任务),hasChild为假值时,Child不显示(用的是v-ifChild组件会被卸载)。

开始执行队列中的任务,当进行到Child组件上的v-if进行判断的时候,Child进入卸载流程,在卸载中调用stop方法就会将其属性active改为false,而一个任务需要执行条件之一是active不为false,所以当Child组件被卸载时,它产生的任务就被变为失效任务。具体的流程图如下:

image.png

既然删除任务和失效任务都是不执行任务,那么两者有什么区别呢:

删除任务失效任务
场景父子组件同时产生更新,会删除子组件的更新组件被卸载,job被认为是失效任务,取出不执行
再次加入队列可以,再次产生更新会加入队列不会,最后队列会清空
意义删除任务是因为已经更新过了,不需要重复更新,
如果依赖的响应式数据被修改,还是要加入队列,
等到更新
组件被卸载,无论如何修改依赖的响应式数据,
都不会产生更新

递归任务

递归任务指的是DOM更新的job在执行的过程,依赖的响应式再次发生了改变,又把自身的的instance.update又加入到队列中。

任务递归是Vue调度中比较复杂的情况,我们通过实际的代码和流程图帮助理解。示例代码如下:

const Child = {
  template:`<div></div>`,

  props: {
    counter: {
      type: Number
    }
  },

  setup(props, {attrs, slots,emit}) {
    console.log(props)
    watch(props, () => {
      if(props.counter <= 2) {
        emit('foo')
      }
    })
  },
}

const App = {
  template:`
  <div>{{counter}}</div>
  <Child :counter="counter"  @foo="changeCounter"/>
  <button @click="changeCounter">changeCounter</button>
  `,
  setup() {
    const counter = ref(1)
    const changeCounter = () => {
      counter.value++
    }

    return {
      counter,
      changeCounter
    }
  },

  components: {
    Child
  }
}

点击按钮触发changeCounter函数,修改了自身依赖的响应式数据,导致了DOM更新,props进行了更新,子组件的watch观察到props发生变化,此时的counter变成了2,派发了事件foo,再次触发了changeCounter,又导致了父组件的DOM更新再次进入队列,counter变成了3,子组件的watch中的条件不成立,不会再派发foo事件。

简单来说,就是父组件App修改了counter, 父组件更新了props, 子组件Child的watch观察到了变化,派发foo事件,再次让父组件App的更新任务进入队列。看一下流程图。

image.png

执行队列

flushJobs 是执行队列的函数,下面是实现的主要逻辑(伪代码)

// 开始执行队列中的任务
function flushJobs(seen?: CountMap) {
    // 执行pre队列中的任务
    flushPreFlushCbs(seen)
    queue.sort((a, b) => getId(a) - getId(b))
     for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
      const job = queue[flushIndex]
      // 比如在卸载组件的时候,会去触发stop函数,这个函数内部就会停止这个组件的产生的任务
      if (job && job.active !== false) {
        if (__DEV__ && check(job)) {
          continue
        }
        // 这里就是执行调度函数的入口
        callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
      }
    // 执行post队列中任务
    flushPostFlushCbs(seen)
    // posst任务队列执行过程中产生新的任务加入到队列中
    // 继续执行,知道全部完成
    flushIndex = 0
    queue.length = 0
    if (
      queue.length ||
      pendingPreFlushCbs.length ||
      pendingPostFlushCbs.length
    ) {
    // 队列中还有任务在等待 请继续执行
      flushJobs(seen)
    }
  }
}

flushJobs主要工作是:1、循环执行pre队列,每一个任务进行递归(当前任务执行时产生新任务),2、执行queue队列,3、执行post队列,4、重新循环执行所有队列,直到所有队列都清空

执行pre队列

export function flushPreFlushCbs(
  seen?: CountMap,
  parentJob: SchedulerJob | null = null
) {
  if (pendingPreFlushCbs.length) {
    currentPreFlushParentJob = parentJob
    activePreFlushCbs = [...new Set(pendingPreFlushCbs)]
    pendingPreFlushCbs.length = 0
    for (
      preFlushIndex = 0;
      preFlushIndex < activePreFlushCbs.length;
      preFlushIndex++
    ) {
      if (
        __DEV__ &&
        checkRecursiveUpdates(seen!, activePreFlushCbs[preFlushIndex])
      ) {
        continue
      }
      // 执行队列中的任务
      activePreFlushCbs[preFlushIndex]()
    }
    // 清空Pre队列
    activePreFlushCbs = null
    preFlushIndex = 0
    currentPreFlushParentJob = null
    // 递归执行,当前任务作为父任务(pre等待队列中的新任务是由当前任务执行产生的)
    flushPreFlushCbs(seen, parentJob)
  }
}

上面是执行pre队列的函数flushPreFlushCbs的实现主要逻辑,先对等待队列中的任务去重并改为activePre队列,到最后可能会进行递归,直到全部执行完毕

执行queue队列

queue.sort((a, b) => getId(a) - getId(b))
for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
  const job = queue[flushIndex]
  // job.active=false 为失效任务不会执行 
  // 比如在卸载组件的时候,会去触发stop函数,这个函数内部就会停止这个组件的产生的任务
  if (job && job.active !== false) {
    if (__DEV__ && check(job)) {
      continue
    }
    // console.log(`running:`, job.id)
    // 这里就是执行调度函数的入口
    callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
  }
  flushIndex = 0
  queue.length = 0
}

上面是执行queue队列的主要逻辑,主要流程如下:

1、对queue队列进行排序

2、 遍历执行queue队列执行任务

3、执行完之后清空queue队列

执行post队列

export function flushPostFlushCbs(seen?: CountMap) {
  if (pendingPostFlushCbs.length) {
    // 去重
    const deduped = [...new Set(pendingPostFlushCbs)]
    pendingPostFlushCbs.length = 0

    // #1947 already has active queue, nested flushPostFlushCbs call
    // 已经存在一个正在执行的队列 那就是嵌套执行flushPostFlushCbs
    // 请将任务添加到正在执行的队列末尾, 等待执行
    if (activePostFlushCbs) {
      activePostFlushCbs.push(...deduped)
      return
    }

    activePostFlushCbs = deduped
    if (__DEV__) {
      seen = seen || new Map()
    }

    activePostFlushCbs.sort((a, b) => getId(a) - getId(b))

    for (
      postFlushIndex = 0;
      postFlushIndex < activePostFlushCbs.length;
      postFlushIndex++
    ) {
      if (
        __DEV__ &&
        checkRecursiveUpdates(seen!, activePostFlushCbs[postFlushIndex])
      ) {
        continue
      }
      activePostFlushCbs[postFlushIndex]()
    }
    // 清空activePost队列中的任务
    activePostFlushCbs = null
    postFlushIndex = 0
  }
}

执行pos队列的是flushPostFlushCbs函数,主要流程如下:

1、对post等待队列进行去重,然后改为activepost队列,

2、如果已经存在activepot队列,那就把任务加入到activepost队列中,然后退出,

3、遍历activepost,执行任务,执行完任务后,清空activepost

为什么post队列不需要进行递归,流程的最后为什么再次执行flushJob

if (
  queue.length ||
  pendingPreFlushCbs.length ||
  pendingPostFlushCbs.length
) {
    // 队列中还有任务在等待 请继续执行
    flushJobs(seen)
}

执行完post队列之后,也可能会产生新的任务,但是我们并不知道它加入到了那个队列中,所以我们要对pre、queue、post队列进行判断,如果三个队列中有一个队列中有新任务,请重新执行flushJob()函数。

最后

调度作为Vue3中的重要的部分,有者十分重要的作用,它使的Vue3能够按照正确的顺序执行各种任务和各种API。学习其中的设计理念,对于我们的技术提升有着很大帮助。

以上仅为个人的分析,还是希望各位哥哥姐姐能指导指导。有说错或者遗漏的欢迎在评论区讲解,谢谢。