vue3 watchEffect的执行时机

400 阅读5分钟

本文主要针对watchEffect,watchPostEffect,watchSyncEffect的执行时机从源码的角度去分析他的执行过程。

本文主要说明三个函数的执行时机。对vue响应式不做赘述。有兴趣可以查看另外一篇文章。

本文使用下面的例子去说明


<template>
    <div>
        <span id="123456"> {{ data1 }}</span>
        <button @click="add">add</button>
    </div>
</template>
  
<script setup lang="ts">
import {
    nextTick,
    ref,
    watchEffect,
    watchPostEffect,
    watchSyncEffect,
} from "vue";
interface Iprops { }

// const props = defineProps<Iprops>();

const data1 = ref({ a: 1 });

const add = () => {
    data1.value.a++;
};

watchEffect(() => {
    data1.value.a;
    console.log("watchEffect", document.getElementById("123456")?.textContent);
});

watchPostEffect(() => {
    data1.value.a;
    console.log("watchPostEffect", document.getElementById("123456")?.textContent);
});

watchSyncEffect(() => {
    data1.value.a;
    console.log("watchSyncEffect", document.getElementById("123456")?.textContent);
});
</script>
<style lang="scss" scoped></style>

该程序当点击按钮的时候执行的结果如下:

截屏2024-07-14 16.04.08.png

从结果可以看出来执行的时机是watchSyncEffectwatchEffectwatchPostEffect,并且只有watchPostEffect可以看到最新的渲染结果,其他两个都是旧的。

对于vue的内部实现来说。这三个函数在执行的时候都会创建一个Effect,当执行对应的回调函数的时候。 会去收集依赖,当依赖更新的时候会重新触发。所以当组件挂载完成之后,依赖也有收集完成了。 下面是当组件挂载之后 data1.value.a收集的依赖。

截屏2024-07-14 16.12.49.png

一共有四个依赖,分别是

  1. watchEffect的Effect。
  2. watchSyncEffect的effect。
  3. 当前组件的update函数对应的Effect。
  4. watchPostEffect的Effect

可以看出来函数更新的Effect在watchEffectwatchSyncEffect两个的后面。是因为当前组件的setup函数执行的时候。还没有调用render函数去生成最新的虚拟dom。而在调用当前组件的setup函数的时候才会去执行watchEffect依赖的收集。所以update依赖的收集的会比前两个慢。如下代码


 const mountComponent: MountComponentFn = (
    initialVNode,
    container,
    anchor,
    parentComponent,
    parentSuspense,
    namespace: ElementNamespace,
    optimized,
  ) => {

    const compatMountInstance =
      __COMPAT__ && initialVNode.isCompatRoot && initialVNode.component
     // 创建当前组件的实例对象。
    const instance: ComponentInternalInstance =
      compatMountInstance ||
      (initialVNode.component = createComponentInstance(
        initialVNode,
        parentComponent,
        parentSuspense,
      ))

    if (!(__COMPAT__ && compatMountInstance)) {
    // 调用当前组件的setup函数去获取当前组件向外暴露的数据。
      setupComponent(instance)
    }

    if (__FEATURE_SUSPENSE__ && instance.asyncDep) {
     /////
    } else {
    // 创建当前组件的update函数。
      setupRenderEffect(
        instance,
        initialVNode,
        container,
        anchor,
        parentSuspense,
        namespace,
        optimized,
      )
    }
  }
  
  
  export function setupComponent(
  instance: ComponentInternalInstance,
  isSSR = false,
) {

  const { props, children } = instance.vnode
  const isStateful = isStatefulComponent(instance)
  initProps(instance, props, isStateful, isSSR)
  initSlots(instance, children)

  // setupStatefulComponent函数的作用就是调用组件的set方法。如下截图
  const setupResult = isStateful
    ? setupStatefulComponent(instance, isSSR)
    : undefined

  return setupResult
}

const setupRenderEffect: SetupRenderEffectFn = (
    instance,
    initialVNode,
  ) => {
    const componentUpdateFn = () => {
      if (!instance.isMounted) {
        let vnodeHook: VNodeHook | null | undefined
        const { el, props } = initialVNode
        const { bm, m, parent } = instance
        if (bm) {
          invokeArrayFns(bm)
        }
        // 调用render函数去生成虚拟dom对象
         const subTree = (instance.subTree = renderComponentRoot(instance))
          patch(
            null,
            subTree,
            container,
            anchor,
            instance,
            parentSuspense,
            namespace,
          )  
          initialVNode.el = subTree.el
        if (m) {
          queuePostRenderEffect(m, parentSuspense)
        }
        // 当前组件挂载成功之后设置isMounted为true
        instance.isMounted = true

      } else {
        let { next, bu, u, parent, vnode } = instance
        }
        let originNext = next
        let vnodeHook: VNodeHook | null | undefined

        if (next) {
          next.el = vnode.el
          updateComponentPreRender(instance, next, optimized)
        } else {
          next = vnode
        }

        if (bu) {
          invokeArrayFns(bu)
        }

        const nextTree = renderComponentRoot(instance)
        const prevTree = instance.subTree
        instance.subTree = nextTree
        
        // 递归patch虚拟dom
        patch(
          prevTree,
          nextTree,
          hostParentNode(prevTree.el!)!,
          getNextHostNode(prevTree),
          instance,
          parentSuspense,
          namespace,
        )
        next.el = nextTree.el
      }
    }

    // 创建当前组件更新函数对应的effect
    const effect = (instance.effect = new ReactiveEffect(
      componentUpdateFn,
      NOOP,
      () => queueJob(update),
      instance.scope, 
    ))

    const update: SchedulerJob = (instance.update = () => {
      if (effect.dirty) {
        effect.run()
      }
    })
    update.id = instance.uid
    update()
  }

