新闻资讯

新闻资讯 行业动态

Vue.js异步更新DOM策略及nextTick

编辑:008     时间:2020-02-25

引入:DOM的异步更新

<template>
  <div>
    <div ref="test">{{test}}</div>
    <button @click="handleClick">tet</button>
  </div>
</template>
export default { data () { return { test: 'begin' };
    }, methods () { handleClick () {
            this.test = 'end';
            console.log(this.$refs.test.innerText);//打印“begin”
        }
    }
}

打印的结果是begin而不是我们设置的end。这个结果足以说明Vue中DOM的更新并非同步。

在Vue官方文档中是这样说明的:

可能你还没有注意到,Vue异步执行DOM更新。只要观察到数据变化,Vue将开启一个队列,并缓冲在同一事件循环中发生的所有数据改变。如果同一个watcher被多次触发,只会被推入到队列中一次。这种在缓冲时去除重复数据对于避免不必要的计算和DOM操作上非常重要。然后,在下一个的事件循环“tick”中,Vue刷新队列并执行实际 (已去重的) 工作。

简而言之,就是在一个事件循环中发生的所有数据改变都会在下一个事件循环的Tick中来触发视图更新,这也是一个“批处理”的过程。(注意下一个事件循环的Tick有可能是在当前的Tick微任务执行阶段执行,也可能是在下一个Tick执行,主要取决于nextTick函数到底是使用Promise/MutationObserver还是setTimeout)

Watcher队列

在Watcher的源码中,我们发现watcher的update其实是异步的。(注:sync属性默认为false,也就是异步)

update () {
    /* istanbul ignore else */ if (this.lazy) {
        this.dirty = true } else if (this.sync) {
        /*同步则执行run直接渲染视图*/
        this.run()
    } else {
        /*异步推送到观察者队列中,下一个tick时调用。*/
        queueWatcher(this)
    }
} 

queueWatcher(this)函数的代码如下:

/*将一个观察者对象push进观察者队列,在队列中已经存在相同的id则该观察者对象将被跳过,除非它是在队列被刷新时推送*/ export function queueWatcher (watcher: Watcher) {
    /*获取watcher的id*/
    const id = watcher.id
    /*检验id是否存在,已经存在则直接跳过,不存在则标记哈希表has,用于下次检验*/ if (has[id] == null) {
        has[id] = true if (!flushing) {
            /*如果没有flush掉,直接push到队列中即可*/
            queue.push(watcher)
        } else {
        ...
        }
        // queue the flush if (!waiting) {
            waiting = true nextTick(flushSchedulerQueue)
        }
    }
} 

这段源码有几个需要注意的地方:

  1. 首先需要知道的是watcher执行update的时候,默认情况下肯定是异步的,它会做以下的两件事:
    • 判断has数组中是否有这个watcher的id
    • 如果有的话是不需要把watcher加入queue中的,否则不做任何处理。
  2. 这里面的nextTick(flushSchedulerQueue)中,flushScheduleQueue函数的作用主要是执行视图更新的操作,它会把queue中所有的watcher取出来并执行相应的视图更新。
  3. 核心其实是nextTick函数了,下面我们具体看一下nextTick到底有什么用。

nextTick

nextTick函数其实做了两件事情,一是生成一个timerFunc,把回调作为microTask或macroTask参与到事件循环中来。二是把回调函数放入一个callbacks队列,等待适当的时机执行。(这个时机和timerFunc不同的实现有关)

首先我们先来看它是怎么生成一个timerFunc把回调作为microTask或macroTask的。

if (typeof Promise !== 'undefined' && isNative(Promise)) {
    /*使用Promise*/
    var p = Promise.resolve()
    var logError = err => { console.error(err) }
    timerFunc = () => {
        p.then(nextTickHandler).catch(logError)
        // in problematic UIWebViews, Promise.then doesn't completely break, but
        // it can get stuck in a weird state where callbacks are pushed into the
        // microTask queue but the queue isn't being flushed, until the browser
        // needs to do some other work, e.g. handle a timer. Therefore we can
        // "force" the microTask queue to be flushed by adding an empty timer. if (isIOS) setTimeout(noop)
    }
} else if (typeof MutationObserver !== 'undefined' && (
    isNative(MutationObserver) ||
    // PhantomJS and iOS 7.x
    MutationObserver.toString() === '[object MutationObserverConstructor]' )) {
    // use MutationObserver where native Promise is not available,
    // e.g. PhantomJS IE11, iOS7, Android 4.4
    /*新建一个textNode的DOM对象,用MutationObserver绑定该DOM并指定回调函数,在DOM变化的时候则会触发回调,该回调会进入主线程(比任务队列优先执行),即textNode.data = String(counter)时便会触发回调*/
    var counter = 1
    var observer = new MutationObserver(nextTickHandler)
    var textNode = document.createTextNode(String(counter))
    observer.observe(textNode, {
        characterData: true })
    timerFunc = () => {
        counter = (counter + 1) % 2
        textNode.data = String(counter)
    }
} else {
    // fallback to setTimeout
    /* istanbul ignore next */
    /*使用setTimeout将回调推入任务队列尾部*/
    timerFunc = () => { setTimeout(nextTickHandler, 0)
    }
} 

