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

DIVI發表於2018-10-20

當我們在vue的beforeCreate和created生命週期傳送ajax到後臺,資料返回的時候發現DOM節點還未生成無法操作節點,那要怎麼辦呢?

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

nextTick是全域性vue的一個函式,在vue系統中,用於處理dom更新的操作。vue裡面有一個watcher,用於觀察資料的變化,然後更新dom,vue裡面並不是每次資料改變都會觸發更新dom,而是將這些操作都快取在一個佇列,在一個事件迴圈結束之後,重新整理佇列,統一執行dom更新操作。

通常情況下,我們不需要關心這個問題,而如果想在DOM狀態更新後做點什麼,則需要用到nextTick。在vue生命週期的created()鉤子函式進行的DOM操作要放在Vue.nextTick()的回撥函式中,因為created()鉤子函式執行的時候DOM並未進行任何渲染,而此時進行DOM操作是徒勞的,所以此處一定要將DOM操作的JS程式碼放進Vue.nextTick()的回撥函式中。而與之對應的mounted鉤子函式,該鉤子函式執行時所有的DOM掛載和渲染都已完成,此時該鉤子函式進行任何DOM操作都不會有個問題。

Vue.nextTick(callback),當資料發生變化,更新後執行回撥。

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

請看如下一段程式碼:

<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”呢?

原始碼解讀

帶著這個疑問,我們找到了Vue.js原始碼的Watch實現。當某個響應式資料發生變化的時候,它的setter函式會通知閉包中的Dep,Dep則會呼叫它管理的所有Watch物件。觸發Watch物件的update實現。我們來看一下update的實現。

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函式。

/**
 * 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

再簡單點可以總結為:

Vue番外篇 -- vue.nextTick()淺析
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提供的思路。

相關文章