其实vue3中的nextTick原理很简单

652 阅读5分钟

在vue中,当我们需要操作dom时,那就需要在dom挂载之后才能去操作,通常我们只要使用nextTick,在nextTick的回调中必定可以拿到挂载后的dom,vue内部是如何做的呢?

要了解这个原理必须要了解js中的宏任务和微任务的概念

举例:

<script>
function a(){
   console.log('我是函数a,我来了')
}



function b(){
   console.log('我是函数b,我来了')
}

function c(){
   console.log('我是函数c,我来了')
}

setTimeout(() => {
   b()
})

Promise.resolve().then(() => {
    c()
});
a()
<script />

// 执行结果:
我是函数a,我来了 
我是函数c,我来了
我是函数b,我来了

事件循环中的任务类型

  1. 宏任务(MacroTask)

    1. setTimeout/setInterval
    2. requestAnimationFrame
    3. I/O操作
    4. setImmediate(Node.js环境)
    5. script标签中的整体代码
  2. 微任务(MicroTask)

    1. Promise.then
    2. process.nextTick(Node.js环境)
    3. MutationObserver

执行顺序规则

如上例子script标签的整体代码是一个宏任务,首先同步执行script标签中的代码,遇到异步宏任务就添加进宏任务队列,遇到异步微任务就添加进微任务队列,等到script标签中的所有同步代码执行完毕之后,就会从当前宏任务的微任务队列中取出微任务进行执行,微任务队列清空后则从宏任务队列中取出宏任务(属于下一个事件循环)进行压栈处理

  1. 执行同步代码(这属于第 一个宏任务)
  2. 执行完所有微任务
  3. 执行下一个宏任务

为什么b最后执行?

这是因为setTimeout是一个宏任务(Macro Task)。当代码执行到setTimeout时,它会将任务添加到宏任务队列中,等到当前执行栈中的所有同步代码执行完毕后,才会从宏任务队列中取出任务执行。因此,b的执行被延后到最后。

为什么a先执行,而c后执行?

  • a是普通的同步代码,直接在当前执行栈中执行,因此最先输出“我是函数a,我来了”。
  • Promise.resolve().then(() => { c(); })是一个微任务(Micro Task)。微任务的执行时机是在当前宏任务(即整个<script>代码块)执行完毕后、进入下一个宏任务之前。所以,c的执行紧接着同步代码完成后进行。

注:每次创建一个宏任务的时候都会创建一个微任务队列,在执行完一个宏任务前会清空当前宏任务内的微任务队列

当代码执行到第23行时,js事件循环机制如下图所示

image.png

a()函数执行完毕之后出栈,取出当前宏任务的微任务压栈进行执行

image.png

等到微任务都清空之后,当前栈为空,所以此时v8会将宏任务队列中的任务取出来进行压栈处理

image.png

最后完成b函数的调用

好了,了解了上述的事件循环机制的宏任务和微任务的概念之后,我们继续了解nextTick的一个实现,在vue中,使用nextTick的原理非常的简洁

const p = Promise.resolve();
export function nextTick(fn?) {
  return fn ? p.then(fn) : p;
}

看了上面的例子之后看这个觉得非常简单,因为本质就是一个微任务。

那这样我们在vue的 代码中直接使用promise.then来实现我们要获取dom的需求不就好了吗?为什么还需要使用vue内部提供的nextTick呢?

虽然直接使用promise.then和nextTick本质都是微任务,但是我们上面的例子讲解了微任务的执行时机是在当前宏任务执行结束之前执行,那么你直接在promise.then回调中和nextTick回调中执行所处的宏任务是不一样的。所以最终执行的效果会不一样,nextTick中的回调能准确获取到已挂载的dom,但是promise.then则不一定

为了更详细的了解nextTick回调中为什么能准确获取到已挂载的dom,我们来看看vue中是如何实现的

  function setupRenderEffect(instance, initialVNode, container) {

    function componentUpdateFn() {
      ....
    }

    instance.update = new ReactiveEffect(componentUpdateFn, {
      scheduler: () => {
        // 把 effect 推到微任务的时候在执行
        queueJob(instance.update);
      },
    });
  }
const queue: any[] = [];
const p = Promise.resolve();

export function queueJob(job) {
  if (!queue.includes(job)) {
    queue.push(job);
    // 执行所有的 job
    queueFlush();
  }
}

function queueFlush() {
  nextTick(flushJobs);
}

export function nextTick(fn?) {
  return fn ? p.then(fn) : p;
}

setupRenderEffect 函数的作用:

  • 为每个组件创建一个渲染 effect
  • 当组件需要更新时,不会立即执行更新,而是通过 scheduler 调度器将更新任务(instance.update)推入队列

queueJob 函数的职责:

  • 接收一个更新任务(job)
  • 确保同一个任务不会重复入队(去重处理)
  • 将任务推入队列后,调用 queueFlush 来安排任务的执行

当组件中的响应式变量发生变化之后,会触发scheduler调度器,scheduler调度器会调用queueJob方法,会将当前更新任务添加进队列中,等待当前宏任务执行完毕之前执行,当前宏任务就是组件更新之后触发的重新渲染,所以在这个宏任务结束之前会去执行nextTick的回调,就能准确拿到dom节点了

所以本质上,nextTick 的回调微任务会在 DOM 更新(即重新渲染)当前这个宏任务结束之前执行,因此在执行 nextTick 的回调时,可以确保 DOM 已经完成更新并能被准确获取。