Vue番外篇 -- vue.nextTick()淺析

當咱們在vue的beforeCreate和created生命週期發送ajax到後臺,數據返回的時候發現DOM節點還未生成沒法操做節點,那要怎麼辦呢?html

這時,咱們就會用到一個方法是this.$nextTick(相信你也用過)。vue

nextTick是全局vue的一個函數,在vue系統中,用於處理dom更新的操做。vue裏面有一個watcher,用於觀察數據的變化,而後更新dom,vue裏面並非每次數據改變都會觸發更新dom,而是將這些操做都緩存在一個隊列,在一個事件循環結束以後,刷新隊列,統一執行dom更新操做。html5

一般狀況下,咱們不須要關心這個問題,而若是想在DOM狀態更新後作點什麼,則須要用到nextTick。在vue生命週期的created()鉤子函數進行的DOM操做要放在Vue.nextTick()的回調函數中,由於created()鉤子函數執行的時候DOM並未進行任何渲染,而此時進行DOM操做是徒勞的,因此此處必定要將DOM操做的JS代碼放進Vue.nextTick()的回調函數中。而與之對應的mounted鉤子函數,該鉤子函數執行時全部的DOM掛載和渲染都已完成,此時該鉤子函數進行任何DOM操做都不會有個問題。react

Vue.nextTick(callback),當數據發生變化,更新後執行回調。ios

Vue.$nextTick(callback),當dom發生變化,更新後執行的回調。git

請看以下一段代碼:github

<template>
  <div>
    <div ref="text">{{text}}</div>
    <button @click="handleClick">text</button>
  </div>
</template>
export default {
    data () {
        return {
            text: 'start'
        };
    },
    methods () {
        handleClick () {
            this.text = 'end';
            console.log(this.$refs.text.innerText);//打印「start」
        }
    }
}
複製代碼

打印的結果是start,爲何明明已經將text設置成了「end」,獲取真實DOM節點的innerText卻沒有獲得咱們預期中的「end」,而是獲得以前的值「start」呢?web

源碼解讀

帶着這個疑問,咱們找到了Vue.js源碼的Watch實現。當某個響應式數據發生變化的時候,它的setter函數會通知閉包中的Dep,Dep則會調用它管理的全部Watch對象。觸發Watch對象的update實現。咱們來看一下update的實現。ajax

watcher

/*
      調度者接口,當依賴發生改變的時候進行回調。
   */
  update () {
    /* istanbul ignore else */
    if (this.lazy) {
      this.dirty = true
    } else if (this.sync) {
    /*同步則執行run直接渲染視圖*/
      this.run()
    } else {
    /*異步推送到觀察者隊列中,由調度者調用。*/
      queueWatcher(this)
    }
  }
複製代碼

咱們發現Vue.js默認是使用異步執行DOM更新。 當異步執行update的時候,會調用queueWatcher函數。express

/**
 * Push a watcher into the watcher queue.
 * Jobs with duplicate IDs will be skipped unless it's * pushed when the queue is being flushed. **/ /*將一個觀察者對象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 { // if already flushing, splice the watcher based on its id // if already past its id, it will be run next immediately. let i = queue.length - 1 while (i > index && queue[i].id > watcher.id) { i-- } queue.splice(i + 1, 0, watcher) } // queue the flush // 刷新隊列 if (!waiting) { waiting = true if (process.env.NODE_ENV !== 'production' && !config.async) { flushSchedulerQueue() return } nextTick(flushSchedulerQueue) } } } 複製代碼

查看queueWatcher的源碼咱們發現,Watch對象並非當即更新視圖,而是被push進了一個隊列queue,此時狀態處於waiting的狀態,這時候繼續會有Watch對象被push進這個隊列queue,等待下一個tick時,這些Watch對象纔會被遍歷取出,更新視圖。同時,id重複的Watcher不會被屢次加入到queue中去,由於在最終渲染時,咱們只須要關心數據的最終結果。

flushSchedulerQueue

vue/src/core/observer/scheduler.js
複製代碼
/**
 * Flush both queues and run the watchers.
 */
  /*nextTick的回調函數,在下一個tick時flush掉兩個隊列同時運行watchers*/
