Vue3任务调度

·  阅读 1170

在vue3中,修改一个响应式对象,像是这样:

<template>
  <span class="my-name">name: {{ name }}</span>
</template>

<script lang="ts" setup>
import { ref, watchEffect } from 'vue'

const name = ref("zxfan")

name.value += ' yes!'

</script>
复制代码

当执行name.value += ' yes!'vue3并不会立即触发当前组件的更新。而是将更新任务放到任务队列中。这一点与vue2一致。

任务队列

调度相关的源码位于runtime-core/src/schduler.ts

在任务调度过程中,任务会被放入三类任务队列中:

  • preFlushCbs:要在执行queue之前执行的队列

  • queue:任务队列

  • postFlush:要在执行queue之后执行的队列

每一次flush的过程(或者说一个Tick),都是按照清空preFlushCbs队列->清空queue队列->清空postFlush队列的顺序

flush 指刷新任务队列,或者说 执行完任务队列中所有的任务

入队queue的时机

queue存放的都是组件渲染任务

组件更新

先提一下vue中的响应式:

import {effect, ref} from '@vue/reactivity'

const age = ref(20)

effect(() => {
  console.log(age.value) // 会输出两次,分别是 20 和 21
})

age.value = 21
复制代码

effect的回调函数会被响应式得重写触发执行。

那么回到本文最开始的例子中,当执行name.value += ' yes!'。响应式数据变化了,触发(trigger)其关联到的effect重新执行。那么需要重新执行的effect在哪里声明的呢?

// runtie-core/src/renderer.ts
const setupRenderEffect: SetupRenderEffectFn = (
  instance,
  initialVNode,
  container,
  anchor,
  parentSuspense,
  isSVG,
  optimized
) => {
  // create reactive effect for rendering
  instance.update = effect(function componentEffect() {
    // 省略。。。创建或更新 VNode
  }, __DEV__ ? createDevEffectOptions(instance) : prodEffectOptions)
}
复制代码

setupRenderEffect给组件套上了个effect。这个函数是组件实例创建的时候调用的。

当修改组件内的响应式数据时,会触发该effect重新执行。

当然不会直接就执行了,注意到了吗,effect还有第二个参数。第二个参数的类型如下:

export interface ReactiveEffectOptions {
  lazy?: boolean // 该effect是否
  scheduler?: (job: ReactiveEffect) => void // 调度
  onTrack?: (event: DebuggerEvent) => void
  onTrigger?: (event: DebuggerEvent) => void
  onStop?: () => void
  allowRecurse?: boolean
}
复制代码

其它先不关注,我们只看scheduler属性。

如果有这个属性,就不会直接执行effect,而是调用这个scheduler函数,并且将effect作为参数传给它

export function trigger(
  target: object,
  type: TriggerOpTypes,
  key?: unknown,
  newValue?: unknown,
  oldValue?: unknown,
  oldTarget?: Map<unknown, unknown> | Set<unknown>
) {
  
  // 省略...
  
  const run = (effect: ReactiveEffect) => {

    // 有scheduler 调用 scheduler
    if (effect.options.scheduler) {
      effect.options.scheduler(effect)
    } else {
      // 否则直接执行 effect
      effect()
    }
  }

  effects.forEach(run)
}
复制代码

那么在给组件实例套上effect的时候,传给effect的第二个参数是什么呢?

// runtie-core/src/renderer.ts

const prodEffectOptions = {
  scheduler: queueJob,
  // #1801, #2043 component render effects should allow recursive updates
  allowRecurse: true
}

function createDevEffectOptions(
  instance: ComponentInternalInstance
): ReactiveEffectOptions {
  return {
    scheduler: queueJob,
    allowRecurse: true,
    onTrack: instance.rtc ? e => invokeArrayFns(instance.rtc!, e) : void 0,
    onTrigger: instance.rtg ? e => invokeArrayFns(instance.rtg!, e) : void 0
  }
}
复制代码

__DEV__ ? createDevEffectOptions(instance) : prodEffectOptions)根据不同的环境传递的参数有一些差异性。但是我们只关注scheduler,scheduler的值都是queueJob这个函数

queueJob的作用就是将effect放入queue任务队列。