截屏2024-07-14 16.23.29.png

从右边的执行栈和左边的日志可以看出当前组件暴露的那些方法和函数。当调用setup函数的时候。会调用里面的watchEffect函数。所以里面的watchEffect三个函数的执行时机比update的要早。因此是里面的函数先收集,update函数是最后收集的。

但是为什么watchPostEffect在最后呢?

是因为在watchPostEffect的内部。他不是一开始就直接执行的,而是会进入一个调度队列里面如下:

  if (cb) {
    if (immediate) {
      job()
    } else {
      oldValue = effect.run()
    }
  } else if (flush === 'post') {
    queuePostRenderEffect(
      effect.run.bind(effect),
      instance && instance.suspense,
    )
  } else {
    effect.run()
  }

cb是watch函数的第二个参数。 watchEffect函数没有,所以直接走else。可以看到如果不是post类型的话是直接执行的。但是如果是post类型就进入到queuePostRenderEffect函数,在合适的时机去调度执行。这个合适的时机就是当前的渲染队列执行完成之后在去执行。所以当update函数执行完成之后。去调用 watchPostEffect,因此他是最后一次调用的。所以他是最后才被收集的。

当调用add的时候会把a的值++。会依次执行a收集到的所有的effect函数。

vue内部会维护两个队列。一个是渲染队列queue。另外一个是渲染完成之后的队列pendingPostFlushCbspendingPostFlushCbs会在queue队列执行完成之后再去运行。

quene的执行时机是在下一次的事件循环的微任务队列里面执行的,如下:

const resolvedPromise =  Promise.resolve()

export function queueJob(job: SchedulerJob) {
  if (
    !queue.length ||
    !queue.includes(
      job,
      isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex,
    )
  ) {
    if (job.id == null) {
      queue.push(job)
    } else {
      queue.splice(findInsertionIndex(job.id), 0, job)
    }
    queueFlush()
  }
}

function queueFlush() {
  if (!isFlushing && !isFlushPending) {
    isFlushPending = true
    //当resolvedPromise的状态是solve的时候才回去触发他的回调函数。
    currentFlushPromise = resolvedPromise.then(flushJobs)
  }
}

  if (flush === 'sync') {
    scheduler = job as any 
  } else if (flush === 'post') {
    scheduler = () => queuePostRenderEffect(job, instance && instance.suspense)
  } else {
    job.pre = true
    if (instance) job.id = instance.uid
    scheduler = () => queueJob(job)
  }

从watch的三种不同的scheduler可以看出来。

watchEffect的执行是通过调用queueJob把当前的执行放入渲染队列。 watchSyncEffect是在当前事件循环中同步执行的。 watchPostEffect是在pendingPostFlushCbs队列中执行的。pendingPostFlushCbs队列执行必须在所有的渲染任务执行完成之后同步执行。

从以上分析可以得出。当一次执行a属性对应的依赖的时候。 只有watchSyncEffect会在当前事件循环里面执行。其他的三个都需要在事件循环的下一次微任务里面执行。因为在异步执行的时候只会打印watchSyncEffect的log语句。并且因为此时还没有执行update所以获取到的dom是没有更新之前的。如下

截屏2024-07-14 16.56.42.png

此时quene队列里面还有两个任务。如下

  1. watchEffect函数的Effect执行
  2. 当前组件的update

截屏2024-07-14 17.00.21.png

执行完成watchEffect对应的job,打印对应的日志如下:

截屏2024-07-14 17.03.39.png 执行完成update之后组件完成更新如下:

截屏2024-07-14 17.04.34.png 当所有的quene任务执行完成之后。会通过flushPostFlushCbs函数去执行pendingPostFlushCbs队列里面的任务。如下:

截屏2024-07-14 17.06.31.png 可以看到watchPostEffect已经执行。并且获取到的是最新的dom信息。

总结: watchEffect的执行时机是在下一次事件循环的微任务队列里面执行的。并且执行的时机是在update的前面。 watchSyncEffect的执行时机是在当前事件循环里面同步执行的。 watchPostEffect是在下一次事件循环的微任务队列里面执行。并且是在所有更新任务完成之后才能执行。所以可以看到最新的dom更新。

另外。watchPostEffect有点类似react的useLayoutEffectwatchEffect类似useEffect