function flushSchedulerQueue () {
  flushing = true
  let watcher, id

  // Sort queue before flush.
  // This ensures that:
  // 1. Components are updated from parent to child. (because parent is always
  //    created before the child)
  // 2. A component's user watchers are run before its render watcher (because // user watchers are created before the render watcher) // 3. If a component is destroyed during a parent component's watcher run,
  //    its watchers can be skipped.
  /*
    刷新前給queue排序,這樣作能夠保證:
    1.組件更新的順序是從父組件到子組件的順序,由於父組件老是比子組件先建立。
    2.一個組件的user watchers比render watcher先運行,由於user watchers每每比render watcher更早建立
    3.若是一個組件在父組件watcher運行期間被銷燬,它的watcher執行將被跳過。
  */
  queue.sort((a, b) => a.id - b.id)

  // do not cache length because more watchers might be pushed
  // as we run existing watchers
  /*這裏不用index = queue.length;index > 0; index--的方式寫是由於不要將length進行緩存,
  由於在執行處理現有watcher對象期間,更多的watcher對象可能會被push進queue*/
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index]
    if (watcher.before) {
      watcher.before()
    }
    id = watcher.id
     /*將has的標記刪除*/
    has[id] = null
     /*執行watcher*/
    watcher.run()
    // in dev build, check and stop circular updates.
    /*
      在測試環境中,檢測watch是否在死循環中
      好比這樣一種狀況
      watch: {
        test () {
          this.test++;
        }
      }
      持續執行了一百次watch表明可能存在死循環
    */
    if (process.env.NODE_ENV !== 'production' && has[id] != null) {
      circular[id] = (circular[id] || 0) + 1
      if (circular[id] > MAX_UPDATE_COUNT) {
        warn(
          'You may have an infinite update loop ' + (
            watcher.user
              ? `in watcher with expression "${watcher.expression}"`
              : `in a component render function.`
          ),
          watcher.vm
        )
        break
      }
    }
  }
  // keep copies of post queues before resetting state
  /*獲得隊列的拷貝*/
  const activatedQueue = activatedChildren.slice()
  const updatedQueue = queue.slice()
  /*重置調度者的狀態*/
  resetSchedulerState()

  // call component updated and activated hooks
  /*使子組件狀態都改編成active同時調用activated鉤子*/
  callActivatedHooks(activatedQueue)
  /*調用updated鉤子*/
  callUpdatedHooks(updatedQueue)

  // devtool hook
  /* istanbul ignore if */
  if (devtools && config.devtools) {
    devtools.emit('flush')
  }
}
複製代碼

flushSchedulerQueue是下一個tick時的回調函數,主要目的是執行Watcher的run函數,用來更新視圖

nextTick

vue.js提供了一個nextTick函數,其實也就是上面調用的nextTick。

nextTick的實現比較簡單,執行的目的是在microtask或者task中推入一個funtion,在當前棧執行完畢(也行還會有一些排在前面的須要執行的任務)之後執行nextTick傳入的funtion。

網上不少文章討論的nextTick實現是2.4版本如下的實現,2.5以上版本對於nextTick的內部實現進行了大量的修改,看一下源碼:

首先是從Vue 2.5+開始,抽出來了一個單獨的文件next-tick.js來執行它。

vue/src/core/util/next-tick.js
複製代碼
/*
    延遲一個任務使其異步執行,在下一個tick時執行,一個當即執行函數,返回一個function
    這個函數的做用是在task或者microtask中推入一個timerFunc,
    在當前調用棧執行完之後以此執行直到執行到timerFunc
    目的是延遲到當前調用棧執行完之後執行
*/
/*存放異步執行的回調*/
const callbacks = []
/*一個標記位,若是已經有timerFunc被推送到任務隊列中去則不須要重複推送*/
let pending = false

