Vue3侦听器和异步任务调度, 其中有个神秘角色

311 阅读8分钟
Vue 3.0 系列文章

Vue 3.0组件的渲染流程

Vue 3.0组件的更新流程和diff算法详解

揭开Vue3.0 setup函数的神秘面纱

Vue 3.0 Props的初始化和更新流程的细节分析

Vue3.0 响应式实现原理分析

Vue 3.0 计算属性的实现原理分析

Vue3.0 常用响应式API的使用和原理分析(一)

Vue3.0 常用响应式API的使用和原理分析(二)

Vue 3.0 Provide和Inject实现共享数据

Vue 3.0 Teleport的使用和原理分析

Vue3侦听器和异步任务调度, 其中有个神秘角色

Vue3.0 指令

Vue3.0 内置指令的底层细节分析

Vue3.0 的事件绑定的实现逻辑是什么

Vue3.0 的双向绑定是如何实现的

Vue3.0的插槽是如何实现的?

探究Vue3.0的keep-alive和动态组件的实现逻辑

Vuex 4.x

Vue Router 4 的使用,一篇文章给你讲透彻

侦听器的实现逻辑

我们先来看看一个最简单的使用方式(watch的使用方式非常灵活,我们通过简单的使用方式来了解流程):

let disabled = ref(false);

let unwatch = watch(disabled, (value, oldValue, oninvalidate) => {
  console.log(oldValue);
  console.log(value);
  nextTick(() => {
      console.log("hoho");
  });
})

先思考问题:

  1. 参数value是新值,oldValue旧值, 如何实现对disabled进行求值的封装,以及旧值oldValue是如何保存的?
  2. 侦听器也是响应式的API,那disabled的依赖收集和依赖分发是如何实现的(即值的变化是怎么被监听到的)?
  3. unwatch是一个取消监听的函数,内部的实现逻辑是什么?
对监听数据求值的实现

逻辑在doWatch方法中:

所有支持的的watch数据都被封装成了对应的求值函数。

<!-- doWatch -->
function doWatch(
  source: WatchSource | WatchSource[] | WatchEffect | object,
  cb: WatchCallback | null,
  { immediate, deep, flush, onTrack, onTrigger }: WatchOptions = EMPTY_OBJ
): WatchStopHandle {
  // 1. 求值函数的封装
  let getter: () => any
  if (isRef(source)) {
    getter = () => source.value
  } else if (isReactive(source)) {
    getter = () => source
  } else if (isArray(source)) {
    // 省略...
  } else if (isFunction(source)) {
    // 省略......
  } else {
    getter = NOOP
  }
}

函数中有一个oldValue变量,每次求新值后都会保存在它上面,作为下一次求值的旧值。 第一次监听的时候会调用求新值的函数,这样数据变化后就知道了最开始的值

<!-- doWatch -->
let oldValue = isMultiSource ? [] : {}

// 计算新值
const newValue = effect.run() // 等同于调用getter函数
oldValue = newValue
数据响应式的实现

目前只需要看下面的第二段代码,第一段代码后面会介绍

<!-- doWatch -->
let scheduler: EffectScheduler
if (flush === 'sync') {
  scheduler = job as any
} else if (flush === 'post') {
  scheduler = () => queuePostRenderEffect(job, instance && instance.suspense)
} else {
  // default: 'pre'
  scheduler = () => {
    if (!instance || instance.isMounted) {
      queuePreFlushCb(job)
    } else {
      job()
    }
  }
}

// 2.
const effect = new ReactiveEffect(getter, scheduler)
effect.run()

和组件的副作用渲染函数一样,侦听器也是基于ReactiveEffect

  1. ReactiveEffectrun方法执行会调用getter函数,我们的例子中会调用disabledvalue方法从而触发依赖收集,我们的例子中收集的就是effect对象;
  2. 当数据disabled变化后会触发依赖分发,会找到effect对象,执行它里面的scheduler方法,scheduler方法又会调用getter方法计算新值然后返回。这些操作同时进行了又一次收集依赖,等待下一次的数据变化。

如果对响应式的 依赖收集依赖分发 有疑问的同学可以参考一下其他的文章。

问题:如果一个数据即被 监听器监听,也被使用在了组件模板中,那 组件的副作用渲染函数监听器函数 哪个会被先执行?

答案是 监听器函数 ,因为监听器是在setup函数中调用的,所以是先收集的 监听器函数

取消监听的实现
<!-- doWatch -->
return () => {
  effect.stop()
  if (instance && instance.scope) {
    remove(instance.scope.effects!, effect)
  }
}
  1. effect.stop()的主要作用是将effect对象 从 监听数据的 依赖列表中移除,这样监听数据变化后就不会再触发 getter函数了;此外将effect对象置为 未激活,未激活的effect对象也是不能触发getter函数的,所以是双保险;
  2. effect对象从组件作用域中移除;

任务调度的实现

在理解任务调度之前我们先来了解一些重要的概念:

  • JS是单线程的,所有的JS代码执行在JS引擎线程中;
  • 浏览器是多线程的,除了JS引擎线程还有UI渲染线程,网络IO线程等;
  • 由于JS执行在一个线程中,所以一次只能执行一个任务,如果有多个任务,就必须排队,前面一个任务完成,再执行后面一个任务;
  • 同步任务 是指主进程中一个个按顺序执行的任务,如果某个任务执行时间久,那后面的任务就等待执行;
  • 异步任务 先不进入主线程,而先进入任务队列,只有主线程空闲了,且异步任务可以执行了,这些任务才会进入主线程,也是按照先后顺序执行;
  • JS操作DOM是同步任务,浏览器渲染DOM是异步任务(因为js引擎线程GUI渲染线程线程间是互斥的);
  • 异步任务分为 微任务宏任务, 微任务优先执行,所有的微任务执行完成后再执行宏任务
  • 微任务promise等,宏任务setTimeout等;
触发组件渲染的入口逻辑
const effect = new ReactiveEffect(
  componentUpdateFn,
  () => queueJob(instance.update),
  instance.scope
)

const update = (instance.update = effect.run.bind(effect) as SchedulerJob)

前面提到过 组件的副作用渲染函数 是基于ReactiveEffect: 组件模板的数据变化后,会触发() => queueJob(instance.update)函数(本质就是ReactiveEffectrun方法),然后最终会调用componentUpdateFn函数执行组件的挂载或者更新,从而更新DOM。

  • queueJob 的逻辑
<!-- scheduler.ts -->
// 组件渲染任务数组
const queue: SchedulerJob[] = []

// 组件渲染任务队列执行函数
export function queueJob(job: SchedulerJob) {
  // 省略其他
  if (job.id == null) {
    queue.push(job)
  } else {
    queue.splice(findInsertionIndex(job.id), 0, job)
  }
  queueFlush()
}

Vue 维护了一个queue队列,用于保存需要执行的 副作用渲染函数ReactiveEffectrun方法。 queueJob 就是将 副作用渲染函数 添加到队列中合适的位置,然后执行 queueFlush方法。

queueJob

queueFlush方法我们先忽略,我们回到侦听器的相关逻辑中。

侦听器的侦测的数据变化后的的逻辑

我们继续看上面提到的一段代码。

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'
  scheduler = () => {
    if (!instance || instance.isMounted) {
      queuePreFlushCb(job)
    } else {
      // with 'pre' option, the first call must happen before
      // the component is mounted so it is called synchronously.
      job()
    }
  }
}

我们可以通过flush参数来指定侦听器的执行顺序,有sync,postpre(默认) 这三种方式。同步很好理解我们不讨论,我们主要来研究queuePostRenderEffectqueuePreFlushCb这两个方法。

  • queuePostRenderEffect在大多数情况下等同于queuePostFlushCb函数:
<!-- render.ts -->
export const queuePostRenderEffect = __FEATURE_SUSPENSE__
  ? queueEffectWithSuspense
  : queuePostFlushCb

所以我们来看看queuePostFlushCbqueuePreFlushCb的逻辑:

<!-- scheduler.ts -->
// 更新DOM前的两个callback队列
const pendingPreFlushCbs: SchedulerJob[] = []
let activePreFlushCbs: SchedulerJob[] | null = null

// 更新DOM前的两个callback队列
const pendingPostFlushCbs: SchedulerJob[] = []
let activePostFlushCbs: SchedulerJob[] | null = null

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

export function queuePostFlushCb(cb: SchedulerJobs) {
  queueCb(cb, activePostFlushCbs, pendingPostFlushCbs, postFlushIndex)
}


function queueCb(
  cb: SchedulerJobs,
  activeQueue: SchedulerJob[] | null,
  pendingQueue: SchedulerJob[],
  index: number
) {
  // 省略其他
  pendingQueue.push(cb)
  queueFlush()
}
  1. Vue 维护了DOM更新前需要执行的回调函数执行队列pendingPreFlushCbsactivePreFlushCbs;
  2. Vue 维护了DOM更新后需要执行的回调函数执行队列pendingPostFlushCbsactivePostFlushCbs;
  3. queuePostFlushCbqueuePreFlushCb分别是吧对应的回调方法加到pendingPostFlushCbspendingPreFlushCbs队列中;
  4. 然后执行queueFlush函数。

queuePostFlushCb和queuePreFlushCb

queueFlush执行异步调用

不管是DOM更新还是监听器的监听到数据后的回调都是进入了queueFlush,我们来看看它的实现逻辑。

const resolvedPromise: Promise<any> = Promise.resolve()

function queueFlush() {
  if (!isFlushing && !isFlushPending) {
    isFlushPending = true
    currentFlushPromise = resolvedPromise.then(flushJobs)
  }
}

queueFlush 使用了微任务的Promise执行异步执行flushJobs。且用isFlushPending控制flushJobs的执行时机。

flushJobs清空所有任务
function flushJobs(seen?: CountMap) {

  isFlushPending = false
  isFlushing = true
    
  // 1. 依次执行所有的所有的回调函数
  flushPreFlushCbs(seen)

  // 2. 对副作用渲染函数排序,然后依次执行所有的副作用渲染函数
  queue.sort((a, b) => getId(a) - getId(b))

  try {
    for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
      const job = queue[flushIndex]
      callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
    }
  } finally {
    // 3. 依次执行所有的所有的回调函数
    flushPostFlushCbs(seen)

    isFlushing = false
    currentFlushPromise = null
    
    // 4. 如果有新的回调函数添加进来,继续一个 1,2,3 的执行流程
    if (
      queue.length ||
      pendingPreFlushCbs.length ||
      pendingPostFlushCbs.length
    ) {
      flushJobs(seen)
    }
  }
}

flushJobs 的作用是清空所有任务:

  1. flushPreFlushCbs依次清空所有DOM更新前的回调函数:1). 先将pendingPreFlushCbs中的所有数据拷贝到activePreFlushCbs中,pendingPreFlushCbs置空等待新的回调函数加入;2). 依次执行activePreFlushCbs中的回调函数;
  2. callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)依次清空所有更新DOM的副作用渲染函数;
  3. flushPostFlushCbs依次清空所有DOM更新后的回调函数:1). 先将pendingPostFlushCbs中的所有数据拷贝到activePostFlushCbs中,pendingPostFlushCbs置空等待新的回调函数加入;2).依次执行activePostFlushCbs中的回调函数;

flushJobs

神秘的nextTick

nextTick异常神秘,遇到DOM的操作问题可能就想到它了。其实非常简单

export function nextTick<T = void>(
  this: T,
  fn?: (this: T) => void
): Promise<void> {
  const p = currentFlushPromise || resolvedPromise
  return fn ? p.then(this ? fn.bind(this) : fn) : p
}

异常简单,其实就是对Promise调用then方法。

Promise.resolve().then(() => {
  // 清空任务(包括更新DOM)
}).then(() => {
  // 是不是可以获取到更新后的DOM了??
})

不那么神秘的forceUpdate

forceUpdate: i => () => queueJob(i.update)

现在也不需要我解释这个方法的作用了。