從 javascript 事件循環看 Vue.nextTick 的原理和執行機制

拋磚引玉

Vue 的特色之一就是響應式,可是有些時候數據更新了,咱們看到頁面上的 DOM 並無馬上更新。若是咱們須要在 DOM 更新以後再執行一段代碼時,能夠藉助 nextTick 實現。javascript

咱們先來看一個例子html

export default {
  data() {
    return {
      msg: 0
    }
  },
  mounted() {
    this.msg = 1
    this.msg = 2
    this.msg = 3
  },
  watch: {
    msg() {
      console.log(this.msg)
    }
  }
}

這裏的結果是隻輸出一個 3,而非依次輸出 1,2,3。這是爲何呢?
vue 的官方文檔是這樣解釋的前端

Vue 異步執行 DOM 更新。只要觀察到數據變化,Vue 將開啓一個隊列,並緩衝在同一事件循環中發生的全部數據改變。若是同一個 watcher 被屢次觸發,只會被推入到隊列中一次。這種在緩衝時去除重複數據對於避免沒必要要的計算和 DOM 操做上很是重要。而後,在下一個的事件循環「tick」中,Vue 刷新隊列並執行實際 (已去重的) 工做。Vue 在內部嘗試對異步隊列使用原生的 Promise.thenMessageChannel,若是執行環境不支持,會採用 setTimeout(fn, 0)代替。

假若有這樣一種狀況,mounted鉤子函數下一個變量 a 的值會被++循環執行 1000 次。 每次++時,都會根據響應式觸發setter->Dep->Watcher->update->run。 若是這時候沒有異步更新視圖,那麼每次++都會直接操做 DOM 一次,這是很是消耗性能的。 因此 Vue 實現了一個queue隊列,在下一個 Tick(或者是當前 Tick 的微任務階段)的時候會統一執行queueWatcherrun。同時,擁有相同 id 的Watcher不會被重複加入到該queue中去,因此不會執行 1000 次Watcherrun。最終的結果是直接把 a 的值從 1 變成 1000,大大提高了性能。vue

在 vue 中,數據監測都是經過Object.defineProperty來重寫裏面的 set 和 get 方法實現的,vue 更新 DOM 是異步的,每當觀察到數據變化時,vue 就開始一個隊列,將同一事件循環內全部的數據變化緩存起來,等到下一次 eventLoop,將會把隊列清空,進行 DOM 更新。html5

想要了解 vue.nextTick 的執行機制,咱們先來了解一下 javascript 的事件循環。java

js 事件循環

js 的任務隊列分爲同步任務和異步任務,全部的同步任務都是在主線程裏執行的。異步任務可能會在 macrotask 或者 microtask 裏面,異步任務進入 Event Table 並註冊函數。當指定的事情完成時,Event Table 會將這個函數移入 Event Queue。主線程內的任務執行完畢爲空,會去 Event Queue 讀取對應的函數,進入主線程執行。上述過程會不斷重複,也就是常說的 Event Loop(事件循環)。webpack

macro-task(宏任務):

每次執行棧執行的代碼就是一個宏任務(包括每次從事件隊列中獲取一個事件回調並放到執行棧中執行)。瀏覽器爲了可以使得 js 內部(macro)task與 DOM 任務可以有序執行,會在一個(macro)task執行結束後,在下一個(macro)task執行開始前,對頁面進行從新渲染。宏任務主要包含:web

  • script(總體代碼)
  • setTimeout / setInterval
  • setImmediate(Node.js 環境)
  • I/O
  • UI render
  • postMessage
  • MessageChannel

micro-task(微任務):

能夠理解是在當前 task 執行結束後當即執行的任務。也就是說,在當前 task 任務後,下一個 task 以前,在渲染以前。因此它的響應速度相比 setTimeout(setTimeout 是 task)會更快,由於無需等渲染。也就是說,在某一個 macrotask 執行完後,就會將在它執行期間產生的全部 microtask 都執行完畢(在渲染前)。microtask 主要包含:算法

  • process.nextTick(Node.js 環境)
  • Promise
  • Async/Await
  • MutationObserver(html5 新特性)

小結

  1. 先執行主線程
  2. 遇到宏隊列(macrotask)放到宏隊列(macrotask)
  3. 遇到微隊列(microtask)放到微隊列(microtask)
  4. 主線程執行完畢
  5. 執行微隊列(microtask),微隊列(microtask)執行完畢
  6. 執行一次宏隊列(macrotask)中的一個任務,執行完畢
  7. 執行微隊列(microtask),執行完畢
  8. 依次循環。。。

