Vue 之 nextTick 原理

佩奇烹飪家發表於2022-05-28

image.png

背景

知其然且知其所以然,Vue 作為目前最為主流的前端 MVVM 框架之一,在熟練使用的基礎上,去深入理解其實現原理是非常有意義的一件事情。

閱讀 Vue 原始碼就是一個很好的學習方式,不僅可以讓我們幫助我們更快解決工作中遇到的問題,也能借鑑優秀原始碼的經驗,學習高手發現問題、思考問題、解決問題的思路,學習怎麼寫出規範又好維護的高質量程式碼。

本文部分文字及程式碼片段主要來自於 Vue官方文件和稀土掘金社群,引用參考文獻出處均在文章末尾顯著標註。如有侵權,請聯絡刪除。

?溫馨提示:本文全文 3277 字,推薦閱讀時間 15分鐘,加油老鐵!

一、非同步更新佇列

可能你還沒有注意到,Vue 在更新 DOM 時是非同步執行的。只要偵聽到資料變化,Vue 將開啟一個佇列,並緩衝在同一事件迴圈中發生的所有資料變更。如果同一個 Watcher 被多次觸發,只會被推入到佇列中一次。這種在緩衝時去除重複資料對於避免不必要的計算和 DOM 操作是非常重要的。然後,在下一個的事件迴圈“tick”中,Vue 重新整理佇列並執行實際 (已去重的) 工作。

Vue 在內部對非同步佇列嘗試使用原生的 Promise.thenMutationObserversetImmediate,如果執行環境不支援,則會採用 setTimeout(fn, 0) 代替。

例如,當你設定 vm.someData = 'new value',該元件不會立即重新渲染。當重新整理佇列時,元件會在下一個事件迴圈 “tick” 中更新。多數情況我們不需要關心這個過程,但是如果你想基於更新後的 DOM 狀態來做點什麼,這就可能會有些棘手。

雖然 Vue.js 通常鼓勵開發人員使用“資料驅動”的方式思考,避免直接接觸 DOM,但是有時我們必須要這麼做。為了在資料變化之後等待 Vue 完成更新 DOM,可以在資料變化之後立即使用 Vue.nextTick(callback)。這樣回撥函式將在 DOM 更新完成後被呼叫。例如:

<div id="example">{{message}}</div>
var vm = new Vue({
    el: '#example',
    data: {
        message: '123'
    }
});
vm.message = 'new message'; // 更改資料
vm.$el.textContent === 'new message'; // false
Vue.nextTick(function () {
    vm.$el.textContent === 'new message'; // true
});

在元件內使用 vm.$nextTick() 例項方法特別方便,因為它不需要全域性 Vue,並且回撥函式中的 this 將自動繫結到當前的 Vue 例項上:

Vue.component('example', {
    template: '<span>{{ message }}</span>',
    data: function () {
        return {
            message: '未更新'
        };
    },
    methods: {
        updateMessage: function () {
            this.message = '已更新';
            console.log(this.$el.textContent); // => '未更新'
            this.$nextTick(function () {
                console.log(this.$el.textContent); // => '已更新'
            });
        }
    }
});

因為 $nextTick() 返回一個 Promise 物件,所以你可以使用新的 ES2017 async/await 語法完成相同的事情:

methods: {
        updateMessage: async function () {
            this.message = '已更新';
            console.log(this.$el.textContent); // => '未更新'
            await this.$nextTick();
            console.log(this.$el.textContent); // => '已更新'
        }
    }

nextTick接收一個回撥函式作為引數,並將這個回撥函式延遲到DOM更新後才執行;nextTick 是在下次 DOM 更新迴圈結束之後執行延遲迴調,在修改資料之後使用nextTick,則可以在回撥中獲取更新後的 DOM

使用場景:想要操作 基於最新資料的生成DOM 時,就將這個操作放在 nextTick 的回撥中;

二、前置知識

nextTick 函式的作用可以理解為非同步執行傳入的函式,這裡先介紹一下什麼是非同步執行,從 JS 執行機制說起。

2.1 JS 執行機制

JS 的執行是單執行緒的,所謂的單執行緒就是事件任務要排隊執行,前一個任務結束,才會執行後一個任務,這就是同步任務,為了避免前一個任務執行了很長時間還沒結束,那下一個任務就不能執行的情況,引入了非同步任務的概念。JS 執行機制簡單來說可以按以下幾個步驟。

  • 所有同步任務都在主執行緒上執行,形成一個執行棧(execution context stack)。
  • 主執行緒之外,還存在一個任務佇列(task queue)。只要非同步任務有了執行結果,會把其回撥函式作為一個任務新增到任務佇列中。
  • 一旦執行棧中的所有同步任務執行完畢,就會讀取任務佇列,看看裡面有那些任務,將其新增到執行棧,開始執行。
  • 主執行緒不斷重複上面的第三步。也就是常說的事件迴圈(Event Loop)。

2.2 非同步任務的型別

nextTick 函式非同步執行傳入的函式,是一個非同步任務。非同步任務分為兩種型別。

