likes
comments
collection
share

Vue3源码学习——nextTick原理

作者站长头像
站长
· 阅读数 19

前言

不知道大家在平时用Vue进行开发的时候,是不是经常听到一句话: 遇事不决,$nextTick。

不得不说nextTick在有些时候确实是有奇效的,特别是前几年刚刚入行的时候,总会遇到一些奇奇怪怪的问题:

  • 这里我明明赋值了啊,为什么没有拿到呢?
  • 这里我明明改了的,怎么这个框框的大小就是拿不到呢?

然后就会去问一问度娘,突然就看到了nextTick这个关键词,反手一个CV,诶,还真好了,真不错!$nextTick真香!

这一节,我们就重点来从源码层面深入的看一下这个神奇nextTick到底是如何运行的。

nextTick

先看一下官网对它的介绍:

等待下一次 DOM 更新刷新的工具方法。

当你在 Vue 中更改响应式状态时,最终的 DOM 更新并不是同步生效的,而是由 Vue 将它们缓存在一个队列中,直到下一个tick才一起执行。这样是为了确保每个组件无论发生多少状态改变,都仅执行一次更新。

nextTick() 可以在状态改变后立即使用,以等待 DOM 更新完成。你可以传递一个回调函数作为参数,或者 await 返回的 Promise。

示例

<script>
import { nextTick } from 'vue'
export default {
  data() {
    return {
      count: 0
    }
  },
  methods: {
    async increment() {
      this.count++
      
      // DOM 还未更新
      console.log(document.getElementById('counter').textContent) // 0
      
      await nextTick()
      
      // DOM 此时已经更新
      console.log(document.getElementById('counter').textContent) // 1
    }
  }
}
</script>
<template>
  <button id="counter" @click="increment">{{ count }}</button>
</template>

上面官网的例子可以很清晰的表明,在函数中对某个状态进行更新的时候,我们在修改数据之后立马获取Dom是拿不到修改后的数据的,但是在nextTick之后,则可以拿到正确的Dom数据。

接下来,我们就来看一下nextTick的源码:

// Vue3这里不再进行向下兼容,直接使用了Promise来操作异步
const resolvedPromise = /*#__PURE__*/ Promise.resolve() as Promise<any>
let currentFlushPromise: Promise<void> | null = null

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

nextTick直接相关的源码就很简单,就是给传入nextTick中的回调函数包了一层,通过Promise.resolve,将回调函数fn放在了微任务队列,实现了fn的延迟执行,从而能够顺利的拿到Dom更新后的数据。

这里,我们可以猜测,既然放入微任务队列的fn执行时可以拿到Dom更新后的数据,那么很显然Dom更新的任务一定是在nextTick创建出来的微任务之前的,那具体是怎么一回事呢?这里我们就要接着去源码中找答案了。

setupRenderEffect

这里需要提一下用于更新组件的函数setupRenderEffect

Vue3源码学习——nextTick原理

setupRenderEffect函数中主要做的事情有:

  • 定义组件更新函数componentUpdateFn,这个函数中会根据实例instance是否挂载来进行不同的操作:如果组件还未挂载,则初始化组件;已经挂载,则在这个函数中进行更新组件操作。
  • 然后将渲染函数componentUpdateFn包装成effect副作用函数,并将该副作用函数effect的执行方法run方法赋值给组件实例instance的update属性上,将组件uid作为update函数的id值(这里的id值主要用于排列effect的执行顺序)。
  • 最后执行update方法,执行更新。

这里我们可以重点关注一下创建effect的逻辑。根据上面的代码,我们可以看到,在创建ReactiveEffect实例的时候除了传入了更新组件的函数之外,还传入了 () => queueJob(update)作为第二个参数,而这第二个参数我们在ReactiveEffect中可以看到是作为实例的scheduler属性的。

class ReactiveEffect {
    ...
    constructor(fn, scheduler = null, scope) {
      this.fn = fn;
      this.scheduler = scheduler;
      this.active = true;
      this.deps = [];
      this.parent = void 0;
      recordEffectScope(this, scope);
    }
    ...
}

scheduler,翻译为调度程序,我们也就可以猜测出,作为scheduler参数传入的函数大概率是跟任务排序相关的内容,我们再在源码中搜索scheduler看看还在哪里出现了呢?

function triggerEffect(
  effect: ReactiveEffect,
  debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
  if (effect !== activeEffect || effect.allowRecurse) {
    ...
    // 如果有scheduler则执行scheduler,否则执行run方法
    if (effect.scheduler) {
      effect.scheduler()
    } else {
      effect.run()
    }
  }
}

triggerEffect函数我们在第一节有提到,主要功能是触发执行effect的函数。这里我们可以看到在执行前,会先判断effect上是否有scheduler属性,如果有则执行的是scheduler函数,没有则才会执行run方法。

queueJob

那至此,我们就明白了effect的执行机制,这会可以把目光转向queueJob:

// 任务队列
const queue = []

