逐行解析vue3 如何使用微任务实现调度系统

注:本文使用vue版本为3.3.7

在上一节万字逐行解析vue3如何使用effect实现响应式中,我们提到了queueJob,从结果上来看是一个将入参延后执行的函数,但是他具体起到什么作用呢?我们看看源码。

queueJob

typescript 复制代码
let flushIndex = 0
// 将任务加入任务队列
export function queueJob(job: SchedulerJob) {
  // 用于数组.includes()的startIndex参数来进行去重查找
  // 默认情况下,搜索索引包括当前正在运行的任务
  // 所以它不能递归地再次触发自身。
  // 如果任务是 watch() 回调函数,搜索将从 +1 索引开始
  // 允许它递归地触发自身
  // 确保它不会陷入无限循环。
  if (
    !queue.length ||  // 如果队列为空,或者
    !queue.includes(
      job,
      isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex  // 如果正在执行队列并且任务允许递归,则搜索从 flushIndex + 1 开始,否则从 flushIndex 开始
    )
  ) {
    if (job.id == null) {
      queue.push(job);  // 如果任务没有 ID,直接将任务加入队列
    } else {
      queue.splice(findInsertionIndex(job.id), 0, job);  // 否则,在找到合适的位置插入任务
    }
    queueFlush();
  }
}

从代码上看,这个函数的作用的确是将任务加入任务队列。

SchedulerJob本质是一个挂载多个可选属性的函数 ,在推入任务队列之前,会检查队列是否为空,或者队列不包含当前任务,并且队列查找当前任务起始startIndex去来重:是否在执行或者是否允许递归,如果正在执行并且任务允许递归,startIndex就是flushIndex + 1,否则是flushIndex

这样确保在执行的时候,任务不会再次触发自身,避免无限循环。

如果任务没有id,那么就会直接推入任务队列,否则通过findInsertionIndex调整合适的位置。

最后执行queueFlush

这里遇到了两个函数,一个是findInsertionIndex,另一个是queueFlush

我们先看findInsertionIndex

typescript 复制代码
function findInsertionIndex(id: number) {
  // 起始索引应为 flushIndex + 1
  let start = flushIndex + 1;
  let end = queue.length;

  // 二分查找循环
  while (start < end) {
    // 计算中间索引
    const middle = (start + end) >>> 1;
    // 获取中间位置的任务和任务的 ID
    const middleJob = queue[middle];
    const middleJobId = getId(middleJob);

    // 如果中间任务的 ID 小于给定 ID,
    // 或者中间任务的 ID 等于给定 ID 且pre为 true,
    // 将起始索引移到中间索引的后一位
    if (middleJobId < id || (middleJobId === id && middleJob.pre)) {
      start = middle + 1;
    } else {
      // 否则,将结束索引移到中间索引的位置
      end = middle;
    }
  }
  // 返回插入位置的索引
  return start;
}
}

使用findInsertionIndex的前提是任务需要有id,因此这里默认存在id,通过二分查找的方式,保证队列的任务执行顺序是递增的。

这里的递增有两个判断确保,id不等的情况下,id自增。

id相同的情况下,pre:true的任务在前面。这样,可以确保任务队列的有序性。

接下来看看queueFlush

typescript 复制代码
function queueFlush() {
  // 如果没有队列在执行且没有等待执行的标志时
  if (!isFlushing && !isFlushPending) {
    isFlushPending = true;  // 设置等待执行的标志为 true
    currentFlushPromise = resolvedPromise.then(flushJobs);  // 使用 Promise 确保会在下一个事件循环中执行
  }
}

很简单的逻辑,就是确保当前任务队列执行完成之后,触发下一轮队列的执行,他通过设置isFlushPendingtrue,来表示等待执行。

然后使用Promise创建一个微任务 ,来却确保flushJobs在下个事件循环的执行。

这样,在当前的任务队列执行结束后,下一轮任务队列就会被触发。这种机制确保了任务队列的顺序执行,避免了并发执行可能引发的问题。

