首頁  >  文章  >  web前端  >  Vue中DOM的非同步更新策略以及nextTick機制詳解

Vue中DOM的非同步更新策略以及nextTick機制詳解

小云云
小云云原創
2018-02-22 11:15:255171瀏覽

本文主要和大家分享對Vue中的DOM非同步更新策略和nextTick機制的解析,需要讀者有一定的Vue使用經驗並且熟悉掌握JavaScript事件循環模型。希望能幫助大家。

引入:DOM的非同步更新

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

列印的結果是begin而不是我們設定的end。這個結果足以說明VueDOM的更新並非同步。

Vue官方文件中是這樣說明的:

可能你還沒注意到,Vue非同步執行DOM更新。只要觀察到資料變化,Vue將開啟一個佇列,並緩衝在同一事件循環中發生的所有資料改變。如果同一個watcher被多次觸發,只會被推入到佇列中一次。這種在緩衝時移除重複資料對於避免不必要的計算和DOM操作上非常重要。然後,在下一個的事件循環「tick」中,Vue刷新佇列並執行實際 (已去重的) 工作。

簡而言之,就是在一個事件循環中發生的所有資料改變都會在下一個事件循環的Tick中來觸發視圖更新,這也是一個「批次」的過程。 (注意下一個事件循環的Tick有可能是在目前的Tick微任務執行階段執行,也可能是在下一個Tick執行,主要取決於nextTick函數到底是使用Promise/MutationObserversetTimeout

Watcher佇列

Watcher的原始碼中,我們發現watcherupdate其實是非同步的。 (註: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數組中是否有這個watcherid

  • 如果有的話是不需要把watcher加入queue中的,否則不做任何處理。

  • 這裡面的nextTick(flushSchedulerQueue)中,flushScheduleQueue函數的作用主要是執行視圖更新的操作,它會把queue中所有的watcher拿出來並執行對應的視圖更新。

  • 核心其實是nextTick函數了,下面我們可以具體看一下nextTick到底有什麼用。

  • nextTick

    nextTick函數其實做了兩件事情,一是產生一個timerFunc,把回呼當作microTaskmacroTask參與到事件循環中。二是把回呼函數放入一個callbacks隊列,等待適當的時機執行。 (這個時機和timerFunc不同的實作有關)

    首先我們先來看它是怎麼產生一個timerFunc把回呼當作microTaskmacroTask的。

    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)
        }
    }

    值得注意的是,它會依照PromiseMutationObserversetTimeout優先權去呼叫傳入的回呼函數。前兩者會產生一個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\ MutationObservernextTickHandler回呼是一個microTask,它會在目前Tick的結尾來執行。如果是setTiemoutnextTickHandler#回呼是一個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闭包中,然后这个callbacksnextTickHandler消费,而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的视图更新。每次调用的时候会把它pushcallbacks中来异步执行。

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

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

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

    这里面使用PromisesetTimeout来执行异步任务的方式都很好理解,比较巧妙的地方是利用MutationObserver执行异步任务。MutationObserverH5的新特性,它能够监听指定范围内的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来创建一个microTasknextTickHandler作为回调传入MutationObserver中。  
    这里面创建了一个textNode作为观测的对象,当timerFunc执行的时候,textNode.data会发生改变,便会触发MutatinObservers的回调函数,而这个函数才是我们真正要执行的任务,它是一个microTask

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

    关于Vue暴露的全局nextTick

    继续来看下面的这段代码:

    <p id="example">
        <p ref="test">{{test}}</p>
        <button @click="handleClick">tet</button>
    </p>
    var vm = new Vue({
        el: '#example',
        data: {
            test: 'begin',
        },
        methods: {
            handleClick() {
                this.test = 'end';
                console.log('1')
                setTimeout(() => { // macroTask
                    console.log('3')
                }, 0);
                Promise.resolve().then(function() { //microTask
                    console.log('promise!')
                })
                this.$nextTick(function () {
                    console.log('2')
                })
            }
        }
    })

    Chrome下,这段代码执行的顺序的1、2、promise、3

    可能有同学会以为这是1、promise、2、3,其实是忽略了一个标志位pending

    我们回到nextTick函数returnqueueNextTick可以发现:

    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
            })
        }
    }

    这里面通过对pending的判断来检测是否已经有timerFunc这个函数在事件循环的任务队列等待被执行。如果存在的话,那么是不会再重复执行的。

    最后异步执行nextTickHandler时又会把pending置为false

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

    所以回到我们的例子:

    handleClick() {
        this.test = &#39;end&#39;;
        console.log(&#39;1&#39;)
        setTimeout(() => { // macroTask
            console.log('3')
        }, 0);
        Promise.resolve().then(function() { //microTask
            console.log('promise!')
        })
        this.$nextTick(function () {
            console.log('2')
        })
    }

    代码中,this.test = 'end'必然会触发watcher进行视图的重新渲染,而我们在文章的Watcher一节中就已经有提到会调用nextTick函数,一开始pending变量肯定就是false,因此它会被修改为true并且执行timerFunc。之后执行this.$nextTick其实还是调用的nextTick函数,只不过此时的pendingtrue说明timerFunc已经被生成,所以this.$nextTick(fn)只是把传入的fn置入callbacks之中。此时的callbacks有两个function成员,一个是flushSchedulerQueue,另外一个就是this.$nextTick()的回调。

    因此,上面这段代码中,在Chrome下,有一个macroTask和两个microTask。一个macroTask就是setTimeout,两个microTask:分别是VuetimerFunc(其中先后执行flushSchedulerQueuefunction() {console.log('2')})、代码中的Promise.resolve().then()

    版本升级带来的变化

    上面讨论的nextTick实现是2.4版本以下的实现,2.5以上版本对于nextTick的内部实现进行了大量的修改。

    独立文件

    首先是从Vue 2.5+开始,抽出来了一个单独的文件next-tick.js来执行它。

    microTask与macroTask

    在代码中,有这么一段注释:

    Vue中DOM的非同步更新策略以及nextTick機制詳解

    其大概的意思就是:在Vue 2.4之前的版本中,nextTick几乎都是基于microTask实现的(具体可以看文章nextTick一节),但是由于microTask的执行优先级非常高,在某些场景之下它甚至要比事件冒泡还要快,就会导致一些诡异的问题;但是如果全部都改成macroTask,对一些有重绘和动画的场景也会有性能的影响。所以最终nextTick采取的策略是默认走microTask,对于一些DOM的交互事件,如v-on绑定的事件回调处理函数的处理,会强制走macroTask

    具体做法就是,在Vue执行绑定的DOM事件时,默认会给回调的handler函数调用withMacroTask方法做一层包装,它保证整个回调函数的执行过程中,遇到数据状态的改变,这些改变而导致的视图更新(DOM更新)的任务都会被推到macroTask

    function add$1 (event, handler, once$$1, capture, passive) {
        handler = withMacroTask(handler);
        if (once$$1) { handler = createOnceHandler(handler, event, capture); }
        target$1.addEventListener(
            event,
            handler,
            supportsPassive
            ? { capture: capture, passive: passive }
            : capture
        );
    }
    
    /**
     * Wrap a function so that if any code inside triggers state change,
     * the changes are queued using a Task instead of a MicroTask.
     */
    function withMacroTask (fn) {
        return fn._withTask || (fn._withTask = function () {
            useMacroTask = true;
            var res = fn.apply(null, arguments);
            useMacroTask = false;
            return res
        })
    }

    而对于macroTask的执行,Vue优先检测是否支持原生setImmediate(高版本IE和Edge支持),不支持的话再去检测是否支持原生MessageChannel,如果还不支持的话为setTimeout(fn, 0)

    最后,写一段demo来测试一下:

    <p id="example">
        <span>{{test}}</span>
        <button @click="handleClick">change</button>
    </p>
    var vm = new Vue({
        el: '#example',
        data: {
            test: 'begin',
        },
        methods: {
            handleClick: function() {
                this.test = end;
                console.log('script')
                this.$nextTick(function () { 
                    console.log('nextTick')
                })
                Promise.resolve().then(function () {
                    console.log('promise')
                })
            }
        }
    })

    Vue 2.5+中,这段代码的输出顺序是scriptpromisenextTick,而Vue 2.4输出scriptnextTickpromisenextTick执行顺序的差异正好说明了上面的改变。

    MessageChannel

    Vue 2.4版本以前使用的MutationObserver来模拟异步任务。而Vue 2.5版本以后,由于兼容性弃用了MutationObserver

    Vue 2.5+版本使用了MessageChannel来模拟macroTask。除了IE以外,messageChannel的兼容性还是比较可观的。

    const channel = new MessageChannel()
    const port = channel.port2
    channel.port1.onmessage = flushCallbacks
    macroTimerFunc = () => {
    port.postMessage(1)
    }

    可见,新建一个MessageChannel对象,该对象通过port1来检测信息,port2发送信息。通过port2的主动postMessage来触发port1onmessage事件,进而把回调函数flushCallbacks作为macroTask参与事件循环。

    MessageChannel VS setTimeout

    为什么要优先MessageChannel创建macroTask而不是setTimeout

    HTML5中规定setTimeout的最小时间延迟是4ms,也就是说理想环境下异步回调最快也是4ms才能触发。

    Vue使用这么多函数来模拟异步任务,其目的只有一个,就是让回调异步且尽早调用。而MessageChannel的延迟明显是小于setTimeout的。对比传送门

    为什么要异步更新视图

    看下面的代码:

    <template>
      <p>
        <p>{{test}}</p>
      </p>
    </template>
    export default {
        data () {
            return {
                test: 0
            };
        },
        mounted () {
          for(let i = 0; i < 1000; i++) {
            this.test++;
          }
        }
    }

    现在有这样的一种情况,mounted的时候test的值会被++循环执行1000次。 每次++时,都会根据响应式触发setter->Dep->Watcher->update->run。 如果这时候没有异步更新视图,那么每次<code>++都会直接操作DOM更新视图,这是非常消耗性能的。 所以Vue实现了一个queue队列,在下一个Tick(或者是当前Tick的微任务阶段)的时候会统一执行queueWatcherrun。同时,拥有相同idWatcher不会被重复加入到该queue中去,所以不会执行1000Watcherrun。最终更新视图只会直接将test对应的DOM0变成1000。 保证更新视图操作DOM的动作是在当前栈执行完以后下一个Tick(或者是当前Tick的微任务阶段)的时候调用,大大优化了性能。

    应用场景

    在操作DOM节点无效的时候,就要考虑操作的实际DOM节点是否存在,或者相应的DOM是否被更新完毕。

    比如说,在created钩子中涉及DOM节点的操作肯定是无效的,因为此时还没有完成相关DOM的挂载。解决的方法就是在nextTick函数中去处理DOM,这样才能保证DOM被成功挂载而有效操作。

    还有就是在数据变化之后要执行某个操作,而这个操作需要使用随数据改变而改变的DOM时,这个操作应该放进Vue.nextTick

    之前在做慕课网音乐webApp的时候关于播放器内核的开发就涉及到了这个问题。下面我把问题简化:

    现在我们要实现一个需求是点击按钮变换audio标签的src属性来实现切换歌曲。

    <p id="example">
        <audio ref="audio"
               :src="url"></audio>
        <span ref="url"></span>
        <button @click="changeUrl">click me</button>
    </p>
    const musicList = [
        'http://sc1.111ttt.cn:8282/2017/1/11m/11/304112003137.m4a?tflag=1519095601&pin=6cd414115fdb9a950d827487b16b5f97#.mp3',
        'http://sc1.111ttt.cn:8282/2017/1/11m/11/304112002493.m4a?tflag=1519095601&pin=6cd414115fdb9a950d827487b16b5f97#.mp3',
        'http://sc1.111ttt.cn:8282/2017/1/11m/11/304112004168.m4a?tflag=1519095601&pin=6cd414115fdb9a950d827487b16b5f97#.mp3'
    ];
    var vm = new Vue({
        el: '#example',
        data: {
            index: 0,
            url: ''
        },
        methods: {
            changeUrl() {
                this.index = (this.index + 1) % musicList.length
                this.url = musicList[this.index];
                this.$refs.audio.play();
            }
        }
    });

    毫无悬念,这样肯定是会报错的:

    Uncaught (in promise) DOMException: The element has no supported sources.

    原因就在于audio.play()是同步的,而这个时候DOM更新是异步的,src属性还没有被更新,结果播放的时候src属性为空,就报错了。

    解决办法就是在play的操作加上this.$nextTick()

    this.$nextTick(function() {
        this.$refs.audio.play();
    });

    以上是Vue中DOM的非同步更新策略以及nextTick機制詳解的詳細內容。更多資訊請關注PHP中文網其他相關文章!

    陳述:
    本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn