你真的理解$nextTick麼

子弈發表於2019-05-13

在掘金刷到有人寫$nextTick,這裡我把我以前的這篇分析文章拿出來給大家看看,希望對大家有所啟迪,這裡是我寫的原文連結地址Vue原始碼分析 - nextTick。可能文中有些表述不是很嚴謹,大家見諒。

順便推薦大家看一篇非常好的文章Tasks, microtasks, queues and schedules,看完絕對有所收穫。

為什麼是nextTick

這裡猜測一下為什麼Vue有一個API叫nextTick

瀏覽器

瀏覽器(多程式)包含了Browser程式(瀏覽器的主程式)、第三方外掛程式GPU程式(瀏覽器渲染程式),其中GPU程式(多執行緒)和Web前端密切相關,包含以下執行緒:

  • GUI渲染執行緒
  • JS引擎執行緒
  • 事件觸發執行緒(和EventLoop密切相關)
  • 定時觸發器執行緒
  • 非同步HTTP請求執行緒

GUI渲染執行緒JS引擎執行緒是互斥的,為了防止DOM渲染的不一致性,其中一個執行緒執行時另一個執行緒會被掛起。

這些執行緒中,和Vue的nextTick息息相關的是JS引擎執行緒事件觸發執行緒

JS引擎執行緒和事件觸發執行緒

瀏覽器頁面初次渲染完畢後,JS引擎執行緒結合事件觸發執行緒的工作流程如下:

(1)同步任務在JS引擎執行緒(主執行緒)上執行,形成執行棧(Execution Context Stack)。

(2)主執行緒之外,事件觸發執行緒管理著一個任務佇列(Task Queue)。只要非同步任務有了執行結果,就在任務佇列之中放置一個事件。

(3)執行棧中的同步任務執行完畢,系統就會讀取任務佇列,如果有非同步任務需要執行,將其加到主執行緒的執行棧並執行相應的非同步任務。

主執行緒的執行流程如下圖所示:

你真的理解$nextTick麼

這裡可能是不夠嚴謹的,在本文中事件佇列任務佇列指向同一個概念。

事件迴圈機制(Event Loop)

事件觸發執行緒管理的任務佇列是如何產生的呢?事實上這些任務就是從JS引擎執行緒本身產生的,主執行緒在執行時會產生執行棧,棧中的程式碼呼叫某些非同步API時會在任務佇列中新增事件,棧中的程式碼執行完畢後,就會讀取任務佇列中的事件,去執行事件對應的回撥函式,如此迴圈往復,形成事件迴圈機制,如下圖所示:

你真的理解$nextTick麼

任務型別

JS中有兩種任務型別:微任務(microtask)和巨集任務(macrotask),在ES6中,microtask稱為 jobs,macrotask稱為 task。

巨集任務: script (主程式碼塊)、setTimeoutsetIntervalsetImmediate 、I/O 、UI rendering

微任務process.nextTick(Nodejs) 、promiseObject.observeMutationObserver

這裡要重點說明一下,巨集任務並非全是非同步任務,主程式碼塊就是屬於巨集任務的一種(Promises/A+規範)。

它們之間區別如下:

  • 巨集任務是每次執行棧執行的程式碼(包括每次從事件佇列中獲取一個事件回撥並放到執行棧中執行)
  • 瀏覽器為了能夠使得JS引擎執行緒GUI渲染執行緒有序切換,會在當前巨集任務結束之後,下一個巨集任務執行開始之前,對頁面進行重新渲染(巨集任務 > 渲染 > 巨集任務 > ...)
  • 微任務是在當前巨集任務執行結束之後立即執行的任務(在當前 巨集任務執行之後,UI渲染之前執行的任務)。微任務的響應速度相比setTimeout(下一個巨集任務)會更快,因為無需等待UI渲染。
  • 當前巨集任務執行後,會將在它執行期間產生的所有微任務都執行一遍。

自我灌輸一下自己的理解:

  • 巨集任務中的事件是由事件觸發執行緒來維護的
  • 微任務中的所有任務是由JS引擎執行緒維護的(這只是自我猜測,因為巨集任務執行完畢後會立即執行微任務,為了提升效能,這種無縫連線的操作放在事件觸發執行緒來維護明顯是不合理的)。

根據事件迴圈機制,重新梳理一下流程:

  • 執行一個巨集任務(首次執行的主程式碼塊或者任務佇列中的回撥函式)
  • 執行過程中如果遇到微任務,就將它新增到微任務的任務佇列中
  • 巨集任務執行完畢後,立即執行當前微任務佇列中的所有任務(依次執行)
  • JS引擎執行緒掛起,GUI執行緒執行渲染
  • GUI執行緒渲染完畢後掛起,JS引擎執行緒執行任務佇列中的下一個巨集任務

舉個例子,以下示例無法直觀的表述UI渲染執行緒的接管過程,只是表述了JS引擎執行緒的執行流程:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Document</title>

  <style>
    .outer {
      height: 200px;
      background-color: red;
      padding: 10px;
    }

    .inner {
      height: 100px;
      background-color: blue;
      margin-top: 50px;
    }
  </style>