resolvedPromise实际上是Promise.resolve(),必定会走到flushJobs中的,所以看看flushJobs是个什么东西。

typescript 复制代码
// 定义比较器函数,用于比较两个任务的优先级
const comparator = (a: SchedulerJob, b: SchedulerJob): number => {
  // 获取任务的 ID,用于比较任务的顺序
  const diff = getId(a) - getId(b);

  // 如果任务的 ID 相等,考虑任务的pre来确定优先级
  if (diff === 0) {
    if (a.pre && !b.pre) return -1;  // 如果任务 a 是pre任务而任务 b 不是,a 的优先级较高,返回 -1
    if (b.pre && !a.pre) return 1;   // 如果任务 b 是pre任务而任务 a 不是,b 的优先级较高,返回 1
  }

  // 返回任务的比较结果
  return diff;  // 返回任务 ID 的差值,正数表示 a 的优先级较高,负数表示 b 的优先级较高,零表示两者优先级相等
};


// 执行任务队列中的任务
function flushJobs(seen?: CountMap) {
  isFlushPending = false;  // 清除等待执行的标志
  isFlushing = true;  // 设置正在执行的标志为 true

  // 在执行前对任务队列进行排序,以确保任务按照一定的顺序执行:
  // 1. 组件的更新从父组件到子组件执行(因为父组件总是在子组件之前创建,所以它的渲染效果的优先级数字较小)
  // 2. 如果一个组件在父组件的更新期间被卸载,它的更新可以被跳过。
  queue.sort(comparator);

  try {
    for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
      const job = queue[flushIndex];
      if (job && job.active !== false) {
        callWithErrorHandling(job, null, ErrorCodes.SCHEDULER);  // 执行任务
      }
    }
  } finally {
    flushIndex = 0;
    queue.length = 0;

    flushPostFlushCbs(seen);  // 执行post任务

    isFlushing = false;  // 任务执行结束
    currentFlushPromise = null;

    // 如果任务队列中还有任务,或者还有待执行的post任务,继续执行任务队列
    if (queue.length || pendingPostFlushCbs.length) {
      flushJobs(seen);
    }
  }
}

这个函数主要用于执行任务队列的任务,他按照任务的优先级,进行了排序,而排序逻辑就是id相同的,优先执行pre,整体是以id从小到大递增。

然后通过for循环执行任务队列中的任务,任务被检测到可能引发递归更新,会跳过该任务,避免无限循环。

函数执行完成后,会检查任务队列是否还有剩余的任务或者待执行的post任务,如果有,则继续执行flushJobs

这里可能有人有疑问了,明明queue.length设置为0,为什么还要在后面检测queue.length的大小。

那是因为flushPostFlushCbs可能会再次往queue推入任务。

queuePostFlushCb

还记得上一节说的,如果watchflushpost,也就是watchPostEffect,那么响应函数会通过queuePostRenderEffect进行包装。queuePostRenderEffect在当前渲染周期结束后执行。也就是延后执行。

queuePostRenderEffect实际上是queuePostFlushCb的别名。而queuePostFlushCb是什么呢?

typescript 复制代码
export function queuePostFlushCb(cb: SchedulerJobs) {
  // 如果cb不是数组
  if (!isArray(cb)) {
    // 如果当前没有活跃的post任务,或者任务不在队列中(根据 allowRecurse 标志决定是否可重复添加)
    if (
      !activePostFlushCbs ||
      !activePostFlushCbs.includes(
        cb,
        cb.allowRecurse ? postFlushIndex + 1 : postFlushIndex
      )
    ) {
      pendingPostFlushCbs.push(cb);  // 将任务post队列
    }
  } else {
    // 如果cb是数组,说明它是一个组件的生命周期钩子,它只能由任务触发
    // 而任务在主队列中已经去重,所以这里可以跳过重复检查,提高性能
    pendingPostFlushCbs.push(...cb);  // 将数组中的任务全部加入到待执行的post任务数组中
  }
  queueFlush();  
}