稍后在讨论queueJob具体都做了些什么,先总结一下组件更新的调度流程

  1. 组件实例创建时,套上了effect方法

  2. 修改响应式数据,触发effect重新执行(先调用trigger函数)

  3. effect的第二个参数中有scheduler属性,所以不会直接执行effect方法,而是将调用scheduler方法即queueJob

  4. 将组件更新effect放入queue队列中

调用forceUpdate

vue2中可以使用this.$forceUpdate强制组件重新渲染。这一API在vue3 Options API中保留了下来

// runtime-core/src/componentPublicInstance.ts

const publicPropertiesMap: PublicPropertiesMap = extend(Object.create(null), {
  // 省略...
  
  $forceUpdate: i => () => queueJob(i.update),
  
  // 省略...
} as PublicPropertiesMap)
复制代码

可以观察到,forceUpdate就是直接调用queueJob,并将effect(i.update就是effect)传入。

hmr

开发环境下,热更新也会触发组件重新渲染。也是调用queueJob将更新任务加入任务队列

入队preFlushCbs和postFlushCbs的时机

preFlushCbs存放的是组件渲染前需要完成的任务

postFlushCbs存放的是组件渲染完成后要完成的任务

watchEffect和watch

watchEffect为例,看一下它的类型声明:

function watchEffect(
  effect: (onInvalidate: InvalidateCbRegistrator) => void,
  options?: WatchEffectOptions
): StopHandle

interface WatchEffectOptions {
  flush?: 'pre' | 'post' | 'sync' // 默认:'pre'
  onTrack?: (event: DebuggerEvent) => void
  onTrigger?: (event: DebuggerEvent) => void
}
复制代码

watchEffect的第二个参数中有个flush属性,有三种取值

  • pre(默认):在组件渲染前异步调用,会将任务放入preFlushCbs队列中
  • post:在组件渲染后异步调用,会将任务放入postFlushCbs队列中
  • sync:同步调用

分析源码,来看一下具体放入队列的操作:

export function watchEffect(
  effect: WatchEffect,
  options?: WatchOptionsBase
): WatchStopHandle {
  // 调用doWatch方法
  return doWatch(effect, null, options)
}


function doWatch(
  source: WatchSource | WatchSource[] | WatchEffect | object,
  cb: WatchCallback | null,
  { immediate, deep, flush, onTrack, onTrigger }: WatchOptions = EMPTY_OBJ,
  instance = currentInstance
): WatchStopHandle {
  
  // 省略...
    
  const job: SchedulerJob = () => {
    // 省略
    // 会执行传入的回调 cb
  }

  // 赋值scheduler
  let scheduler: ReactiveEffectOptions['scheduler']
  if (flush === 'sync') {
    scheduler = job
  } else if (flush === 'post') {
    scheduler = () => queuePostRenderEffect(job, instance && instance.suspense)
  } else {
    // default: 'pre'
    scheduler = () => {
      if (!instance || instance.isMounted) {
        queuePreFlushCb(job)
      } else {
        // 第一次执行,组件未渲染的话,同步执行
        job()
      }
    }
  }

  // 创建effect
  const runner = effect(getter, {
    lazy: true, // true的话, effect第一次不会自动执行,而是直接返回effect
    onTrack,
    onTrigger,
    scheduler
  })
  
  // 省略...
}
复制代码

总结一下:

  1. 响应式都是通过effect来实现的

  2. 先赋值sheduler,有三种情况

    • flush: sync:直接赋值job,也就是直接触发effect执行
    • flush: post:赋值一个函数,函数会执行queuePostRenderEffect,即将job放入postFlushCbs队列
    • flush: pre:赋值一个函数,函数会执行queuePreRenderEffect,即将job放入preFlushCbs队列
  3. 创建effect,以实现响应式 并将scheduler传入。当响应式数据发生变化,就会调用scheduler函数

除此之外,在Vnode创建、更新和销毁的过程中,许多环节需要在组件更新完之后再去执行相应操作。vue调用了queuePostFlushCb

任务调度

现在来具体看一下这些任务进入任务队列中都做了什么:

queueJob

