從Vue.js源碼看異步更新DOM策略及nextTick

寫在前面

由於對Vue.js很感興趣,並且平時工做的技術棧也是Vue.js,這幾個月花了些時間研究學習了一下Vue.js源碼,並作了總結與輸出。 文章的原地址:https://github.com/answershuto/learnVue。 在學習過程當中,爲Vue加上了中文的註釋https://github.com/answershuto/learnVue/tree/master/vue-src,但願能夠對其餘想學習Vue源碼的小夥伴有所幫助。 可能會有理解存在誤差的地方,歡迎提issue指出,共同窗習,共同進步。javascript

操做DOM

在使用vue.js的時候,有時候由於一些特定的業務場景,不得不去操做DOM,好比這樣:html

<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」呢?vue

Watcher隊列

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

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函數。react

/*將一個觀察者對象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中去,由於在最終渲染時,咱們只須要關心數據的最終結果。git

那麼,什麼是下一個tick?github

nextTick

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

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

/**
 * 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 來作數據更新,那麼渲染就會進行兩次。

參考顧軼靈知乎的回答:https://www.zhihu.com/question/55364497/answer/144215284

首先是Promise,(Promise.resolve()).then()能夠在microtask中加入它的回調,

MutationObserver新建一個textNode的DOM對象,用MutationObserver綁定該DOM並指定回調函數,在DOM變化的時候則會觸發回調,該回調會進入microtask,即textNode.data = String(counter)時便會加入該回調。

setTimeout是最後的一種備選方案,它會將回調函數加入task中,等到執行。

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

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實例了。

關於

做者:染陌

Email:answershuto@gmail.com or answershuto@126.com

Github: https://github.com/answershuto

Blog:http://answershuto.github.io/

知乎主頁:https://www.zhihu.com/people/cao-yang-49/activities

知乎專欄:https://zhuanlan.zhihu.com/ranmo

掘金: https://juejin.im/user/58f87ae844d9040069ca7507

osChina:https://my.oschina.net/u/3161824/blog

轉載請註明出處,謝謝。

歡迎關注個人公衆號

相關文章
相關標籤/搜索