</head>
<body>
  <div class="outer">
    <div class="inner"></div>
  </div>
</body>

<script>
let inner = document.querySelector('.inner')
let outer = document.querySelector('.outer')

// 監聽outer元素的attribute變化
new MutationObserver(function() {
  console.log('mutate')
}).observe(outer, {
  attributes: true
})

// click監聽事件
function onClick() {
  console.log('click')

  setTimeout(function() {
    console.log('timeout')
  }, 0)

  Promise.resolve().then(function() {
    console.log('promise')
  })

  outer.setAttribute('data-random', Math.random())
}

inner.addEventListener('click', onClick)

</script>
</html>
複製程式碼

點選inner元素列印的順序是:建議放入瀏覽器驗證。

觸發的click事件會加入巨集任務佇列,MutationObserverPromise的回撥會加入微任務佇列,setTimeout加入到巨集任務佇列,對應的任務用物件直觀的表述一下(自我認知的一種表述,只有參考價值):

{
 // tasks是巨集任務佇列
  tasks: [{
	script: '主程式碼塊'
  }, {
    script: 'click回撥函式',
   // microtasks是微任務佇列
    microtasks: [{ 
      script: 'Promise'
    }, {
      script: 'MutationObserver'
    }]
  }, {
    script: 'setTimeout'
  }]
}
複製程式碼

稍微增加一下程式碼的複雜度,在原有的基礎上給outer元素新增一個click監聽事件:

outer.addEventListener('click', onClick)
複製程式碼

點選inner元素列印的順序是:建議放入瀏覽器驗證。

由於冒泡,click函式再一次執行了,對應的任務用物件直觀的表述一下(自我認知的一種表述,只有參考價值):

{
  tasks: [{
	script: '主程式碼塊'
  }, {
    script: 'innter的click回撥函式',
    microtasks: [{
      script: 'Promise'
    }, {
      script: 'MutationObserver'
    }]
  }, {
    script: 'outer的click回撥函式',
    microtasks: [{
      script: 'Promise'
    }, {
      script: 'MutationObserver'
    }]
  }, {
    script: 'setTimeout'
  }, {
    script: 'setTimeout'
  }]
}
複製程式碼

Node.js中的process.nextTick

Node.js中有一個nextTick函式和Vue中的nextTick命名一致,很容易讓人聯想到一起(Node.js的Event Loop和瀏覽器的Event Loop有差異)。重點講解一下Node.js中的nextTick的執行機制,簡單的舉個例子:

setTimeout(function() {
  console.log('timeout')
})

process.nextTick(function(){
  console.log('nextTick 1')
})

new Promise(function(resolve){
  console.log('Promise 1')
  resolve();
  console.log('Promise 2')
}).then(function(){
  console.log('Promise Resolve')
})

process.nextTick(function(){
  console.log('nextTick 2')
})
複製程式碼

在Node環境(10.3.0版本)中列印的順序: Promise 1 > Promise 2 > nextTick 1 > nextTick 2 > Promise Resolve > timeout

在Node.js的v10.x版本中對於process.nextTick的說明如下:

The process.nextTick() method adds the callback to the "next tick queue". Once the current turn of the event loop turn runs to completion, all callbacks currently in the next tick queue will be called. This is not a simple alias to setTimeout(fn, 0). It is much more efficient. It runs before any additional I/O events (including timers) fire in subsequent ticks of the event loop.

Vue的API命名nextTick

Vue官方對nextTick這個API的描述:

在下次 DOM 更新迴圈結束之後執行延遲迴調。在修改資料之後立即使用這個方法,獲取更新後的 DOM。

// 修改資料
vm.msg = 'Hello'
// DOM 還沒有更新
Vue.nextTick(function () {
  // DOM 更新了
})

// 作為一個 Promise 使用 (2.1.0 起新增,詳見接下來的提示)
Vue.nextTick()
 .then(function () {
  // DOM 更新了
})
複製程式碼

2.1.0 起新增:如果沒有提供回撥且在支援 Promise 的環境中,則返回一個 Promise。請注意 Vue 不自帶 Promise 的 polyfill,所以如果你的目標瀏覽器不原生支援 Promise (IE:你們都看我幹嘛),你得自己提供 polyfill。 0

可能你還沒有注意到,Vue 非同步執行 DOM 更新。只要觀察到資料變化,Vue 將開啟一個佇列,並緩衝在同一事件迴圈中發生的所有資料改變。如果同一個 watcher 被多次觸發,只會被推入到佇列中一次。這種在緩衝時去除重複資料對於避免不必要的計算和 DOM 操作上非常重要。然後,在下一個的事件迴圈“tick”中,Vue 重新整理佇列並執行實際 (已去重的) 工作。Vue 在內部嘗試對非同步佇列使用原生的 Promise.then 和 MessageChannel,如果執行環境不支援,會採用 setTimeout(fn, 0) 代替。