// runtime-core/src/schduler.ts
export function queueJob(job: SchedulerJob) {
  // 判断队列中是否已有该job,没有才会添加到任务队列中
  if (
    (!queue.length ||
      !queue.includes(
        job,
        isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex
      )) &&
    job !== currentPreFlushParentJob
  ) {
    // 找的任务在任务队列中合适的位置
    const pos = findInsertionIndex(job)
    if (pos > -1) {
      queue.splice(pos, 0, job)
    } else {
      queue.push(job)
    }
    // 清空任务队列
    queueFlush()
  }
}
复制代码

先进行重复判断。这是确保任务队列中不会出现多个相同组件的effect,防止组件重复渲染

最终调用queueFlush来执行清空任务队列(执行完所有任务)

queuePreFlushCb

// runtime-core/src/schduler.ts
function queueCb(
  cb: SchedulerCbs,
  activeQueue: SchedulerCb[] | null,
  pendingQueue: SchedulerCb[],
  index: number
) {
  if (!isArray(cb)) {
    if (
      !activeQueue ||
      !activeQueue.includes(
        cb,
        (cb as SchedulerJob).allowRecurse ? index + 1 : index
      )
    ) {
      pendingQueue.push(cb)
    }
  } else {
    pendingQueue.push(...cb)
  }
  
  // 清空任务队列
  queueFlush()
}

export function queuePreFlushCb(cb: SchedulerCb) {
  queueCb(cb, activePreFlushCbs, pendingPreFlushCbs, preFlushIndex)
}

export function queuePostFlushCb(cb: SchedulerCbs) {
  queueCb(cb, activePostFlushCbs, pendingPostFlushCbs, postFlushIndex)
}
复制代码

queuePreFlushCbqueuePostFlushCb都会再去调用queueCb函数,这个函数做两件事

  1. 将任务放入响应的任务队列(当然 先判断重复)
  2. 执行queueFlush,来清空任务队列

queueFlush

// runtime-core/src/schduler.ts
const resolvedPromise: Promise<any> = Promise.resolve()
function queueFlush() {
  if (!isFlushing && !isFlushPending) {
    isFlushPending = true
    currentFlushPromise = resolvedPromise.then(flushJobs)
  }
}

function flushJobs(seen?: CountMap) {
  // 省略...
  
  // 1. 清空preFlushCb队列
  flushPreFlushCbs(seen)
  
	// 省略...
  
  try {
    // 2. 清空queue队列
    for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
      const job = queue[flushIndex]
      if (job) {
        // 执行job,也就是前面传入的effect
        callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
      }
    }
  } finally {

    // 省略...
    
    // 3. 清空postFlushCbs队列
    flushPostFlushCbs(seen)
		// 省略...
  }
}
复制代码

清空队列主要流程:

  1. 异步执行flushJob,这里使用了resolvedPromise.then(flushJobs)。将清空队列的操作变成了异步。
  2. 清空preFlushCb队列
  3. 清空queue队列
  4. 清空postFlushCbs队列

这里的“清空”是执行队列中所有任务的意思

nextTick原理?

先看一段demo:

<template>
  <span class="my-name">name: {{name}}</span>
</template>

<script lang="ts" setup>
import { nextTick, onMounted, ref } from 'vue'

const name = ref("zxfan")

onMounted(() => {
  name.value += ' yes!'
  const dom = document.querySelector(".my-name")
  
  console.log(dom?.innerHTML); // 输出:name: zxfan
  nextTick(() => {
    console.log(dom?.innerHTML); // 输出:name: zxfan yes!
  })
})
</script>
复制代码

由于修改响应式数据,不会立即触发组件重渲染,所以第一个console.log输出的内容是上一次渲染的内容。而nexTick注册的回调会等到在渲染完毕后执行。

我们看一下nextTick是如何实现的

// runtime-core/src/schduler.ts
export function nextTick(
  this: ComponentPublicInstance | void,
  fn?: () => void
): Promise<void> {
  const p = currentFlushPromise || resolvedPromise
  return fn ? p.then(this ? fn.bind(this) : fn) : p
}
复制代码

注意到,它是等promise执行完毕后在去执行回调函数。那么这个promise是什么呢?

  • currentFlushPromise:这个promise我们刚刚遇到过,是在queueFlush中赋值的。这个promise会在本轮任务队列全部清空后才会变成fullfilled。这是再去调用nextTick的回调,就能获取到最新的dom
  • resolvedPromise:说明现在任务队列为空,那就包装一层promise并返回