/*下一個tick時的回調*/
function flushCallbacks () {
/*一個標記位,標記等待狀態(即函數已經被推入任務隊列或者主線程,已經在等待當前棧執行完畢去執行),這樣就不須要在push多個回調到callbacks時將timerFunc屢次推入任務隊列或者主線程*/
  pending = false
  //複製callback
  const copies = callbacks.slice(0)
  //清除callbacks
  callbacks.length = 0
  for (let i = 0; i < copies.length; i++) {
  //觸發callback的回調函數
    copies[i]()
  }
}

// Here we have async deferring wrappers using both microtasks and (macro) tasks.
// In < 2.4 we used microtasks everywhere, but there are some scenarios where
// microtasks have too high a priority and fire in between supposedly
// sequential events (e.g. #4521, #6690) or even between bubbling of the same
// event (#6566). However, using (macro) tasks everywhere also has subtle problems
// when state is changed right before repaint (e.g. #6813, out-in transitions).
// Here we use microtask by default, but expose a way to force (macro) task when
// needed (e.g. in event handlers attached by v-on).
/**
其大概的意思就是:在Vue2.4以前的版本中,nextTick幾乎都是基於microTask實現的,
可是因爲microTask的執行優先級很是高,在某些場景之下它甚至要比事件冒泡還要快,
就會致使一些詭異的問題;可是若是所有都改爲macroTask,對一些有重繪和動畫的場
景也會有性能的影響。因此最終nextTick採起的策略是默認走microTask,對於一些DOM
的交互事件,如v-on綁定的事件回調處理函數的處理,會強制走macroTask。
**/

let microTimerFunc
let macroTimerFunc
let useMacroTask = false

// 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 */ /** 而對於macroTask的執行,Vue優先檢測是否支持原生setImmediate(高版本IE和Edge支持), 不支持的話再去檢測是否支持原生MessageChannel,若是還不支持的話爲setTimeout(fn, 0)。 **/ if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) { macroTimerFunc = () => { setImmediate(flushCallbacks) } } else if (typeof MessageChannel !== 'undefined' && ( // MessageChannel與原先的MutationObserver殊途同歸 /** 在Vue 2.4版本之前使用的MutationObserver來模擬異步任務。 而Vue 2.5版本之後,因爲兼容性棄用了MutationObserver。 Vue 2.5+版本使用了MessageChannel來模擬macroTask。 除了IE之外,messageChannel的兼容性仍是比較可觀的。 **/ isNative(MessageChannel) || // PhantomJS MessageChannel.toString() === '[object MessageChannelConstructor]' )) { /** 可見,新建一個MessageChannel對象,該對象經過port1來檢測信息,port2發送信息。 經過port2的主動postMessage來觸發port1的onmessage事件, 進而把回調函數flushCallbacks做爲macroTask參與事件循環。 **/ 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) } } // Determine microtask defer implementation. /* istanbul ignore next, $flow-disable-line */ if (typeof Promise !== 'undefined' && isNative(Promise)) { /*使用Promise*/ const p = Promise.resolve() microTimerFunc = () => { p.then(flushCallbacks) // 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. //iOS的webview下,須要強制刷新隊列,執行上面的回調函數 if (isIOS) setTimeout(noop) } } else { // fallback to macro microTimerFunc = macroTimerFunc } /** * Wrap a function so that if any code inside triggers state change, * the changes are queued using a (macro) task instead of a microtask. */ /** 在Vue執行綁定的DOM事件時,默認會給回調的handler函數調用withMacroTask方法作一層包裝, 它保證整個回調函數的執行過程當中,遇到數據狀態的改變,這些改變而致使的視圖更新(DOM更新) 的任務都會被推到macroTask而不是microtask。 **/ export function withMacroTask (fn: Function): Function { return fn._withTask || (fn._withTask = function () { useMacroTask = true const res = fn.apply(null, arguments) useMacroTask = false return res }) } /* 推送到隊列中下一個tick時執行 cb 回調函數 ctx 上下文 */ export function nextTick (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 if (useMacroTask) { macroTimerFunc() } else { microTimerFunc() } } // $flow-disable-line if (!cb && typeof Promise !== 'undefined') { return new Promise(resolve => { _resolve = resolve }) } } 複製代碼