queueJob类似,不过这里是将post的任务推入pendingPostFlushCbs中,那么pendingPostFlushCbs会被谁来消费呢?没错,就是上文的flushPostFlushCbs

typescript 复制代码
export function flushPostFlushCbs(seen?: CountMap) {
  // 如果有待执行的post任务
  if (pendingPostFlushCbs.length) {
    // 去除重复的任务
    const deduped = [...new Set(pendingPostFlushCbs)];
    pendingPostFlushCbs.length = 0;  // 清空待执行的post任务队列

    // 如果已经存在活跃的post任务,则将去重后的任务添加到队列中
    if (activePostFlushCbs) {
      activePostFlushCbs.push(...deduped);
      return;
    }

    activePostFlushCbs = deduped;  // 将去重后的队列设置为活跃的post任务队列

    // 根据任务的 ID 进行排序,以确保它们按照一定的顺序执行
    activePostFlushCbs.sort((a, b) => getId(a) - getId(b));

    // 依次执行post任务队列
    for (postFlushIndex = 0; postFlushIndex < activePostFlushCbs.length; postFlushIndex++) {
      activePostFlushCbs[postFlushIndex]();  // 执行
    }
    activePostFlushCbs = null;
    postFlushIndex = 0;
  }
}

这个函数主要用于执行post任务。它会去除重复的任务,并按照回进行排序,以确保post任务队列按照一定的顺序执行。在执行过程中,如果有新的任务被加入,会在下一轮执行时继续执行。

也就是是执行pendingPostFlushCbs队列去重后的任务。

因此,上文会说,flushPostFlushCbs可能会再次往queue推入任务。

比如下面的例子。

typescript 复制代码
const num = ref(0)

const a = watchPostEffect(() => {
  num.value // 收集依赖
  const b = watchEffect(() => {
    num.value++
  })
})
num.value++

最后的num.value ++会触发watchPostEffect中函数的执行,也就是执行了flushPostFlushCbs,而里面的watchEffect默认是flushpre,在上一节我们讲过,scheduler会被包装成() => queueJob(job),最后作为调度函数执行。

也就是说flushPostFlushCbs可以通过这种方式,再次往queue推入任务。

flushPreFlushCbs

我们前文介绍了正常任务(没有id的)也提到了pre类型的任务排在同id前面执行,然后也讲到了post类型的任务,看起来很完美,但细想上一节,watch中标记pre的是要在模板渲染之前执行的。但显然只是一个sort并无法让pre类型的任务提前执行。

回忆一下。模板渲染之前调用的方法

typescript 复制代码
 const updateComponentPreRender = (
    instance: ComponentInternalInstance,
    nextVNode: VNode,
    optimized: boolean
  ) => {
    // ....
    pauseTracking()
    flushPreFlushCbs()
    resetTracking()
  }

我们发现这里面调用了flushPreFlushCbs,如果这个方法是执行pre任务,这样确实是可以实现,在模板渲染之前执行pre任务的需求。

typescript 复制代码
export function flushPreFlushCbs(
  seen?: CountMap,
  // 如果当前正在执行中,跳过当前的任务本身
  i = isFlushing ? flushIndex + 1 : 0
) {
  // 从指定索引(默认为0)开始遍历任务队列
  for (; i < queue.length; i++) {
    const cb = queue[i];
    if (cb && cb.pre) {  // 如果任务存在并且是pre任务
      queue.splice(i, 1);  // 从队列中移除当前任务
      i--;  // 减少索引,以便遍历下一个元素
      cb();  // 执行任务
    }
  }
}

这个函数用于执行任务队列中pre的任务。它从指定索引开始(默认从0开始),遍历任务队列中的任务函数,找到标记为pre的任务,然后依次执行他们。在执行过程中,如果某个任务被执行,它将会被从任务队列中移除,以避免重复执行。这样,所有pre任务都会被有序地执行,确保了它们的执行顺序。

那么问题来了,这里执行了pre任务,flushJobs也执行了pre任务,那么他们到底谁执行pre任务呢?