主執行緒的執行過程就是一個 tick,而所有的非同步任務都是通過任務佇列來一一執行。任務佇列中存放的是一個個的任務(task)。規範中規定 task 分為兩大類,分別是巨集任務(macro task)和微任務 (micro task),並且每個 macro task 結束後,都要清空所有的 micro task

用一段程式碼形象介紹 task的執行順序。

for (macroTask of macroTaskQueue) {
    handleMacroTask();
    for (microTask of microTaskQueue) {
        handleMicroTask(microTask);
    }
}

在瀏覽器環境中, 常見的建立 macro task 的方法有

  • setTimeoutsetIntervalpostMessageMessageChannel(佇列優先於setTimeiout執行)
  • 網路請求IO
  • 頁面互動:DOM、滑鼠、鍵盤、滾動事件
  • 頁面渲染

常見的建立 micro task 的方法

  • Promise.then
  • MutationObserve
  • process.nexttick

nextTick 函式要利用這些方法把通過引數 cb 傳入的函式處理成非同步任務。

三、nextTick 實現原理

將傳入的回撥函式包裝成非同步任務,非同步任務又分微任務和巨集任務,為了儘快執行所以優先選擇微任務;
nextTick 提供了四種非同步方法 Promise.thenMutationObserversetImmediatesetTimeOut(fn,0)

3.1 Vue.nextTick 內部邏輯

在執行 initGlobalAPI(Vue) 初始化 Vue 全域性 API 中,這麼定義
Vue.nextTick

function initGlobalAPI(Vue) {
    //...
    Vue.nextTick = nextTick;
}

可以看出是直接把 nextTick 函式賦值給 Vue.nextTick,就可以了,非常簡單。

3.2 vm.$nextTick 內部邏輯

Vue.prototype.$nextTick = function (fn) {
    return nextTick(fn, this)
};

可以看出是vm.$nextTick內部也是呼叫 nextTick 函式。

3.3 原始碼解讀

nextTick 的原始碼位於 src/core/util/next-tick.js
nextTick 原始碼主要分為兩塊:

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

//  上面三行與核心程式碼關係不大,瞭解即可
//  noop 表示一個無操作空函式,用作函式預設值,防止傳入 undefined 導致報錯
//  handleError 錯誤處理函式
//  isIE, isIOS, isNative 環境判斷函式,
//  isNative 判斷是否原生支援,如果通過第三方實現支援也會返回 false

export let isUsingMicroTask = false     // nextTick 最終是否以微任務執行

const callbacks = []     // 存放呼叫 nextTick 時傳入的回撥函式
let pending = false     // 標識當前是否有 nextTick 在執行,同一時間只能有一個執行


// 宣告 nextTick 函式,接收一個回撥函式和一個執行上下文作為引數
export function nextTick(cb?: Function, ctx?: Object) {
    let _resolve
    // 將傳入的回撥函式存放到陣列中,後面會遍歷執行其中的回撥
    callbacks.push(() => {
        if (cb) {   // 對傳入的回撥進行 try catch 錯誤捕獲
            try {
                cb.call(ctx)
            } catch (e) {
                handleError(e, ctx, 'nextTick')
            }
        } else if (_resolve) {
            _resolve(ctx)
        }
    })
    
    // 如果當前沒有在 pending 的回撥,就執行 timeFunc 函式選擇當前環境優先支援的非同步方法
    if (!pending) {
        pending = true
        timerFunc()
    }
    
    // 如果沒有傳入回撥,並且當前環境支援 promise,就返回一個 promise
    if (!cb && typeof Promise !== 'undefined') {
        return new Promise(resolve => {
            _resolve = resolve
        })
    }
}

可以看到在 nextTick 函式中把通過引數 cb 傳入的函式,做一下包裝然後 push 到 callbacks 陣列中。

然後用變數 pending 來保證執行一個事件迴圈中只執行一次 timerFunc()。

最後執行 if (!cb && typeof Promise !== 'undefined'),判斷引數 cb不存在且瀏覽器支援 Promise,則返回一個 Promise 類例項化物件。例如 nextTick().then(() => {}),當 _resolve 函式執行,就會執行 then 的邏輯中。

來看一下 timerFunc 函式的定義,先只看用 Promise 建立一個非同步執行的 timerFunc 函式 。

// 判斷當前環境優先支援的非同步方法,優先選擇微任務
// 優先順序:Promise---> MutationObserver---> setImmediate---> setTimeout
// setTimeOut 最小延遲也要4ms,而 setImmediate 會在主執行緒執行完後立刻執行
// setImmediate 在 IE10 和 node 中支援

// 多次呼叫 nextTick 時 ,timerFunc 只會執行一次

