淺析Vue.nextTick()原理

一、爲何用Vue.nextTick()

首先來了解一下JS的運行機制。html

JS運行機制(Event Loop)

JS執行是單線程的,它是基於事件循環的。vue

  1. 全部同步任務都在主線程上執行,造成一個執行棧。
  2. 主線程以外,會存在一個任務隊列,只要異步任務有告終果,就在任務隊列中放置一個事件。
  3. 當執行棧中的全部同步任務執行完後,就會讀取任務隊列。那些對應的異步任務,會結束等待狀態,進入執行棧。
  4. 主線程不斷重複第三步。

這裏主線程的執行過程就是一個tick,而全部的異步結果都是經過任務隊列來調度。Event Loop 分爲宏任務和微任務,不管是執行宏任務仍是微任務,完成後都會進入到一下tick並在兩個tick之間進行UI渲染react

因爲Vue DOM更新是異步執行的,即修改數據時,視圖不會當即更新,而是會監聽數據變化,並緩存在同一事件循環中,等同一數據循環中的全部數據變化完成以後,再統一進行視圖更新。爲了確保獲得更新後的DOM,因此設置了 Vue.nextTick()方法。git

二、什麼是Vue.nextTick()

是Vue的核心方法之一,官方文檔解釋以下:github

在下次DOM更新循環結束以後執行延遲迴調。在修改數據以後當即使用這個方法,獲取更新後的DOM。
MutationObserver

先簡單介紹下MutationObserver:MO是HTML5中的API,是一個用於監視DOM變更的接口,它能夠監聽一個DOM對象上發生的子節點刪除、屬性修改、文本內容修改等。數組

調用過程是要先給它綁定回調,獲得MO實例,這個回調會在MO實例監聽到變更時觸發。這裏MO的回調是放在microtask中執行的。promise

// 建立MO實例
const observer = new MutationObserver(callback)

const textNode = '想要監聽的Don節點'

observer.observe(textNode, {
    characterData: true // 說明監聽文本內容的修改
})
源碼淺析

nextTick 的實現單獨有一個JS文件來維護它,在src/core/util/next-tick.js中。瀏覽器

nextTick 源碼主要分爲兩塊:能力檢測和根據能力檢測以不一樣方式執行回調隊列。緩存

能力檢測

因爲宏任務耗費的時間是大於微任務的,因此在瀏覽器支持的狀況下,優先使用微任務。若是瀏覽器不支持微任務,再使用宏任務。安全

// 空函數,可用做函數佔位符
import { noop } from 'shared/util' 

 // 錯誤處理函數
import { handleError } from './error'

 // 是不是IE、IOS、內置函數
import { isIE, isIOS, isNative } from './env'

// 使用 MicroTask 的標識符,這裏是由於火狐在<=53時 沒法觸發微任務,在modules/events.js文件中引用進行安全排除
export let isUsingMicroTask = false 

 // 用來存儲全部須要執行的回調函數
const callbacks = []

// 用來標誌是否正在執行回調函數
let pending = false 

// 對callbacks進行遍歷,而後執行相應的回調函數
function flushCallbacks () {
    pending = false
    // 這裏拷貝的緣由是:
    // 有的cb 執行過程當中又會往callbacks中加入內容
    // 好比 $nextTick的回調函數裏還有$nextTick
    // 後者的應該放到下一輪的nextTick 中執行
    // 因此拷貝一份當前的,遍歷執行完當前的便可,避免無休止的執行下去
    const copies = callbcks.slice(0)
    callbacks.length = 0
    for(let i = 0; i < copies.length; i++) {
        copies[i]()
    }
}

let timerFunc // 異步執行函數 用於異步延遲調用 flushCallbacks 函數

// 在2.5中,咱們使用(宏)任務(與微任務結合使用)。
// 然而,當狀態在從新繪製以前發生變化時,就會出現一些微妙的問題
// (例如#6813,out-in轉換)。
// 一樣,在事件處理程序中使用(宏)任務會致使一些奇怪的行爲
// 所以,咱們如今再次在任何地方使用微任務。
// 優先使用 Promise
if(typeof Promise !== 'undefined' && isNative(Promise)) {
    const p = Promise.resolve()
    timerFunc = () => {
        p.then(flushCallbacks)
        
        // IOS 的UIWebView, Promise.then 回調被推入 microTask 隊列,可是隊列可能不會如期執行
        // 所以,添加一個空計時器強制執行 microTask
        if(isIOS) setTimeout(noop)
    }
    isUsingMicroTask = true
} else if(!isIE && typeof MutationObserver !== 'undefined' && (isNative(MutationObserver) || MutationObserver.toString === '[object MutationObserverConstructor]')) {
    // 當 原生Promise 不可用時,使用 原生MutationObserver
    // e.g. PhantomJS, iOS7, Android 4.4
 
    let counter = 1
    // 建立MO實例,監聽到DOM變更後會執行回調flushCallbacks
    const observer = new MutationObserver(flushCallbacks)
    const textNode = document.createTextNode(String(counter))
    observer.observe(textNode, {
        characterData: true // 設置true 表示觀察目標的改變
    })
    
    // 每次執行timerFunc 都會讓文本節點的內容在 0/1之間切換
    // 切換以後將新值複製到 MO 觀測的文本節點上
    // 節點內容變化會觸發回調
    timerFunc = () => {
        counter = (counter + 1) % 2
        textNode.data = String(counter) // 觸發回調
    }
    isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
    timerFunc = () => {
        setImmediate(flushCallbacks)
    }
} else {
    timerFunc = () => {
        setTimeout(flushCallbacks, 0)
    }
}