flushPreFlushCbs的调用时机可以看出来,如果涉及到模板渲染,会由updateComponentPreRender主动执行flushPreFlushCbs来执行pre任务,而非模板渲染更新阶段的事件循环,由flushJobs执行。

那么,这样调度任务有什么用呢?

答案是这样我们我们将多次重复相似的操作合并起来,让他在渲染模板的时候,只调用最新的值。从而减小多次渲染造成的负担。

比如下面的逻辑

html 复制代码
<template>
  <div>{{num}}</div>
</template>
<script>
  import { ref } from "vue"
  export default {
    setup() {
      const num = ref(0)
      setTimeout(() => {
        for (let i = 0; i < 1000; i++) {
          num.value++
        }
      }, 2000)
      return { num }
    },
  }
</script>

我们知道,更新组件是重新调用componentUpdateFn,我下面放一下相关代码

typescript 复制代码
const effect = (instance.effect = new ReactiveEffect(
  componentUpdateFn,
  () => queueJob(update),
  instance.scope 
))

const update: SchedulerJob = (instance.update = () => effect.run())
update.id = instance.uid

componentUpdateFn的调用取决于effect.run的执行。也就是update的执行。

并且由于定义update的时候,将实例的uid赋值给他。因此这个任务是有id的。

而这个任务在第一次更新的时候,就被放入了任务队列,

因此第二次num.value自增,实际数据在refset赋值了,然后会触发依赖effect,并且再度触发queueJob(update),但由于queueJob的判断

typescript 复制代码
!queue.includes(
  job,
  isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex
) {
    // ...往任务队列推
}

显而易见,queue里面已经有一个job了,所以不会再次推一个job,也就是componentUpdateFn

所以最后执行的渲染函数是第一次 进入任务队列的渲染函数,但使用的值是最新的值

nextTick

nextTick就是基于任务调度实现的,所以我们直接看看他的代码

typescript 复制代码
export function nextTick<T = void, R = void>(
  this: T,
  fn?: (this: T) => R
): Promise<Awaited<R>> {
  const p = currentFlushPromise || resolvedPromise;  // 获取当前的currentFlushPromise,如果不存在则resolvedPromise
  return fn ? p.then(this ? fn.bind(this) : fn) : p;  // 如果传入了回调函数,返回一个 promise,在下一个事件循环中执行回调函数(如果有 this 上下文,则绑定 this)
}

实际上还是基于Promise微任务,在下一个事件循环执行传入的回调函数,而这个Promise,可能是queueFlush正在执行的事件循环的Promise,如果没有,那么就使用默认的Promise,也就是resolvedPromise

或者说可以看做近似的立即执行。

所以我们可以得到一个结论,vue3queuejob就是基于Promise创建的微任务来实现的。

相关推荐
Myli_ing23 分钟前
考研倒计时-配色+1
前端·javascript·考研
余道各努力,千里自同风26 分钟前
前端 vue 如何区分开发环境
前端·javascript·vue.js
PandaCave33 分钟前
vue工程运行、构建、引用环境参数学习记录
javascript·vue.js·学习
软件小伟35 分钟前
Vue3+element-plus 实现中英文切换(Vue-i18n组件的使用)
前端·javascript·vue.js
醉の虾1 小时前
Vue3 使用v-for 渲染列表数据后更新
前端·javascript·vue.js
张小小大智慧1 小时前
TypeScript 的发展与基本语法
前端·javascript·typescript
chusheng18401 小时前
Java项目-基于SpringBoot+vue的租房网站设计与实现
java·vue.js·spring boot·租房·租房网站
游走于计算机中摆烂的2 小时前
启动前后端分离项目笔记
java·vue.js·笔记
幼儿园的小霸王2 小时前
通过socket设置版本更新提示
前端·vue.js·webpack·typescript·前端框架·anti-design-vue
疯狂的沙粒2 小时前
对 TypeScript 中高级类型的理解?应该在哪些方面可以更好的使用!
前端·javascript·typescript