Vue.nextTick 源碼

vue 是採用雙向數據綁定的方法驅動數據更新的,雖然這樣能避免直接操做 DOM,提升了性能,但有時咱們也不可避免須要操做 DOM,這時就該 Vue.nextTick(callback)出場了,它接受一個回調函數,在 DOM 更新完成後,這個回調函數就會被調用。不論是 vue.nextTick 仍是vue.prototype.\$nextTick 都是直接用的nextTick這個閉包函數。npm

export const nextTick = (function () {
  const callbacks = []
  let pending = false
  let timerFunc

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

使用數組callbacks保存回調函數,pending表示當前狀態,使用函數nextTickHandler 來執行回調隊列。在該方法內,先經過slice(0)保存了回調隊列的一個副本,經過設置 callbacks.length = 0清空回調隊列,最後使用循環執行在副本里的全部函數。

if (typeof Promise !== 'undefined' && isNative(Promise)) {
  var p = Promise.resolve()
  var logError = err => {
    console.error(err)
  }
  timerFunc = () => {
    p.then(nextTickHandler).catch(logError)
    if (isIOS) setTimeout(noop)
  }
} else 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)
  }
} else {
  timeFunc = () => {
    setTimeout(nextTickHandle, 0)
  }
}

隊列控制的最佳選擇是microtask,而microtask的最佳選擇是Promise。但若是當前環境不支持 Promise,就檢測到瀏覽器是否支持 MO,是則建立一個文本節點,監聽這個文本節點的改動事件,以此來觸發nextTickHandler(也就是 DOM 更新完畢回調)的執行。此外由於兼容性問題,vue 不得不作了microtaskmacrotask 的降級方案。

爲讓這個回調函數延遲執行,vue 優先用promise來實現,其次是 html5 的 MutationObserver,而後是setTimeout。前二者屬於microtask,後一個屬於 macrotask。下面來看最後一部分。

return function queueNextTick(cb?: Function, ctx?: Object) {
  let _resolve
  callbacks.push(() => {
    if (cb) cb.call(ctx)
    if (_resolve) _resolve(ctx)
  })
  if (!pending) {
    pending = true
    timerFunc()
  }
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

這就是咱們真正調用的nextTick函數,在一個event loop內它會將調用 nextTickcb 回調函數都放入 callbacks 中,pending 用於判斷是否有隊列正在執行回調,例若有可能在 nextTick 中還有一個 nextTick,此時就應該屬於下一個循環了。最後幾行代碼是 promise 化,能夠將 nextTick 按照 promise 方式去書寫(暫且用的較少)。

應用場景

場景1、點擊按鈕顯示本來以 v-show = false 隱藏起來的輸入框,並獲取焦點。

<input id="keywords" v-if="showit">

showInput(){
  this.showit = true
  document.getElementById("keywords").focus()
}

以上的寫法在第一個 tick 裏,由於獲取不到輸入框,天然也獲取不到焦點。若是咱們改爲如下的寫法,在 DOM 更新後就能夠獲取到輸入框焦點了。

showsou(){
  this.showit = true
  this.$nextTick(function () {
    // DOM 更新了
    document.getElementById("keywords").focus()
  })
}

場景2、獲取元素屬,點擊獲取元素寬度。

<div id="app">
  <p ref="myWidth" v-if="showMe">{{ message }}</p>
  <button @click="getMyWidth">獲取p元素寬度</button>
</div>

getMyWidth() {
  this.showMe = true;
  this.message = this.$refs.myWidth.offsetWidth;
  //報錯 TypeError: this.$refs.myWidth is undefined
  this.$nextTick(()=>{
      //dom元素更新後執行,此時能拿到p元素的屬性
    this.message = this.$refs.myWidth.offsetWidth;
  })
}

推薦文章

總結javascript處理異步的方法
總結移動端H5開發經常使用技巧(乾貨滿滿哦!)
從零開始構建一個webpack項目
總結幾個webpack打包優化的方法
總結前端性能優化的方法
總結vue知識體系之高級應用篇
總結vue知識體系之實用技巧
幾種常見的JS遞歸算法
封裝一個toast和dialog組件併發布到npm
一文讀盡前端路由、後端路由、單頁面應用、多頁面應用
淺談JavaScript的防抖與節流

相關文章
相關標籤/搜索