延遲調用優先級以下:
Promise > MutationObserver > setImmediate > setTimeout

export function nextTick(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)
        }
    })
    
    // pending 爲false 說明本輪事件循環中沒有執行過timerFunc()
    if(!pending) {
        pending = true
        timerFunc()
    }
    
    // 當不傳入 cb 參數時,提供一個promise化的調用 
    // 如nextTick().then(() => {})
    // 當_resolve執行時,就會跳轉到then邏輯中
    if(!cb && typeof Promise !== 'undefined') {
        return new Promise(resolve => {
            _resolve = resolve
        })
    }
}

next-tick.js 對外暴露了nextTick這一個參數,因此每次調用Vue.nextTick時會執行:

  • 把傳入的回調函數cb壓入callbacks數組
  • 執行timerFunc函數,延遲調用 flushCallbacks 函數
  • 遍歷執行 callbacks 數組中的全部函數

這裏的 callbacks 沒有直接在 nextTick 中執行回調函數的緣由是保證在同一個 tick 內屢次執行nextTick,不會開啓多個異步任務,而是把這些異步任務都壓成一個同步任務,在下一個 tick 執行完畢。

附加

noop 的定義以下

/**
 * Perform no operation.
 * Stubbing args to make Flow happy without leaving useless transpiled code
 * with ...rest (https://flow.org/blog/2017/05/07/Strict-Function-Call-Arity/).
 */
export function noop (a?: any, b?: any, c?: any) {}

三、怎麼用

語法Vue.nextTick([callback, context])

參數

  • {Function} [callback]:回調函數,不傳時提供promise調用
  • {Object} [context]:回調函數執行的上下文環境,不傳默認是自動綁定到調用它的實例上。
//改變數據
vm.message = 'changed'

//想要當即使用更新後的DOM。這樣不行,由於設置message後DOM尚未更新
console.log(vm.$el.textContent) // 並不會獲得'changed'

//這樣能夠,nextTick裏面的代碼會在DOM更新後執行
Vue.nextTick(function(){
    // DOM 更新了
    //能夠獲得'changed'
    console.log(vm.$el.textContent)
})

// 做爲一個 Promise 使用 即不傳回調
Vue.nextTick()
  .then(function () {
    // DOM 更新了
  })

Vue實例方法vm.$nextTick作了進一步封裝,把context參數設置成當前Vue實例。

四、小結

使用Vue.nextTick()是爲了能夠獲取更新後的DOM 。
觸發時機:在同一事件循環中的數據變化後,DOM完成更新,當即執行Vue.nextTick()的回調。

同一事件循環中的代碼執行完畢 -> DOM 更新 -> nextTick callback觸發

1596618069-5a5da8c8522c2_articlex

應用場景:

  • 在Vue生命週期的created()鉤子函數進行的DOM操做必定要放在Vue.nextTick()的回調函數中。

    緣由:是created()鉤子函數執行時DOM其實並未進行渲染。

  • 在數據變化後要執行的某個操做,而這個操做須要使用隨數據改變而改變的DOM結構的時候,這個操做應該放在Vue.nextTick()的回調函數中。

    緣由:Vue異步執行DOM更新,只要觀察到數據變化,Vue將開啓一個隊列,並緩衝在同一事件循環中發生的全部數據改變,若是同一個watcher被屢次觸發,只會被推入到隊列中一次。

版本分析

2.6 版本優先使用 microtask 做爲異步延遲包裝器,且寫法相對簡單。而2.5 版本中,nextTick 的實現是 microTimerFunc、macroTimerFunc 組合實現的,延遲調用優先級是:Promise > setImmediate > MessageChannel > setTimeout,具體見源碼。

2.5 版本在重繪以前狀態改變時會有小問題(如 #6813)。此外,在事件處理程序中使用 macrotask 會致使一些沒法規避的奇怪行爲(如 #7109#7153等)。

microtask 在某些狀況下也是會有問題的,由於 microtask 優先級比較高,事件會在順序事件(如#4521#6690 有變通方法)之間甚至在同一事件的冒泡過程當中觸發(#6566)。

參考:

相關文章
相關標籤/搜索