vue中$nextTick的實現

前置知識:宏任務 macrotask 和 微任務 microtask

任務隊列分爲 microtask 和 macrotask,且在node和不同的瀏覽器環境執行方式不同。

宿主環境提供的叫宏任務,由語言標準提供的叫微任務。
宿主環境:
簡單來說就是能使javascript完美運行的環境,只要能完美運行javascript的載體就是javascript的宿主環境。目前我們常見的兩種宿主環境有瀏覽器和node。宿主環境內所有的內建或自定義的變量/函數都是 global/window 這個全局對象的屬性/方法,而由宿主環境提供的也叫宏任務。
語言標準:
我們都知道JavaScript是一種編程語言,但其實JavaScript由ECMA制定標準,稱之爲ECMAScript,所以由語言標準提供的就是微任務,比如ES6提供的promise。

宏任務:macrotask:setTimeout、setImmediate
微任務: microtask:Promise、MutationObserver

這裏只討論瀏覽器環境下接收範圍比較廣的,例如谷歌瀏覽器:

當調用棧空閒後每次事件循環只會從 macrotask 中讀取一個任務並執行,而在同一次事件循環內會將 microtask 隊列中所有的任務全部執行完畢,且要先於 macrotask。


MutationObserver接口提供了監視對DOM樹所做更改的能力。它被設計爲舊的Mutation Events功能的替代品,該功能是DOM3 Events規範的一部分。

	// MutationObserver 創建一個微任務
    const observer = new MutationObserver(function(){console.log(666)})
    let counter = 1
    const textNode = document.createTextNode(String(counter))
    
    observer.observe(textNode, {
        characterData: true 
        // 設爲true以監視指定目標節點或子節點樹中節點所包含的字符數據的變化
    })
    
    counter = (counter + 1) % 2
    textNode.data = String(counter)

  • macrotask 和 microtask 執行過程實例
console.log('1');

setTimeout(function() {
    console.log('2');
    new Promise(function(resolve) {
        console.log('3');
        resolve();
    }).then(function() {
        console.log('4')
    })
    setTimeout(()=>{
        console.log('5')
    })
    // MutationObserver 創建一個微任務
    let counter = 1
    const observer = new MutationObserver(function(){console.log(6)})
    const textNode = document.createTextNode(String(counter))
    observer.observe(textNode, {
        characterData: true // 設爲true以監視指定目標節點或子節點樹中節點所包含的字符數據的變化。
    })
    counter = (counter + 1) % 2
    textNode.data = String(counter)
})

// new promise 會立即執行, then會分發到微任務
new Promise(function(resolve) {
    console.log('7');
    resolve();
}).then(function() {
    console.log('8')
})

setTimeout(function() {
    console.log('9');
    new Promise(function(resolve) {
        console.log('10');
        resolve();
    }).then(function() {
        console.log('11')
    })
})

引出問題:爲什麼使用$nextTick

在vue項目中,可以用 setTimeout 替換 $nextTick,形如用 setTimeout(cb, 0) 代替 $nextTick(cb),既然可以使用 setTimeout 替換 $nextTick 那麼爲什麼不用 setTimeout 呢?

原因就在於 setTimeout 並不是最優的選擇,$nextTick 的意義就是它會選擇一條最優的解決方案,即優先選擇微任務。

在 macrotask 中兩個不同的任務之間可能穿插着UI的重渲染,那麼我們只需要在 microtask 中把所有在UI重渲染之前需要更新的數據全部更新,這樣只需要一次重渲染就能得到最新的DOM了,所以要優先選用 microtask 去更新數據狀態而不是 macrotask。


nextTick 的實現

  1. $nextTick
Vue.prototype.$nextTick = function (fn: Function) {
	return nextTick(fn, this)
}

我們常用的 $nextTick 方法實際上就是對 nextTick 函數的封裝。

  1. 整體結構

打開/src/core/util/next-tick.js文件可以看到文件大體結構如下:


    // 從外部導入一些方法 noop、handleError、isIE、isIOS、isNative
    import ... 



    // 聲明一些變量
    export let isUsingMicroTask = false // 導出一個變量,標誌是否使用微任務
    const callbacks = [] // nextTick的回調函數隊列
    let pending = false // 標誌回調隊列callbacks是否處於等待刷新的狀態,初始false,代表回調隊列爲空,不需要等待刷新

    // 聲明 flushCallbacks 函數
    function flushCallbacks () {...} // 清空回調隊列函數(先進先出),作爲setTimeout、setImmediate、Promise、MutationObserver等的回調函數

    // 聲明 timerFunc 函數
    let timerFunc // timerFunc 函數採用合適的策略將 flushCallbacks 作爲回調註冊一個微任務或宏任務
    if(){...}else if(){...}else if(){...}else{...} // 採用合適的策略補充 timerFunc 函數



    // 導出 nextTick 函數主體
    export function nextTick (cb, ctx) {...}

  1. flushCallbacks 函數
    // 清空回調隊列函數(先進先出)
    // 作爲setTimeout、setImmediate、Promise、MutationObserver等的回調函數
    function flushCallbacks () {
      pending = false // 將變量 pending 重置爲 false 
      const copies = callbacks.slice(0) // 備份
      callbacks.length = 0 // 清空  
      for (let i = 0; i < copies.length; i++) {
        copies[i]()
      }
	  // 疑點:爲什麼備份並在遍歷 copies 數組之前將 callbacks 數組清空,將pending重置?
    }
  1. timerFunc 函數 - 本部分即最優解的實現
	// timerFunc 函數採用合適的策略將 flushCallbacks 作爲回調註冊一個微任務或宏任務
	
    let timerFunc

    if (typeof Promise !== 'undefined' && isNative(Promise)) {
      const p = Promise.resolve()
      timerFunc = () => {
        p.then(flushCallbacks)
        if (isIOS) setTimeout({})
      }
      isUsingMicroTask = true
    } 
	if (isIOS) setTimeout({})
	這一行是一個解決怪異問題的變通方法,在一些 UIWebViews 中存在很奇怪的問題,即 microtask 沒有被刷新,對於這個問題的解決方案就是讓瀏覽做一些其他的事情,比如註冊一個 (macro)task, 即使這個 (macro)task 什麼都不做,這樣就能夠間接觸發 microtask 的刷新。
    如果宿主環境不支持 Promise,我們就需要降級處理
    舊版本是 Promise > setImmediate > MessageChannel > setTimeout
    現在最新的版本(dev分支)是 Promise > MutationObserver > setImmediate > setTimeout
    else if (!isIE && typeof MutationObserver !== 'undefined' && (
      isNative(MutationObserver) ||
      MutationObserver.toString() === '[object MutationObserverConstructor]'
    )) {
      let counter = 1
      const observer = new MutationObserver(flushCallbacks)
      const textNode = document.createTextNode(String(counter))
      observer.observe(textNode, {
        characterData: true
      })
      timerFunc = () => {
        counter = (counter + 1) % 2
        textNode.data = String(counter)
      }
      isUsingMicroTask = true
    }
	setImmediate 擁有比 setTimeout 更好的性能,因爲 setTimeout 在將回調註冊爲 macrotask 之前要不停的做超時檢測,而 setImmediate 則不需要。
	但是 setImmediate 的缺陷也很明顯,就是它的兼容性問題,到目前爲止只有IE瀏覽器實現了它。
    else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
      timerFunc = () => {
        setImmediate(flushCallbacks)
      }
    } else {
      timerFunc = () => {
        setTimeout(flushCallbacks, 0)
      }
    }
  1. nextTick 主體
    function nextTick (cb, ctx) {
      let _resolve // 用於無回調函數時
	  //向回調函數隊列添加一個新的函數
      callbacks.push(() => {
        if (cb) {
          try {
            cb.call(ctx) 
            //對於 $nextTick 方法來講,傳遞給 $nextTick 的回調函數的作用域,
            //就是當前組件實例對象
          } catch (e) {
            handleError(e, ctx, 'nextTick')
          }
        } else if (_resolve) {
          _resolve(ctx)
          // 當 flushCallbacks 函數開始執行 callbacks 數組中的函數時,
          // 如果沒有傳遞 cb 參數,則直接調用 _resolve 函數
        }
      })
	  // 註冊微任務/宏任務
      if (!pending) {
      	pending = true
        timerFunc() 
      }
	pending初始值爲false,第一次調用nextTick時,設pending爲true,代表此時回調隊列不爲空,正在等待刷新,後面再調用nextTick時,就不會再註冊新的微任務/宏任務。
	調用timerFunc函數,將flushCallbacks註冊爲微任務/宏任務,但此時 flushCallbacks 函數並不會執行,需等待調用棧被清空之後纔會執行,即實現了等數據準備完(例1中可視爲data1改變後),再實行更新(打印data1)
      // 無回調函數情況
      if (!cb && typeof Promise !== 'undefined') {
        return new Promise(resolve => {
          _resolve = resolve
        })
      }
	在使用 $nextTick 方法時是可以省略回調函數這個參數的,這時 $nextTick 方法會返回一個 promise 實例對象。
	當 nextTick 函數沒有接收到 cb 參數時,會檢測當前宿主環境是否支持 Promise,如果支持則直接返回一個 Promise 實例對象,並且將 resolve 函數賦值給 _resolve 變量
    }
  • 例1:解釋nextTick實現過程
    var data1 = 'data1'

    let cb1 = function(){
      // do sth
      console.log('this is callback: '+data1)
    }

    let cb2 = function(){
      console.log('this is callback2')
    }

    console.log(data1)

    nextTick(cb1,this)
    nextTick(cb2,this) // 此時callback棧內已蒐集所有回調函數

    data1="data1 changed"

    let p = nextTick() // 無cb
  • 例2:回答疑點:爲什麼備份並在遍歷 copies 數組之前將 callbacks 數組清空?
    let cb3 = function(){
      console.log('this is callback3')
    }
    var data2 = 'data2'

    nextTick(function(){
      data2 = 'new data2'
      nextTick(cb3,this)
    },this)
	嵌套nextTick的時候(不推薦這麼寫),使子nextTick行爲不受影響。
	第一次執行 flushCallbacks 時,先重置pending並清空callback,再執行函數:
        function(){
            data2 = 'new data2'
            nextTick(cb3,this)
        }
	當nextTick(cb3,this)執行時,callback已清空,
	所以cb3被push後回調隊列爲 [ cb3 ],
	pending爲false,
	所以會將 flushCallbacks 函數註冊爲一個新的 microtask

參考

宿主環境提供的叫宏任務,由語言標準提供的叫微任務

MutationObserver API

vue技術內幕

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章