【Vue源碼】Vue中DOM的異步更新策略以及nextTick機制

本篇文章主要是對 Vue中的 DOM異步更新策略和 nextTick機制的解析,須要讀者有必定的 Vue使用經驗而且熟悉掌握JavaScript事件循環模型。

引入: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。這個結果足以說明VueDOM的更新並不是同步。javascript

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

可能你尚未注意到, Vue異步執行 DOM更新。只要觀察到數據變化, Vue將開啓一個隊列,並緩衝在同一事件循環中發生的全部數據改變。若是同一個 watcher被屢次觸發,只會被推入到隊列中一次。這種在緩衝時去除重複數據對於避免沒必要要的計算和 DOM操做上很是重要。而後,在下一個的事件循環「 tick」中, Vue刷新隊列並執行實際 (已去重的) 工做。

簡而言之,就是在一個事件循環中發生的全部數據改變都會在下一個事件循環的Tick中來觸發視圖更新,這也是一個「批處理」的過程。(注意下一個事件循環的Tick有多是在當前的Tick微任務執行階段執行,也多是在下一個Tick執行,主要取決於nextTick函數究竟是使用Promise/MutationObserver仍是setTimeoutvue

Watcher隊列

Watcher的源碼中,咱們發現watcherupdate實際上是異步的。(注:sync屬性默認爲false,也就是異步)java

update () {
    /* istanbul ignore else */
    if (this.lazy) {
        this.dirty = true
    } else if (this.sync) {
        /*同步則執行run直接渲染視圖*/
        this.run()
    } else {
        /*異步推送到觀察者隊列中,下一個tick時調用。*/
        queueWatcher(this)
    }
}

queueWatcher(this)函數的代碼以下:git

/*將一個觀察者對象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)
        }
    }
}

這段源碼有幾個須要注意的地方:github

  1. 首先須要知道的是watcher執行update的時候,默認狀況下確定是異步的,它會作如下的兩件事:web

    • 判斷has數組中是否有這個watcherid
    • 若是有的話是不須要把watcher加入queue中的,不然不作任何處理。
  2. 這裏面的nextTick(flushSchedulerQueue)中,flushScheduleQueue函數的做用主要是執行視圖更新的操做,它會把queue中全部的watcher取出來並執行相應的視圖更新。
  3. 核心實際上是nextTick函數了,下面咱們具體看一下nextTick到底有什麼用。

nextTick

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

首先咱們先來看它是怎麼生成一個timerFunc把回調做爲microTaskmacroTask的。promise

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一次。

也就是說,默認waiting變量爲false,執行一次後waitingtrue,後續的this.xxx不會再次觸發nextTick的執行,而是把this.xxx相對應的watcher推入flushSchedulerQueuequeue隊列中。

因此,也就是說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

繼續來看下面的這段代碼:

<div id="example">
    <div ref="test">{{test}}</div>
    <button @click="handleClick">tet</button>
</div>
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下,這段代碼執行的順序的一、二、promise、3

可能有同窗會覺得這是一、promise、二、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 = 'end';
    console.log('1')
    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 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來測試一下:

<div id="example">
    <span>{{test}}</span>
    <button @click="handleClick">change</button>
</div>
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>
  <div>
    <div>{{test}}</div>
  </div>
</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。 若是這時候沒有異步更新視圖,那麼每次++都會直接操做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屬性來實現切換歌曲。

<div id="example">
    <audio ref="audio"
           :src="url"></audio>
    <span ref="url"></span>
    <button @click="changeUrl">click me</button>
</div>
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();
});

參考連接

https://github.com/youngwind/...

https://github.com/answershut...

https://juejin.im/post/5a1af8...

相關文章
相關標籤/搜索