要理解nextTick,还需要结合浏览器的事件循环来分析,我们先明确几个知识点:

  1. 浏览器的事件循环将事件放到两个队列——宏任务队列和**微任务队列
  2. 每一次事件循环,会优先先清空微任务队列
  3. promise属于微任务

以下面的demo为例,解释一下从修改响应式数据到nextTick中回调执行的过程

name.value += ' yes!'
nextTick(() => {
  console.log(dom?.innerHTML); // 输出:name: zxfan yes!
})
复制代码
  1. 当执行name.value += ' yes!'时,响应式数据发生了改变。
    • 触发Proxy的setter,将更新组件任务的effect放入vue任务队列中(调用queueJob)
    • 调用queueFlush,这里调用了promise.resolve,所以将flushJobs放入微任务队列
    • 此时微任务队列= [flushJobs]
  2. 接着执行nextTick。等待currentFlushPromise执行完毕
  3. 开始清空微任务队列。先执行flushJobs,执行更新组件任务的effect,这个过程会操作DOM,一旦操作DOM。浏览器会进行线程切换。由JS线程切换到渲染线程,浏览器去更新DOM。当DOM更新完毕,在切换回JS线程。回到JS线程后,currentFlushPromise状态变成fullfilled。将nextTick的回调cb放入微任务队列。此时微任务队列= [cb]
  4. 继续清空微任务队列,执行cb。此时如果在cb中访问dom,就是渲染完成后的dom内容。

总结

vue任务调度.png

Vue2中的表现

任务调度

vu2的任务调度原理也是类似。

  1. 组件的响应式数据发生改变,会触发setter方法。
  2. 触发dep.notify()
  3. 遍历dep对象上的watcher,逐一调用其update方法,(Watcher对象是在渲染组件时创建的,用来更新组件)
  4. update再调用queueWatcher,将更新任务加入任务队列
  5. 调用nextTick(flushSchedulerQueue),用来清空任务队列。nextTick是为了让组件更新变成异步。

nextTick

vue2中的nextTick比vue3多了些内容。

nextTick也模拟了一个回调任务队列callbacks(注意这不是调度的任务队列)

export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  
  // 将回调加入队列中
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  
  // 如果队列没有正在执行(flush),就去清空队列
  if (!pending) {
    pending = true
    timerFunc() // 清空队列
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}
复制代码

nextTick调用了timeFunc来清空队列

function flushCallbacks () {
  // 省略... 就是清空callbakcs任务队列
}

let timerFunc


// 1 支持promise,就用promise来模拟异步
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  timerFunc = () => {
    p.then(flushCallbacks)
   
    if (isIOS) setTimeout(noop)
  }
  isUsingMicroTask = true
} 
// 2. 不支持promise 就用MutationObserver来模拟异步
else if (!isIE && typeof MutationObserver !== 'undefined' && (
  isNative(MutationObserver) ||
  // PhantomJS and iOS 7.x
  MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
  // Use MutationObserver where native Promise is not available,
  // e.g. PhantomJS, iOS7, Android 4.4
  // (#6466 MutationObserver is unreliable in IE11)
  let counter = 1
  const observer = new MutationObserver(flushCallbacks)
  const textNode = document.createTextNode(String(counter))
  observer.observe(textNode, {
    characterData: true
  })
  timerFunc = () => {
    counter = (counter + 1) % 2
    textNode.data = String(counter)
  }
  isUsingMicroTask = true
} 

// 3.用setImmediate来模拟异步
else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {

  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  // Fallback to setTimeout.
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}
复制代码

为了更多的兼容浏览器,timerFunc模拟异步判断了三种情况

  1. 支持Promise,就直接用Promise来模拟异步。Promise属于微任务
  2. 不支持Promise,支持MutationObserver。MutationObserver也属于微任务
    • 创建一个空的文本节点,监听这个文本节点的字符变化。变化了就调用flushCallbacks来清空回调任务队列
    • 调用timerFunc,会修改文本节点的内容,从而触发flushCallbacks函数的执行
  3. MutationObserverPromise都不支持,就用setImmediate来模拟异步,后者属于宏任务
  4. 最后方案就是使用setTimeout来模拟异步

可见vue2为了兼容性,下足了功夫。而vue3直接就使用了Promise

分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改