值得注意的是,它会按照Promise、MutationObserver、setTimeout优先级去调用传入的回调函数。前两者会生成一个microTask任务,而后者会生成一个macroTask。(微任务和宏任务)

之所以会设置这样的优先级,主要是考虑到浏览器之间的兼容性(IE没有内置Promise)。另外,设置Promise最优先是因为Promise.resolve().then回调函数属于一个微任务,浏览器在一个Tick中执行完macroTask后会清空当前Tick所有的microTask再进行UI渲染,把DOM更新的操作放在Tick执行microTask的阶段来完成,相比使用setTimeout生成的一个macroTask会少一次UI的渲染。

而nextTickHandler函数,其实才是我们真正要执行的函数。

function nextTickHandler () {
    pending = false /*执行所有callback*/
    const copies = callbacks.slice(0)
    callbacks.length = 0 for (let i = 0; i < copies.length; i++) {
        copies[i]()
    }
} 

这里的callbacks变量供nextTickHandler消费。而前面我们所说的nextTick函数第二点功能中“等待适当的时机执行”,其实就是因为timerFunc的实现方式有差异,如果是Promise\MutationObserver则nextTickHandler回调是一个microTask,它会在当前Tick的末尾来执行。如果是setTiemout则nextTickHandler回调是一个macroTask,它会在下一个Tick来执行。

还有就是callbacks中的成员是如何被push进来的?从源码中我们可以知道,nextTick是一个自执行的函数,一旦执行是return了一个queueNextTick,所以我们在调用nextTick其实就是在调用queueNextTick这个函数。它的源代码如下:

return function queueNextTick (cb?: Function, ctx?: Object) { let _resolve
    /*cb存到callbacks中*/
    callbacks.push(() => { if (cb) {
            try {
            cb.call(ctx)
            } catch (e) {
            handleError(e, ctx, 'nextTick')
            }
        } else if (_resolve) {
            _resolve(ctx)
        }
    }) if (!pending) {
        pending = true timerFunc()
    } if (!cb && typeof Promise !== 'undefined') { return new Promise((resolve, reject) => {
            _resolve = resolve
        })
    }
} 

可以看到,一旦调用nextTick函数时候,传入的function就会被存放到callbacks闭包中,然后这个callbacks由nextTickHandler消费,而nextTickHandler的执行时间又是由timerFunc来决定。

我们再回来看Watcher中的一段代码:

/*将一个观察者对象push进观察者队列,在队列中已经存在相同的id则该观察者对象将被跳过,除非它是在队列被刷新时推送*/ export function queueWatcher (watcher: Watcher) {
  /*获取watcher的id*/
  const id = watcher.id
  /*检验id是否存在,已经存在则直接跳过,不存在则标记哈希表has,用于下次检验*/ if (has[id] == null) {
    has[id] = true if (!flushing) {
        /*如果没有flush掉,直接push到队列中即可*/
        queue.push(watcher)
    } else {
      ...
    }
    // queue the flush if (!waiting) {
      waiting = true nextTick(flushSchedulerQueue)
    }
  }
} 

这里面的nextTick(flushSchedulerQueue)中的flushSchedulerQueue函数其实就是watcher的视图更新。调用的时候会把它push到callbacks中来异步执行。

另外,关于waiting变量,这是很重要的一个标志位,它保证flushSchedulerQueue回调只允许被置入callbacks一次。

也就是说,默认waiting变量为false,执行一次后waiting为true,后续的this.xxx不会再次触发nextTick的执行,而是把this.xxx相对应的watcher推入flushSchedulerQueue的queue队列中。

所以,也就是说DOM确实是异步更新,但是具体是在下一个Tick更新还是在当前Tick执行microTask的时候更新,具体要看nextTcik的实现方式,也就是具体跑的是Promise/MutationObserver还是setTimeout。

附:nextTick源码带注释,有兴趣可以观摩一下。

这里面使用Promise和setTimeout来执行异步任务的方式都很好理解,比较巧妙的地方是利用MutationObserver执行异步任务。MutationObserver是H5的新特性,它能够监听指定范围内的DOM变化并执行其回调,它的回调会被当作microTask来执行,具体参考MDN。

var counter = 1
var observer = new MutationObserver(nextTickHandler)
var textNode = document.createTextNode(String(counter))
observer.observe(textNode, {
    characterData: true })
timerFunc = () => {
    counter = (counter + 1) % 2
    textNode.data = String(counter)
} 

可以看到,通过借用MutationObserver来创建一个microTask。nextTickHandler作为回调传入MutationObserver中。 这里面创建了一个textNode作为观测的对象,当timerFunc执行的时候,textNode.data会发生改变,便会触发MutatinObservers的回调函数,而这个函数才是我们真正要执行的任务,它是一个microTask。

注:2.5+版本的Vue对nextTick进行了修改,具体参考下面“版本升级”一节。



原文链接:https://juejin.im/post/5e53f07d51882549036940fc
郑重声明:本文版权归原作者所有,转载文章仅为传播更多信息之目的,如作者信息标记有误,请第一时间联系我们修改或删除,多谢。

回复列表

相关推荐