function queueJob(job) {
  if (!queue.length || !queue.includes(job,isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex)) {
    if (job.id == null) {
        // 将job维护在任务队列末尾
      queue.push(job)
    } else {
      // 替换掉任务队列中从flushIndex + 1开始对应id的job
      queue.splice(findInsertionIndex(job.id), 0, job)
    }
    queueFlush()
  }
}

function queueFlush() {
    // 如果任务队列的更新状态是还未开始更新
  if (!isFlushing && !isFlushPending) {
      // 将更新状态修改为pending
    isFlushPending = true
    // 将flushJobs维护进微任务队列
    currentFlushPromise = resolvedPromise.then(flushJobs)
  }
}

其中有几个变量这里解释一下:

  • queue表示 维护的任务队列。
  • job表示 我们维护进队列中的任务,就像上面我们提到的update函数。
  • isFlushing表示 队列正处于更新状态中。
  • allowRecurse表示 允许递归调用。

queueJob函数的整体逻辑:

  • 首先,我们根据条件进行判断,如果任务队列queue为空或者在当前队列中无法搜索到job(这里会根据是否允许递归调用自身来决定查询的起始点),满足条件的任务job才会被维护进queue数组中。
  • 然后,根据传入的job是否有id,没有id则将job维护在队列末尾,如果有id,则替换掉queue中相应的job
  • 最后,判断任务队列没有被执行的话,则将flushJobs函数添加到微任务中,并将该Promise赋值给currentFlushPromise

flushJobs

接着,我们再看被推入微任务队列的这个flushJobs函数具体是做什么的:

function flushJobs(seen) {
    // 将更新状态修改为正在更新
    isFlushPending = false;
    isFlushing = true;
    
    // 主要用于记录传入的job次数
    seen = seen || /* @__PURE__ */ new Map();
    
    //根据Job的id不同进行排序,如果相同则根据是否有pre属性进行排序
    //(这里pre属性主要是在watch时会赋予)
    queue.sort(comparator);
    
    // checkRecursiveUpdates函数用于维护传入的job出现次数,上限为100次,超出则会在控制台中给出警告
    const check = (job) => checkRecursiveUpdates(seen, job)
    
    try {
      // 遍历并执行任务队列中的每个job
      for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
        const job = queue[flushIndex];
        if (job && job.active !== false) {
          if (check(job)) {
            continue;
          }
          callWithErrorHandling(job, null, 14 /* SCHEDULER */);
        }
      }
    } finally {
     // 任务队列执行完,重置任务队列索引
      flushIndex = 0;
      // 清空任务队列
      queue.length = 0;
      // 执行后置任务队列
      flushPostFlushCbs(seen);
      // 复原更新工作状态为false
      isFlushing = false;
      // 重置当前微任务变量字段为null
      currentFlushPromise = null;
      // 如果主任务队列、后置任务队列还有没被清空,就继续递归执行
      if (queue.length || pendingPostFlushCbs.length) {
        flushJobs(seen);
      }
    }
  }
  
const comparator = (a: SchedulerJob, b: SchedulerJob): number => {
  const diff = getId(a) - getId(b)
  if (diff === 0) {
    if (a.pre && !b.pre) return -1
    if (b.pre && !a.pre) return 1
  }
  return diff
}

flushJobs函数在做的事情注释里写的很清楚了,整体流程就是:

  • 首先,修改状态,将任务队列queue进行排序。
  • 然后执行任务队列queue中的每个任务job
  • 最后,执行后置任务队列,重置相关变量及状态。

到这里,我们其实就可以发现,Vue3任务队列整体的更新策略就是:

  • 首先,更新job中带有pre属性的,这个是在queue.sort排序的时候将带有pre属性的job排在前面体现。
  • 然后,执行那些普通的job
  • 最后,在finally中会执行flushPostFlushCbs函数去处理后置任务队列中的job

总结

至此,我们大概就了解了Vue3中任务队列的运行机制。我们再来看nextTick:

在nextTick函数中,我们注意到有这样一行代码:

const p = currentFlushPromise || resolvedPromise
return fn ? p.then(this ? fn.bind(this) : fn) : p

我们可以发现,nextTick中的函数会在currentFlushPromisethen回调中才会执行,换句话也就是说,这样就确保了currentFlushPromise中的内容是要比nextTick中的函数先执行的。

然后,在组件更新函数setupRenderEffect中,我们关注到这个函数把更新函数componentUpdateFn包装为副作用函数effect并将effect的执行函数通过queueJob函数维护进任务队列queue

最后在queueFlush函数中,我们就发现了currentFlushPromise被赋值

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

这里将任务队列的执行函数包装为了一个Promise,并赋值给了currentFlushPromise。而这个任务队列中,就包含有组件更新函数。所以也就确保了,nextTick中的函数,一定是等待Dom更新完毕之后才去执行的,也就能拿到正确的Dom数据了

参考文章:

官网

大佬的小册

转载自:https://juejin.cn/post/7243688914928631864
评论
请登录