5、vue nextTick

主線程的執行過程就是一個 tick,而全部的異步結果都是經過 「任務隊列」 來調度被調度。 消息隊列中存放的是一個個的任務(task)。 規範中規定 task 分爲兩大類,分別是 macro task 和 micro task,而且每一個 macro task 結束後,都要清空全部的 micro task。node

關於 macro task 和 micro task 的概念,這裏不會細講,簡單經過一段代碼演示他們的執行順序:ios

for (macroTask of macroTaskQueue) {
    // 1. Handle current MACRO-TASK
    handleMacroTask();
      
    // 2. Handle all MICRO-TASK
    for (microTask of microTaskQueue) {
        handleMicroTask(microTask);
    }
}

在瀏覽器環境中,常見的 macro task 有 setTimeout、MessageChannel、postMessage、setImmediate;常見的 micro task 有 MutationObsever 和 Promise.then。數組

nextTick 的實現單獨有一個 JS 文件來維護它,它的源碼並很少,總共也就 100 多行。接下來咱們來看一下它的實現,在 src/core/util/next-tick.js 中promise

import { noop } from 'shared/util'
import { handleError } from './error'
import { isIOS, isNative } from './env'

const callbacks = []
let pending = false

function flushCallbacks () {
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  for (let i = 0; i < copies.length; i++) {
    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).
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 */
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  macroTimerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else if (typeof MessageChannel !== 'undefined' && (
  isNative(MessageChannel) ||
  // PhantomJS
  MessageChannel.toString() === '[object MessageChannelConstructor]'
)) {
  const channel = new MessageChannel()
  const port = channel.port2
  channel.port1.onmessage = flushCallbacks
  macroTimerFunc = () => {
    port.postMessage(1)
  }
} else {
  /* istanbul ignore next */
  macroTimerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

// Determine microtask defer implementation.
/* istanbul ignore next, $flow-disable-line */
if (typeof Promise !== 'undefined' && isNative(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.
    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.
 */
export function withMacroTask (fn: Function): Function {
  return fn._withTask || (fn._withTask = function () {
    useMacroTask = true
    const res = fn.apply(null, arguments)
    useMacroTask = false
    return res
  })
}

export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  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
    })
  }
}

咱們知道任務隊列並不是只有一個隊列,在 node 中更爲複雜,但總的來講咱們能夠將其分爲 microtask 和 (macro)task,而且這兩個隊列的行爲還要依據不一樣瀏覽器的具體實現去討論,這裏咱們只討論被普遍認同和接受的隊列執行行爲。當調用棧空閒後每次事件循環只會從 (macro)task 中讀取一個任務並執行,而在同一次事件循環內會將 microtask 隊列中全部的任務所有執行完畢,且要先於 (macro)task。另外 (macro)task 中兩個不一樣的任務之間可能穿插着UI的重渲染,那麼咱們只須要在 microtask 中把全部在UI重渲染以前須要更新的數據所有更新,這樣只須要一次重渲染就能獲得最新的DOM了。剛好 Vue 是一個數據驅動的框架,若是能在UI重渲染以前更新全部數據狀態,這對性能的提高是一個很大的幫助,全部要優先選用 microtask 去更新數據狀態而不是 (macro)task,這就是爲何不使用 setTimeout 的緣由,由於 setTimeout 會將回調放到 (macro)task 隊列中而不是 microtask 隊列,因此理論上最優的選擇是使用 Promise,當瀏覽器不支持 Promise 時再降級爲 setTimeout。以下是 next-tick.js 文件中的一段代碼:瀏覽器

if (typeof Promise !== 'undefined' && isNative(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.
    if (isIOS) setTimeout(noop)
  }
} else {
  // fallback to macro
  microTimerFunc = macroTimerFunc
}

其中變量 microTimerFunc 定義在文件頭部,它的初始值是 undefined,上面的代碼中首先檢測當前宿主環境是否支持原生的 Promise,若是支持則優先使用 Promise 註冊 microtask,作法很簡單,首先定義常量 p 它的值是一個當即 resolve 的 Promise 實例對象,接着將變量 microTimerFunc 定義爲一個函數,這個函數的執行將會把 flushCallbacks 函數註冊爲 microtask。另外你們注意這句代碼:app

if (isIOS) setTimeout(noop)

註釋已經寫得很清楚了,這是一個解決怪異問題的變通方法,在一些 UIWebViews 中存在很奇怪的問題,即 microtask 沒有被刷新,對於這個問題的解決方案就是讓瀏覽作一些其餘的事情好比註冊一個 (macro)task 即便這個 (macro)task 什麼都不作,這樣就可以間接觸發 microtask 的刷新。框架

使用 Promise 是最理想的方案,可是若是宿主環境不支持 Promise,咱們就須要降級處理,即註冊 (macro)task,這就是 else 語句塊內代碼所作的事情異步

if (typeof Promise !== 'undefined' && isNative(Promise)) {
  // 省略...
} else {
  // fallback to macro
  microTimerFunc = macroTimerFunc
}

將 macroTimerFunc 的值賦值給 microTimerFunc。咱們知道 microTimerFunc 用來將 flushCallbacks 函數註冊爲 microtask,而 macroTimerFunc 則是用來將 flushCallbacks 函數註冊爲 (macro)task 的,來看下面這段代碼:async

if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  macroTimerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else if (typeof MessageChannel !== 'undefined' && (
  isNative(MessageChannel) ||
  // PhantomJS
  MessageChannel.toString() === '[object MessageChannelConstructor]'
)) {
  const channel = new MessageChannel()
  const port = channel.port2
  channel.port1.onmessage = flushCallbacks
  macroTimerFunc = () => {
    port.postMessage(1)
  }
} else {
  /* istanbul ignore next */
  macroTimerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

若是宿主環境支持原生 setImmediate 函數,則使用 setImmediate 註冊 (macro)task,爲何首選 setImmediate 呢?這是有緣由的,由於 setImmediate 擁有比 setTimeout 更好的性能,這個問題很好理解,setTimeout 在將回調註冊爲 (macro)task 以前要不停的作超時檢測,而 setImmediate 則不須要,這就是優先選用 setImmediate 的緣由。可是 setImmediate 的缺陷也很明顯,就是它的兼容性問題,到目前爲止只有IE瀏覽器實現了它,因此爲了兼容非IE瀏覽器咱們還須要作兼容處理,只不過此時還輪不到 setTimeout 上場,而是使用 MessageChannel:ide

if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  // 省略...
} else if (typeof MessageChannel !== 'undefined' && (
  isNative(MessageChannel) ||
  // PhantomJS
  MessageChannel.toString() === '[object MessageChannelConstructor]'
)) {
>   const channel = new MessageChannel()
>   const port = channel.port2
>   channel.port1.onmessage = flushCallbacks
>   macroTimerFunc = () => {
>     port.postMessage(1)
>   }
} else {
  // 省略...
}

相信你們應該瞭解過 Web Workers,實際上 Web Workers 的內部實現就是用到了 MessageChannel,一個 MessageChannel 實例對象擁有兩個屬性 port1 和 port2,咱們只須要讓其中一個 port 監聽 onmessage 事件,而後使用另一個 port 的 postMessage 向前一個 port 發送消息便可,這樣前一個 port 的 onmessage 回調就會被註冊爲 (macro)task,因爲它也不須要作任何檢測工做,因此性能也要優於 setTimeout。總之 macroTimerFunc 函數的做用就是將 flushCallbacks 註冊爲 (macro)task。

如今是時候仔細看一下 nextTick 函數都作了什麼事情了,不過爲了更融入理解 nextTick 函數的代碼,咱們須要從 $nextTick 方法入手,以下:

export function renderMixin (Vue: Class<Component>) {
  // 省略...
>   Vue.prototype.$nextTick = function (fn: Function) {
>     return nextTick(fn, this)
>   }
  // 省略...
}

$nextTick 方法只接收一個回調函數做爲參數,但在內部調用 nextTick 函數時,除了把回調函數 fn 透傳以外,第二個參數是硬編碼爲當前組件實例對象 this。咱們知道在使用 $nextTick 方法時是能夠省略回調函數這個參數的,這時 $nextTick 方法會返回一個 promise 實例對象。這些功能實際上都是由 nextTick 函數提供的,以下是 nextTick 函數的簽名:

export function nextTick (cb?: Function, ctx?: Object) {
  // 省略...
}

nextTick 函數接收兩個參數,第一個參數是一個回調函數,第二個參數指定一個做用域。下面咱們逐個分析傳遞迴調函數與不傳遞迴調函數這兩種使用場景功能的實現,首先咱們來看傳遞迴調函數的狀況,那麼此時參數 cb 就是回調函數,來看以下代碼:

export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  // 省略
}

nextTick 函數會在 callbacks 數組中添加一個新的函數,callbacks 數組定義在文件頭部:const callbacks = []。注意並非將 cb 回調函數直接添加到 callbacks 數組中,但這個被添加到 callbacks 數組中的函數的執行會間接調用 cb 回調函數,而且能夠看到在調用 cb 函數時使用 .call 方法將函數 cb 的做用域設置爲 ctx,也就是 nextTick 函數的第二個參數。因此對於 $nextTick 方法來說,傳遞給 $nextTick 方法的回調函數的做用域就是當前組件實例對象,固然了前提是回調函數不能是箭頭函數,其實在平時的使用中,回調函數使用箭頭函數也不要緊,只要你可以達到你的目的便可。另外咱們再次強調一遍,此時回調函數並無被執行,當你調用 $nextTick 方法並傳遞迴調函數時,會使用一個新的函數包裹回調函數並將新函數添加到 callbacks 數組中。

咱們繼續看 nextTick 函數的代碼,以下:

export function nextTick (cb?: Function, ctx?: Object) {
  // 省略...
  if (!pending) {
    pending = true
    if (useMacroTask) {
      macroTimerFunc()
    } else {
      microTimerFunc()
    }
  }
  // 省略...
}

在將回調函數添加到 callbacks 數組以後,會進行一個 if 條件判斷,判斷變量 pending 的真假,pending 變量也定義在文件頭部:let pending = false,它是一個標識,它的真假表明回調隊列是否處於等待刷新的狀態,初始值是 false 表明回調隊列爲空不須要等待刷新。假如此時在某個地方調用了 $nextTick 方法,那麼 if 語句塊內的代碼將會被執行,在 if 語句塊內優先將變量 pending 的值設置爲 true,表明着此時回調隊列不爲空,正在等待刷新。既然等待刷新,那麼固然要刷新回調隊列啊,怎麼刷新呢?這時就用到了咱們前面講過的 microTimerFunc 或者 macroTimerFunc 函數,咱們知道這兩個函數的做用是將 flushCallbacks 函數分別註冊爲 microtask 和 (macro)task。可是不管哪一種任務類型,它們都將會等待調用棧清空以後才執行。以下

created () {
  this.$nextTick(() => { console.log(1) })
  this.$nextTick(() => { console.log(2) })
  this.$nextTick(() => { console.log(3) })
}

上面的代碼中咱們在 created 鉤子中連續調用三次 $nextTick 方法,但只有第一次調用 $nextTick 方法時纔會執行 microTimerFunc 函數將 flushCallbacks 註冊爲 microtask,但此時 flushCallbacks 函數並不會執行,由於它要等待接下來的兩次 $nextTick 方法的調用語句執行完後纔會執行,或者準確的說等待調用棧被清空以後纔會執行。也就是說當 flushCallbacks 函數執行的時候,callbacks 回調隊列中將包含本次事件循環所收集的全部經過 $nextTick 方法註冊的回調,而接下來的任務就是在 flushCallbacks 函數內將這些回調所有執行並清空。以下是 flushCallbacks 函數的源碼

function flushCallbacks () {
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

很好理解,首先將變量 pending 重置爲 false,接着開始執行回調,但須要注意的是在執行 callbacks 隊列中的回調函數時並無直接遍歷 callbacks 數組,而是使用 copies 常量保存一份 callbacks 的複製,而後遍歷 copies 數組,而且在遍歷 copies 數組以前將 callbacks 數組清空:callbacks.length = 0。爲何要這麼作呢?這麼作確定是有緣由的,咱們模擬一下整個異步更新的流程就明白了,以下代碼:

created () {
  this.name = 'HcySunYang'
  this.$nextTick(() => {
    this.name = 'hcy'
    this.$nextTick(() => { console.log('第二個 $nextTick') })
  })
}

上面代碼中咱們在外層 $nextTick 方法的回調函數中再次調用了 $nextTick 方法,理論上外層 $nextTick 方法的回調函數不該該與內層 $nextTick 方法的回調函數在同一個 microtask 任務中被執行,而是兩個不一樣的 microtask 任務,雖然在結果上看或許沒什麼差異,但從設計角度就應該這麼作。

咱們注意上面代碼中咱們修改了兩次 name 屬性的值(假設它是響應式數據),首先咱們將 name 屬性的值修改成字符串 HcySunYang,咱們前面講過這會致使依賴於 name 屬性的渲染函數觀察者被添加到 queue 隊列中,這個過程是經過調用 src/core/observer/scheduler.js 文件中的 queueWatcher 函數完成的。同時在 queueWatcher 函數內會使用 nextTick 將 flushSchedulerQueue 添加到 callbacks 數組中,因此此時 callbacks 數組以下:

callbacks = [
flushSchedulerQueue // queue = [renderWatcher]]
同時會將 flushCallbacks 函數註冊爲 microtask,因此此時 microtask 隊列以下:

// microtask 隊列
[
flushCallbacks]
接着調用了第一個 $nextTick 方法,$nextTick 方法會將其回調函數添加到 callbacks 數組中,那麼此時的 callbacks 數組以下:

callbacks = [
flushSchedulerQueue, // queue = [renderWatcher]
() => {
this.name = 'hcy'
this.$nextTick(() => { console.log('第二個 $nextTick') })
}]
接下來主線程處於空閒狀態(調用棧清空),開始執行 microtask 隊列中的任務,即執行 flushCallbacks 函數,flushCallbacks 函數會按照順序執行 callbacks 數組中的函數,首先會執行 flushSchedulerQueue 函數,這個函數會遍歷 queue 中的全部觀察者並從新求值,完成從新渲染(re-render),在完成渲染以後,本次更新隊列已經清空,queue 會被重置爲空數組,一切狀態還原。接着會執行以下函數:

() => {
this.name = 'hcy'
this.$nextTick(() => { console.log('第二個 $nextTick') })
}
這個函數是第一個 $nextTick 方法的回調函數,因爲在執行該回調函數以前已經完成了從新渲染,因此該回調函數內的代碼是可以訪問更新後的DOM的,到目前爲止一切都很正常,咱們繼續往下看,在該回調函數內再次修改了 name 屬性的值爲字符串 hcy,這會再次觸發響應,一樣的會調用 nextTick 函數將 flushSchedulerQueue 添加到 callbacks 數組中,可是因爲在執行 flushCallbacks 函數時優先將 pending 的重置爲 false,因此 nextTick 函數會將 flushCallbacks 函數註冊爲一個新的 microtask,此時 microtask 隊列將包含兩個 flushCallbacks 函數:

// microtask 隊列
[
flushCallbacks, // 第一個 flushCallbacks
flushCallbacks // 第二個 flushCallbacks]
怎麼樣?咱們的目的達到了,如今有兩個 microtask 任務。

而另外除了將變量 pending 的值重置爲 false 以外,咱們要知道第一個 flushCallbacks 函數遍歷的並非 callbacks 自己,而是它的複製品 copies 數組,而且在第一個 flushCallbacks 函數的一開頭就清空了 callbacks 數組自己。因此第二個 flushCallbacks 函數的一切流程與第一個 flushCallbacks 是徹底相同。

最後咱們再來說一下,當調用 $nextTick 方法時不傳遞迴調函數時,是如何實現返回 Promise 實例對象的,實現很簡單咱們來看一下 nextTick 函數的代碼,以下:

export function nextTick (cb?: Function, ctx?: Object) {
let _resolve
// 省略...
// $flow-disable-line
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
如上高亮代碼所示,當 nextTick 函數沒有接收到 cb 參數時,會檢測當前宿主環境是否支持 Promise,若是支持則直接返回一個 Promise 實例對象,而且將 resolve 函數賦值給 _resolve 變量,_resolve 變量聲明在 nextTick 函數的頂部。同時再來看以下代碼:

export function nextTick (cb?: Function, ctx?: Object) {
let _resolve
callbacks.push(() => {
if (cb) {
try {
cb.call(ctx)
} catch (e) {
handleError(e, ctx, 'nextTick')
}
} else if (_resolve) {
_resolve(ctx)
}
})
// 省略...
// $flow-disable-line
if (!cb && typeof Promise !== 'undefined') {
return new Promise(resolve => {
_resolve = resolve
})
}
}
當 flushCallbacks 函數開始執行 callbacks 數組中的函數時,若是沒有傳遞 cb 參數,則直接調用 _resolve 函數,咱們知道這個函數就是返回的 Promise 實例對象的 resolve 函數。這樣就實現了 Promise 方式的 $nextTick 方法。

MutationObserver

補充知識點,在以前的版本微任務有MutationObserver的實現

MutationObserver是HTML5新增的屬性,用於監聽DOM修改事件,可以監聽到節點的屬性、文本內容、子節點等的改動,是一個功能強大的利器,基本用法以下:

//MO基本用法

var observer = new MutationObserver(function(){

  //這裏是回調函數

  console.log('DOM被修改了!');

});

var article = document.querySelector('article');

observer.observer(article);
if (typeof MutationObserver !== 'undefined' && (isNative(MutationObserver) || MutationObserver.toString() === '[object MutationObserverConstructor]')) {
    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)

  }

}
相關文章
相關標籤/搜索