從Vue.js原始碼看nextTick機制

舞動乾坤發表於2017-10-25

從Vue.js原始碼看nextTick機制

Vue原始碼詳解之nextTick:MutationObserver只是浮雲,microtask才是核心!


       可能會有理解存在偏差的地方,歡迎大神斧正,共同學習,共同進步。

操作DOM

在使用vue.js的時候,有時候因為一些特定的業務場景,不得不去操作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,為什麼我們明明已經將test設定成了“end”,獲取真實DOM節點的innerText卻沒有得到我們預期中的“end”,而是得到之前的值“begin”呢?

Watcher佇列

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

update () {
    /* istanbul ignore else */
    if (this.lazy) {
        this.dirty = true
    } else if (this.sync) {
        /*同步則執行run直接渲染檢視*/
        this.run()
    } else {
        /*非同步推送到觀察者佇列中,下一個tick時呼叫。*/
        queueWatcher(this)
    }
}
複製程式碼

我們發現Vue.js預設是使用非同步執行DOM更新
當非同步執行update的時候,會呼叫queueWatcher函式。

/*將一個觀察者物件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 >= 0 && queue[i].id > watcher.id) {
        i--
      }
      queue.splice(Math.max(i, index) + 1, 0, watcher)
    }
    // queue the flush
    if (!waiting) {
      waiting = true
      nextTick(flushSchedulerQueue)
    }
  }
}
複製程式碼

檢視queueWatcher的原始碼我們發現,Watch物件並不是立即更新檢視,而是被push進了一個佇列queue,此時狀態處於waiting的狀態,這時候會繼續會有Watch物件被push進這個佇列queue,等待下一個tick時,這些Watch物件才會被遍歷取出,更新檢視。同時,id重複的Watcher不會被多次加入到queue中去,因為在最終渲染時,我們只需要關心資料的最終結果。

那麼,什麼是下一個tick?

nextTick

vue.js提供了一個nextTick函式,其實也就是上面呼叫的nextTick。

nextTick的實現比較簡單,執行的目的是在microtask或者task中推入一個funtion,在當前棧執行完畢(也行還會有一些排在前面的需要執行的任務)以後執行nextTick傳入的funtion,看一下原始碼:

/**
 * Defer a task to execute it asynchronously.
 */
 /*
    延遲一個任務使其非同步執行,在下一個tick時執行,一個立即執行函式,返回一個function
    這個函式的作用是在task或者microtask中推入一個timerFunc,
    在當前呼叫棧執行完以後以此執行直到執行到timerFunc
    目的是延遲到當前呼叫棧執行完以後執行
*/
export const nextTick = (function () {
  /*存放非同步執行的回撥*/
  const callbacks = []
  /*一個標記位,如果已經有timerFunc被推送到任務佇列中去則不需要重複推送*/
  let pending = false
  /*一個函式指標,指向函式將被推送到任務佇列中,等到主執行緒任務執行完時,任務佇列中的timerFunc被呼叫*/
  let timerFunc

  /*下一個tick時的回撥*/
  function nextTickHandler () {
    /*一個標記位,標記等待狀態(即函式已經被推入任務佇列或者主執行緒,已經在等待當前棧執行完畢去執行),這樣就不需要在push多個回撥到callbacks時將timerFunc多次推入任務佇列或者主執行緒*/
    pending = false
    /*執行所有callback*/
    const copies = callbacks.slice(0)
    callbacks.length = 0
    for (let i = 0; i < copies.length; i++) {
      copies[i]()
    }
  }

  // the nextTick behavior leverages the microtask queue, which can be accessed
  // via either native Promise.then or MutationObserver.
  // MutationObserver has wider support, however it is seriously bugged in
  // UIWebView in iOS >= 9.3.3 when triggered in touch event handlers. It
  // completely stops working after triggering a few times... so, if native
  // Promise is available, we will use it:
  /* istanbul ignore if */

  /*
    這裡解釋一下,一共有Promise、MutationObserver以及setTimeout三種嘗試得到timerFunc的方法
    優先使用Promise,在Promise不存在的情況下使用MutationObserver,這兩個方法都會在microtask中執行,會比setTimeout更早執行,所以優先使用。
    如果上述兩種方法都不支援的環境則會使用setTimeout,在task尾部推入這個函式,等待呼叫執行。
    參考:https://www.zhihu.com/question/55364497
  */
  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)
    }
  }

  /*
    推送到佇列中下一個tick時執行
    cb 回撥函式
    ctx 上下文
  */
  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
      })
    }
  }
})()
複製程式碼