MessageChannel VS setTimeout

爲何要優先MessageChannel建立macroTask而不是setTimeout?

HTML5中規定setTimeout的最小時間延遲是4ms,也就是說理想環境下異步回調最快也是4ms才能觸發。

Vue使用這麼多函數來模擬異步任務,其目的只有一個,就是讓回調異步且儘早調用。而MessageChannel的延遲明顯是小於setTimeout的。

說了這麼多,到底什麼是macrotasks,什麼是microtasks呢?

二者的具體實現

macrotasks:

setTimeout ,setInterval, setImmediate,requestAnimationFrame, I/O ,UI渲染

microtasks:

Promise, process.nextTick, Object.observe, MutationObserver

再簡單點能夠總結爲:

1.在 macrotask 隊列中執行最先的那個 task ,而後移出

2.再執行 microtask 隊列中全部可用的任務,而後移出

3.下一個循環,執行下一個 macrotask 中的任務 (再跳到第2步)

那咱們上面提到的任務隊列究竟是什麼呢?跟macrotasks和microtasks有什麼聯繫呢?

• An event loop has one or more task queues.(task queue is macrotask queue)
• Each event loop has a microtask queue.
• task queue = macrotask queue != microtask queue
• a task may be pushed into macrotask queue,or microtask queue
• when a task is pushed into a queue(micro/macro),we mean preparing work is finished,
so the task can be executed now.
複製代碼

翻譯一下就是:

• 一個事件循環有一個或者多個任務隊列;

• 每一個事件循環都有一個microtask隊列;

• macrotask隊列就是咱們常說的任務隊列,microtask隊列不是任務隊列;

• 一個任務能夠被放入到macrotask隊列,也能夠放入microtask隊列;

• 當一個任務被放入microtask或者macrotask隊列後,準備工做就已經結束,這時候能夠開始執行任務了。

可見,setTimeout和Promises不是同一類的任務,處理方式應該會有區別,具體的處理方式有什麼不一樣呢?從這篇文章裏找到了下面這段話:

Microtasks are usually scheduled for things that should happen straight after the currently
executing script, such as reacting to a batch of actions, or to make something async
without taking the penalty of a whole new task. The microtask queue is processed after
callbacks as long as no other JavaScript is mid-execution, and at the end of each task. Any
additional microtasks queued during microtasks are added to the end of the queue and also
processed. Microtasks include mutation observer callbacks, and as in the above example,
promise callbacks.
複製代碼

通俗的解釋一下,microtasks的做用是用來調度應在當前執行的腳本執行結束後當即執行的任務。 例如響應事件、或者異步操做,以免付出額外的一個task的費用。

microtask會在兩種狀況下執行:

任務隊列(macrotask = task queue)回調後執行,前提條件是當前沒有其餘執行中的代碼。 每一個task末尾執行。 另外在處理microtask期間,若是有新添加的microtasks,也會被添加到隊列的末尾並執行。

也就是說執行順序是:

開始 -> 取task queue第一個task執行 -> 取microtask所有任務依次執行 -> 取task queue下一個任務執行 -> 再次取出microtask所有任務執行 -> ... 這樣循環往復

Once a promise settles, or if it has already settled, it queues a microtask for its
reactionary callbacks. This ensures promise callbacks are async even if the promise has
already settled. So calling .then(yey, nay) against a settled promise immediately queues a
microtask. This is why promise1 and promise2 are logged after script end, as the currently
running script must finish before microtasks are handled. promise1 and promise2 are logged
before setTimeout, as microtasks always happen before the next task.
複製代碼