例如,當你設定 vm.someData = 'new value' ,該元件不會立即重新渲染。當重新整理佇列時,元件會在事件迴圈佇列清空時的下一個“tick”更新。多數情況我們不需要關心這個過程,但是如果你想在 DOM 狀態更新後做點什麼,這就可能會有些棘手。雖然 Vue.js 通常鼓勵開發人員沿著“資料驅動”的方式思考,避免直接接觸 DOM,但是有時我們確實要這麼做。為了在資料變化之後等待 Vue 完成更新 DOM ,可以在資料變化之後立即使用 Vue.nextTick(callback) 。這樣回撥函式在 DOM 更新完成後就會呼叫。

Vue對於這個API的感情是曲折的,在2.4版本、2.5版本和2.6版本中對於nextTick進行反覆變動,原因是瀏覽器對於微任務的不相容性影響、微任務巨集任務各自優缺點的權衡。

你真的理解$nextTick麼

看以上流程圖,如果Vue使用setTimeout巨集任務函式,那麼勢必要等待UI渲染完成後的下一個巨集任務執行,而如果Vue使用微任務函式,無需等待UI渲染完成才進行nextTick的回撥函式操作,可以想象在JS引擎執行緒GUI渲染執行緒之間來回切換,以及等待GUI渲染執行緒的過程中,瀏覽器勢必要消耗效能,這是一個嚴謹的框架完全需要考慮的事情。

當然這裡所說的只是nextTick執行使用者回撥之後的效能情況考慮,這中間當然不能忽略flushBatcherQueue更新Dom的操作,使用非同步函式的另外一個作用當然是要確保同步程式碼執行完畢Dom更新之後我們可以拿到Dom的資料。

到了這裡,對於Vue中nextTick函式的命名應該是瞭然於心了,當然這個命名不知道和Node.js的process.nextTick還有沒有什麼必然聯絡。

Vue中NextTick原始碼(這裡加了一些簡單的註釋說明)

2.5版本

/* @flow */
/* globals MessageChannel */

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]()
  }
}

// 在2.4中使用了microtasks ,但是還是存在問題,
// 在2.5版本中組合使用macrotasks和microtasks,組合使用的方式是對外暴露withMacroTask函式
// 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).

// 2.5版本在nextTick中對於呼叫microtask(微任務)還是macrotask(巨集任務)宣告瞭兩個不同的變數
let microTimerFunc
let macroTimerFunc

// 預設使用microtask(微任務)
let useMacroTask = false


// 這裡主要定義macrotask(巨集任務)函式
// macrotask(巨集任務)的執行優先順序
// setImmediate -> MessageChannel -> setTimeout
// setImmediate是最理想的選擇
// 最Low的狀況是降級執行setTimeout

// 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)
  }
}


// 這裡主要定義microtask(微任務)函式
// microtask(微任務)的執行優先順序
// Promise -> macroTimerFunc
// 如果原生不支援Promise,那麼執行macrotask(巨集任務)函式

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


// 對外暴露withMacroTask 函式
// 觸發變化執行nextTick時強制執行macrotask(巨集任務)函式

/**
 * 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
    try {
      return fn.apply(null, arguments)
    } finally {
      useMacroTask = false    
    }
  })
}

// 這裡需要注意pending
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
    })
  }
}
複製程式碼

2.6版本

/* @flow */
/* globals MutationObserver */

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

export let isUsingMicroTask = false

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]()
  }
}

// 在2.5版本中組合使用microtasks 和macrotasks,但是重繪的時候還是存在一些小問題,而且使用macrotasks在任務佇列中會有幾個特別奇怪的行為沒辦法避免,So又回到了之前的狀態,在任何地方優先使用microtasks 。
// Here we have async deferring wrappers using microtasks.
// In 2.5 we used (macro) tasks (in combination with microtasks).
// However, it has subtle problems when state is changed right before repaint
// (e.g. #6813, out-in transitions).
// Also, using (macro) tasks in event handler would cause some weird behaviors
// that cannot be circumvented (e.g. #7109, #7153, #7546, #7834, #8109).
// So we now use microtasks everywhere, again.
// A major drawback of this tradeoff is that there are some scenarios
// where microtasks have too high a priority and fire in between supposedly
// sequential events (e.g. #4521, #6690, which have workarounds)
// or even between bubbling of the same event (#6566).
let timerFunc

// 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 next, $flow-disable-line */


// task的執行優先順序
// Promise -> MutationObserver -> setImmediate -> setTimeout

if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  timerFunc = () => {
    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)
  }
  isUsingMicroTask = true
} else if (!isIE && 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, iOS7, Android 4.4
  // (#6466 MutationObserver is unreliable in IE11)
  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
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  // Fallback to setImmediate.
  // Techinically it leverages the (macro) task queue,
  // but it is still a better choice than setTimeout.
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  // Fallback to setTimeout.
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

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
    timerFunc()
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}
複製程式碼

總結

本文的表述可能存在一些不嚴謹的地方。

參考文獻

相關文章