let timerFunc   
// 判斷當前環境是否支援 promise
if (typeof Promise !== 'undefined' && isNative(Promise)) {  // 支援 promise
    const p = Promise.resolve()
    timerFunc = () => {
    // 用 promise.then 把 flushCallbacks 函式包裹成一個非同步微任務
        p.then(flushCallbacks)
        if (isIOS) setTimeout(noop)
    }
    // 標記當前 nextTick 使用的微任務
    isUsingMicroTask = true
    
    
    // 如果不支援 promise,就判斷是否支援 MutationObserver
    // 不是IE環境,並且原生支援 MutationObserver,那也是一個微任務
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
    isNative(MutationObserver) ||
    MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
    let counter = 1
    // new 一個 MutationObserver 類
    const observer = new MutationObserver(flushCallbacks) 
    // 建立一個文字節點
    const textNode = document.createTextNode(String(counter))   
    // 監聽這個文字節點,當資料發生變化就執行 flushCallbacks 
    observer.observe(textNode, { characterData: true })
    timerFunc = () => {
        counter = (counter + 1) % 2
        textNode.data = String(counter)  // 資料更新
    }
    isUsingMicroTask = true    // 標記當前 nextTick 使用的微任務
    
    
    // 判斷當前環境是否原生支援 setImmediate
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
    timerFunc = () => { setImmediate(flushCallbacks)  }
} else {

    // 以上三種都不支援就選擇 setTimeout
    timerFunc = () => { setTimeout(flushCallbacks, 0) }
}

其中 isNative 方法是如何定義,程式碼如下。

function isNative(Ctor) {
    return typeof Ctor === 'function' && /native code/.test(Ctor.toString())
}

在其中發現 timerFunc 函式就是用各種非同步執行的方法呼叫 flushCallbacks 函式。

來看一下 flushCallbacks 函式

// 如果多次呼叫 nextTick,會依次執行上面的方法,將 nextTick 的回撥放在 callbacks 陣列中
// 最後通過 flushCallbacks 函式遍歷 callbacks 陣列的拷貝並執行其中的回撥
function flushCallbacks() {
    pending = false
    const copies = callbacks.slice(0)    // 拷貝一份
    callbacks.length = 0    // 清空 callbacks
    for (let i = 0; i < copies.length; i++) {    // 遍歷執行傳入的回撥
        copies[i]()
    }
}

// 為什麼要拷貝一份 callbacks

// callbacks.slice(0) 將 callbacks 拷貝出來一份,
// 是因為考慮到 nextTick 回撥中可能還會呼叫 nextTick 的情況,
// 如果 nextTick 回撥中又呼叫了一次 nextTick,則又會向 callbacks 中新增回撥,
// nextTick 回撥中的 nextTick 應該放在下一輪執行,
// 如果不將 callbacks 複製一份就可能一直迴圈

執行 pending = false 使下個事件迴圈中能 nextTick 函式中呼叫 timerFunc 函式。
執行 var copies = callbacks.slice(0);callbacks.length = 0; 把要非同步執行的函式集合 callbacks克隆到常量 copies,然後把 callbacks 清空。
然後遍歷 copies 執行每一項函式。回到 nextTick 中是把通過引數 cb 傳入的函式包裝後 push 到 callbacks 集合中。來看一下怎麼包裝的。

function() {
    if (cb) {
        try {
            cb.call(ctx);
        } catch (e) {
            handleError(e, ctx, 'nextTick');
        }
    } else if (_resolve) {
        _resolve(ctx);
    }
}

邏輯很簡單。若引數 cb 有值。在 try 語句中執行 cb.call(ctx) ,引數 ctx 是傳入函式的引數。 如果執行失敗執行 handleError(e, ctx, 'nextTick')

若引數 cb 沒有值。執行 _resolve(ctx),因為在 nextTick 函式中如何引數 cb 沒有值,會返回一個 Promise 類例項化物件,那麼執行 _resolve(ctx),就會執行 then 的邏輯中。

到這裡 nextTick 函式的主線邏輯就很清楚了。定義一個變數 callbacks,把通過引數 cb 傳入的函式用一個函式包裝一下,在這個中會執行傳入的函式,及處理執行失敗和引數 cb 不存在的場景,然後 新增到 callbacks。

呼叫 timerFunc 函式,在其中遍歷 callbacks 執行每個函式,因為 timerFunc 是一個非同步執行的函式,且定義一個變數 pending 來保證一個事件迴圈中只呼叫一次 timerFunc 函式。這樣就實現了 nextTick 函式非同步執行傳入的函式的作用了。

那麼其中的關鍵還是怎麼定義 timerFunc 函式。因為在各瀏覽器下對建立非同步執行函式的方法各不相同,要做相容處理,下面來介紹一下各種方法。

3.4 為什麼優先使用微任務:

按照上面事件迴圈的執行順序,執行下一次巨集任務之前會執行一次 UI 渲染,等待時長比微任務要多很多。所以在能使用微任務的時候優先使用微任務,不能使用微任務的時候才使用巨集任務,優雅降級。*

四、參考文獻

深入響應式原理 — Vue.js
nextTick實現原理,必拿下! - 掘金
?Vue原始碼——nextTick實現原理 - 掘金

我是Cloudy,年輕的前端攻城獅,愛專研,愛技術,愛分享。
個人筆記,整理不易,感謝閱讀、點贊、關注和收藏。
文章有任何問題歡迎大家指出,也歡迎大家一起交流學習!

相關文章