Promise一旦狀態置爲完成態,便爲其回調(.then內的函數)安排一個microtask。

接下來咱們看回咱們上面的代碼:

setTimeout(function(){
    console.log(1)
},0);
new Promise(function(resolve){
    console.log(2)
    for( var i=100000 ; i>0 ; i-- ){
        i==1 && resolve()
    }
    console.log(3)
}).then(function(){
    console.log(4)
});
console.log(5);
複製代碼

按照上面的規則從新分析一遍:

當運行到setTimeout時,會把setTimeout的回調函數console.log(1)放到任務隊列裏去,而後繼續向下執行。

接下來會遇到一個Promise。首先執行打印console.log(2),而後執行for循環,即時for循環要累加到10萬,也是在執行棧裏面,等待for循環執行完畢之後,將Promise的狀態從fulfilled切換到resolve,隨後把要執行的回調函數,也就是then裏面的console.log(4)推到microtask裏面去。接下來立刻執行立刻console.log(3)。

而後出Promise,還剩一個同步的console.log(5),直接打印。這樣第一輪下來,已經依次打印了2,3,5。

如今第一輪任務隊列已經執行完畢,沒有正在執行的代碼。符合上面講的microtask執行條件,所以會將microtask中的任務優先執行,所以執行console.log(4)

最後還剩macrotask裏的setTimeout放入的函數console.log(1)最後執行。

如此分析輸出順序是:

2
3
5
4
1
複製代碼

咱們再來看一個:

當一個程序有:setTimeout, setInterval ,setImmediate, I/O, UI渲染,Promise ,process.nextTick, Object.observe, MutationObserver的時候:

1.先執行 macrotasks:I/O -》 UI渲染

2.再執行 microtasks :process.nextTick -》 Promise -》MutationObserver ->Object.observe

3.再把setTimeout setInterval setImmediate 塞入一個新的macrotasks,依次:

setImmediate --》setTimeout ,setInterval

綜上,nextTick的目的就是產生一個回調函數加入task或者microtask中,當前棧執行完之後(可能中間還有別的排在前面的函數)調用該回調函數,起到了異步觸發(即下一個tick時觸發)的目的。

setImmediate(function(){
    console.log(1);
},0);
setTimeout(function(){
    console.log(2);
},0);
new Promise(function(resolve){
    console.log(3);
    resolve();
    console.log(4);
}).then(function(){
    console.log(5);
});
console.log(6);
process.nextTick(function(){
    console.log(7);
});
console.log(8);
結果是:3 4 6 8 7 5 1 2
複製代碼

使用了nextTick異步更新視圖有什麼好處呢?

接下來咱們看一下一個Demo:

<template>
  <div>
    <div>{{test}}</div>
  </div>
</template>
export default {
    data () {
        return {
            test: 0
        };
    },
    created () {
      for(let i = 0; i < 1000; i++) {
        this.test++;
      }
    }
}
複製代碼

如今有這樣的一種狀況,created的時候test的值會被++循環執行1000次。 每次++時,都會根據響應式觸發setter->Dep->Watcher->update->patch。 若是這時候沒有異步更新視圖,那麼每次++都會直接操做DOM更新視圖,這是很是消耗性能的。 因此Vue.js實現了一個queue隊列,在下一個tick的時候會統一執行queue中Watcher的run。同時,擁有相同id的Watcher不會被重複加入到該queue中去,因此不會執行1000次Watcher的run。最終更新視圖只會直接將test對應的DOM的0變成1000。 保證更新視圖操做DOM的動做是在當前棧執行完之後下一個tick的時候調用,大大優化了性能。

要是喜歡的話給個star, 鼓勵一下,github

感謝首發於zhleven提供的思路。

相關文章
相關標籤/搜索