它是一個立即執行函式,返回一個queueNextTick介面。

傳入的cb會被push進callbacks中存放起來,然後執行timerFunc(pending是一個狀態標記,保證timerFunc在下一個tick之前只執行一次)。

timerFunc是什麼?

看了原始碼發現timerFunc會檢測當前環境而不同實現,其實就是按照Promise,MutationObserver,setTimeout優先順序,哪個存在使用哪個,最不濟的環境下使用setTimeout。

這裡解釋一下,一共有Promise、MutationObserver以及setTimeout三種嘗試得到timerFunc的方法。
優先使用Promise,在Promise不存在的情況下使用MutationObserver,這兩個方法的回撥函式都會在microtask中執行,它們會比setTimeout更早執行,所以優先使用。
如果上述兩種方法都不支援的環境則會使用setTimeout,在task尾部推入這個函式,等待呼叫執行。

為什麼要優先使用microtask?我在顧軼靈在知乎的回答中學習到:

JS 的 event loop 執行時會區分 task 和 microtask,引擎在每個 task 執行完畢,從佇列中取下一個 task 來執行之前,會先執行完所有 microtask 佇列中的 microtask。
setTimeout 回撥會被分配到一個新的 task 中執行,而 Promise 的 resolver、MutationObserver 的回撥都會被安排到一個新的 microtask 中執行,會比 setTimeout 產生的 task 先執行。
要建立一個新的 microtask,優先使用 Promise,如果瀏覽器不支援,再嘗試 MutationObserver。
實在不行,只能用 setTimeout 建立 task 了。
為啥要用 microtask?
根據 HTML Standard,在每個 task 執行完以後,UI 都會重渲染,那麼在 microtask 中就完成資料更新,當前 task 結束就可以得到最新的 UI 了。
反之如果新建一個 task 來做資料更新,那麼渲染就會進行兩次。

首先是Promise,(Promise.resolve()).then()可以在microtask中加入它的回撥,

MutationObserver新建一個textNode的DOM物件,用MutationObserver繫結該DOM並指定回撥函式,在DOM變化的時候則會觸發回撥,該回撥會進入microtask,即textNode.data = String(counter)時便會加入該回撥。

setTimeout是最後的一種備選方案,並且還有4ms延時,setTimeout延時0不會老老實實立即執行:

setTimeout(function(){
    console.log("我不是立即執行的,我會延時4ms,哈哈");
},0);
複製程式碼

它會將回撥函式加入task中,等到執行。

setTimeout(function(){console.log(4)},0);
new Promise(function(resolve){
    console.log(1)
    for( var i=0 ; i<10000 ; i++ ){
        i==9999 && resolve()
    }
    console.log(2)
}).then(function(){
    console.log(5)
});
console.log(3);
結果是:
1,2,3,5,4
複製程式碼

綜上,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 2 1
複製程式碼

flushSchedulerQueue

/*Github:https://github.com/answershuto*/
/**
 * 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]
    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鉤子*/
  callUpdateHooks(updatedQueue)

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

flushSchedulerQueue是下一個tick時的回撥函式,主要目的是執行Watcher的run函式,用來更新檢視

為什麼要非同步更新檢視

來看一下下面這一段程式碼

<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的時候呼叫,大大優化了效能。

訪問真實DOM節點更新後的資料

所以我們需要在修改data中的資料後訪問真實的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';
            this.$nextTick(() => {
                console.log(this.$refs.test.innerText);//列印"end"
            });
            console.log(this.$refs.test.innerText);//列印“begin”
        }
    }
}
複製程式碼

使用Vue.js的global API的$nextTick方法,即可在回撥中獲取已經更新好的DOM例項了。


相關文章