美文网首页
对vue.nextTick的理解

对vue.nextTick的理解

作者: Splendid飞羽 | 来源:发表于2020-08-05 11:04 被阅读0次

    Vue源码解析之nextTick

    前言

    nextTick是Vue的一个核心功能,在Vue内部实现中也经常用到nextTick。但是,很多新手不理解nextTick的原理,甚至不清楚nextTick的作用。

    那么,我们就先来看看nextTick是什么。

    nextTick功能

    看看官方文档的描述:

    在下次 DOM 更新循环结束之后执行延迟回调。在修改数据之后立即使用这个方法,获取更新后的 DOM。

    再看看官方示例:

    // 修改数据
    vm.msg = 'Hello'
    // DOM 还没有更新
    Vue.nextTick(function () {
      // DOM 更新了
    })
    
    // 作为一个 Promise 使用 (2.1.0 起新增,详见接下来的提示)
    Vue.nextTick()
      .then(function () {
        // DOM 更新了
      })
    
    

    2.1.0 起新增:如果没有提供回调且在支持 Promise 的环境中,则返回一个 Promise。请注意 Vue 不自带 Promise 的 polyfill,所以如果你的目标浏览器不原生支持 Promise (IE:你们都看我干嘛),你得自己提供 polyfill。

    可以看到,nextTick主要功能就是改变数据后让回调函数作用于dom更新后。很多人一看到这里就懵逼了,为什么需要在dom更新后再执行回调函数,我修改了数据后,不是dom自动就更新了吗?

    这个和JS中的Event Loop有关,网上教程不计其数,在此就不再赘述了。建议明白Event Loop后再继续向下阅读本文。

    举个实际的例子:

    我们有个带有分页器的表格,每次翻页需要选中第一项。正常情况下,我们想的是点击翻页器,向后台获取数据,更新表格数据,操纵表格API选中第一项。
    但是,你会发现,表格数据是更新了,但是并没有选中第一项。因为,你选中第一项时,虽然数据更新了,但是DOM并没有更新。此时,你可以使用nextTick,在DOM更新后再操纵表格第一项的选中。

    那么,nextTick到底做了什么了才能实现在DOM更新后执行回调函数?

    源码分析

    nextTick的源码位于src/core/util/next-tick.js,总计118行,十分的短小精悍,十分适合初次阅读源码的同学。

    nextTick源码主要分为两块:

    1.能力检测
    2.根据能力检测以不同方式执行回调队列

    1、能力检测

    这一块其实很简单,众所周知,Event Loop分为宏任务(macro task)以及微任务( micro task),不管执行宏任务还是微任务,完成后都会进入下一个tick,并在两个tick之间执行UI渲染。

    但是,宏任务耗费的时间是大于微任务的,所以在浏览器支持的情况下,优先使用微任务。如果浏览器不支持微任务,使用宏任务;但是,各种宏任务之间也有效率的不同,需要根据浏览器的支持情况,使用不同的宏任务。

    nextTick在能力检测这一块,就是遵循的这种思想。

    // Determine (macro) task defer implementation.
    // Technically setImmediate should be the ideal choice, but it's only available
    // in IE. The only polyfill that consistently queues the callback after all DOM
    // events triggered in the same loop is by using MessageChannel.
    /* istanbul ignore if */
    // 如果浏览器不支持Promise,使用宏任务来执行nextTick回调函数队列
    // 能力检测,测试浏览器是否支持原生的setImmediate(setImmediate只在IE中有效)
    if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
      // 如果支持,宏任务( macro task)使用setImmediate
      macroTimerFunc = () => {
        setImmediate(flushCallbacks)
      }
      // 同上
    } else if (typeof MessageChannel !== 'undefined' && (
      isNative(MessageChannel) ||
      // PhantomJS
      MessageChannel.toString() === '[object MessageChannelConstructor]'
    )) {
      const channel = new MessageChannel()
      const port = channel.port2
      channel.port1.onmessage = flushCallbacks
      macroTimerFunc = () => {
        port.postMessage(1)
      }
    } else {
      /* istanbul ignore next */
      // 都不支持的情况下,使用setTimeout
      macroTimerFunc = () => {
        setTimeout(flushCallbacks, 0)
      }
    }
    
    

    首先,检测浏览器是否支持setImmediate,不支持就使用MessageChannel,再不支持只能使用效率最差但是兼容性最好的setTimeout了。

    之后,检测浏览器是否支持Promise,如果支持,则使用Promise来执行回调函数队列,毕竟微任务速度大于宏任务。如果不支持的话,就只能使用宏任务来执行回调函数队列。

    2、执行回调函数队列

    执行回调函数队列的代码刚好在一头一尾

    // 回调函数队列
    const callbacks = []
    // 异步锁
    let pending = false
    
    // 执行回调函数
    function flushCallbacks () {
      // 重置异步锁
      pending = false
      // 防止出现nextTick中包含nextTick时出现问题,在执行回调函数队列前,提前复制备份,清空回调函数队列
      const copies = callbacks.slice(0)
      callbacks.length = 0
      // 执行回调函数队列
      for (let i = 0; i < copies.length; i++) {
        copies[i]()
      }
    }
    
    ...
    
    // 我们调用的nextTick函数
    export function nextTick (cb?: Function, ctx?: Object) {
      let _resolve
      // 将回调函数推入回调队列
      callbacks.push(() => {
        if (cb) {
          try {
            cb.call(ctx)
          } catch (e) {
            handleError(e, ctx, 'nextTick')
          }
        } else if (_resolve) {
          _resolve(ctx)
        }
      })
      // 如果异步锁未锁上,锁上异步锁,调用异步函数,准备等同步函数执行完后,就开始执行回调函数队列
      if (!pending) {
        pending = true
        if (useMacroTask) {
          macroTimerFunc()
        } else {
          microTimerFunc()
        }
      }
      // $flow-disable-line
      // 2.1.0新增,如果没有提供回调,并且支持Promise,返回一个Promise
      if (!cb && typeof Promise !== 'undefined') {
        return new Promise(resolve => {
          _resolve = resolve
        })
      }
    }
    
    

    总体流程就是,接收回调函数,将回调函数推入回调函数队列中。

    同时,在接收第一个回调函数时,执行能力检测中对应的异步方法(异步方法中调用了回调函数队列)。

    如何保证只在接收第一个回调函数时执行异步方法?

    nextTick源码中使用了一个异步锁的概念,即接收第一个回调函数时,先关上锁,执行异步方法。此时,浏览器处于等待执行完同步代码就执行异步代码的情况。

    打个比喻:相当于一群旅客准备上车,当第一个旅客上车的时候,车开始发动,准备出发,等到所有旅客都上车后,就可以正式开车了。

    当然执行flushCallbacks函数时有个难以理解的点,即:为什么需要备份回调函数队列?执行的也是备份的回调函数队列?

    因为,会出现这么一种情况:nextTick套用nextTick。如果flushCallbacks不做特殊处理,直接循环执行回调函数,会导致里面nextTick中的回调函数会进入回调队列。这就相当于,下一个班车的旅客上了上一个班车。

    可以看到,在简易版的nextTick中,通过nextTick接收回调函数,通过setTimeout来异步执行回调函数。通过这种方式,可以实现在下一个tick中执行回调函数,即在UI重新渲染后执行回调函数。

    关于Event Loop(结合 ES6 的 microtask 与 macrotask)

    检验是否理解事件循环,不依靠控制台,猜一下下面的结果:

    提示: async 会返回 Promise 对象; await 会等待 Promise 对象完成,期间会让出线程。

    async function async1(){
        console.log('async1 start')
        await async2()
        console.log('async1 end')
    }
    async function async2(){
        console.log('async2')
    }
    console.log('script start')
    setTimeout(function(){
        console.log('setTimeout')
    },0)  
    async1();
    new Promise(function(resolve){
        console.log('promise1')
        resolve();
    }).then(function(){
        console.log('promise2')
    })
    console.log('script end')
    
    

    再看看修改版的结果会是啥:

    async function async1(){
        console.log('async1 start')
        await async2()
        console.log('async1 end')
    }
    function async2(){ // 去掉了 async 关键字
        console.log('async2');
    }
    console.log('script start')
    setTimeout(function(){
        console.log('setTimeout')
    },0)  
    async1();
    new Promise(function(resolve){
        console.log('promise1')
        resolve();
    }).then(function(){
        console.log('promise2')
    })
    console.log('script end')
    

    实现一个简易的nextTick

    说了这么多,我们来实现一个简单的nextTick:

    let callbacks = []
    let pending = false
    
    function nextTick (cb) {
        callbacks.push(cb)
    
        if (!pending) {
            pending = true
            setTimeout(flushCallback, 0)
        }
    }
    
    function flushCallback () {
        pending = false
        let copies = callbacks.slice()
        callbacks.length = 0
        copies.forEach(copy => {
            copy()
        })
    }
    
    

    相关文章

      网友评论

          本文标题:对vue.nextTick的理解

          本文链接:https://www.haomeiwen.com/subject/qkndrktx.html