Vue.js 源码 (13)—— 你应该了解的 nextTick

1,072 阅读3分钟

这是我参与更文挑战的第13天,活动详情查看: 更文挑战

前言

前面,我们已经大致梳理 Vue.js 里的整体流程和基本原理,接下来我们还会探究 Vue.js 实例或组件上的一些方法的原理和具体实现。

本次,我们将一起来探索 nextTick

vm.$nextTick

nextTick 接收一个回调函数作为参数,它的作用是将回调延迟到下次DOM更新周期之后执行。它与全局方法Vue.nextTick 一样,不同的是回调的this自动绑定到调用它的实例上。如果没有提供回调且在支持Promise的环境中,则返回一个 Promise

应用场景

我们在开发项目时会遇到一种场景:当更新了状态(数据)后,需要对新 DOM 做一些操作,但是这时我们其实获取不到更新后的DOM,因为还没有重新渲染。这个时候我们需要使用 nextTick 方法。

new Vue({
    methods: {
        example: function() {
            this.message = 'changed';
            this.$nextTick(function(){
                // Dom 更新了
                // this 指向当前实例
                this.doSomethingElse()
            })
        }
    }
})

在Vue.js中,当状态发生变化时,watcher 会得到通知,然后触发虚拟DOM的渲染流程。而 watcher 触发渲染这个操作并不是同步的,而是异步的。Vue.js 中有一个队列,每当需要渲染时,会将 watcher 推送到这个队列中,在下一次事件循环中再让 watcher 触发渲染的流程

为什么Vue.js使用异步更新队列

我们之前学过,Vue.js 2.0 引入了虚拟DOM,变化侦测的通知只会发给组件。那么,如果我们同时修改了组件的两个状态,是不是组件就要重新渲染两次?这显示是不合理的。

如何解决重复渲染的问题?

我们只要把 watcher 实例添加到一个队列中缓存起来,并且在添加到队列时检查是否已经添加过了,只有不存在时,才将 watcher 实例添加到队列中。在下一次事件循环中,就可以依次触发 watcher 来重新渲染了。这样一来,在一个事件循环中,某个组件的有多个状态发生变化,这个组件也只会重新渲染 1 次。

异步任务

为了实现延迟运行的效果,我们需要使用到异步任务。Javascript 中的异步任务分为两种类型:微任务宏任务

属于微任务的事件包括但不限于以下几种:

  • Promise.then()
  • MutationObserver
  • Object.observe
  • process.nextTick

属于宏任务的事件包括但不限于以下几种:

  • setTimeout
  • setInterval
  • settImmediate
  • MessageChannel
  • requestAnimationFrame
  • I/O
  • UI 交互事件

什么是执行栈

当我们执行一个方法时,JavaScript会生成一个与这个方法对应的执行环境(context),又叫执行上下文。这个执行环境中有这个方法的私有作用域、上层作用域的指向、方法的参数、私有作用域中定义的变量以及this对象。这个执行环境会被添加到一个栈中,这个栈就是执行栈。

回到前面的问题,“下次DOM更新周期” 的意思其实是下次微任务执行时更新DOM。而 vm.$nextTick 其实是将回调添加到微任务中。只有在特殊情况下才会降级成宏任务,默认会添加到微任务中。

注意 不论是更新DOM的回调还是使用 vm.$nextTick 注册的回调,都是向微任务队列中添加任务,所以哪个任务先添加到队列中,就先执行哪个任务。

nextTick 的内部注册流程和执行流程

    graph 
     subgraph 执行流程
         b[任务被执行] --> c[依次执行callbacks中的所有回调] --> d[清空callbacks]
         end
         
        subgraph 注册流程
        n[nextTick] --> s[将回调函数添加到callbacks中]
        
        s --> t{本轮事件循环<br>中第一次使用<br>nextTick?} --否--> e[结束]
        
        t --是--> a[向任务队列中添加任务] --> e
         end
const callbacks = [];
let pending = false; // 用来标记是否是本轮事件循环中第一次使用 nextTick

function flushCallbacks(){
    pending = false;
    const copies = callbacks.slice(0);
    callbacks.length = 0; // 清空 callbacks
    // 依次执行 callbacks 中注册的回调
    for(let i = 0; i < copies.length; i++){
        copies[i]()
    }
}

let microTimerFunc
const p = Promise.resolve()
// 优先使用微任务来实现异步
microTimerFunc = () => {
    p.then(flushCallbacks)
}

export function nextTick(cb, ctx){
    // 添加回调
    callbacks.push(()=>{
        if(cb) {
            cb.call(ctx)
        }
    })
    if (!pending) {
        pending = true;
        microTimerFunc()
    }
}

上面的代码,我们演示了使用微任务注册回调的方式。但是在某些终端上,使用微任务会有些问题。所以,Vue.js 提供了在特殊场合下降级成宏任务的方式。

优先级如下:

Promise.then > setImmediate > MessageChannel > setTimeout

根据终端支持的情况,优先使用左侧的。

总结

简单总结下,nextTick 是将回调注册到一个异步队列中。同样, DOM 的更新也是使用的异步队列。nextTick 默认使用微任务,特殊场合下会降级成宏任务。