【原始碼&庫】Vue3 中的 nextTick 魔法背後的原理

田八發表於2023-02-28

在使用Vue的時候,最讓人著迷的莫過於nextTick了,它可以讓我們在下一次DOM更新迴圈結束之後執行延遲迴調。

所以我們想要拿到更新的後的DOM就上nextTick,想要在DOM更新之後再執行某些操作還上nextTick,不知道頁面什麼時候掛載完成依然上nextTick

雖然我不懂Vue的內部實現,但是我知道有問題上nextTick就對了,你天天上nextTick,那麼nextTick為什麼可以讓你這麼爽你就不好奇嗎?

大家好,這裡是田八的【原始碼&庫】系列,Vue3的原始碼閱讀計劃,Vue3的原始碼閱讀計劃不出意外每週一更,歡迎大家關注。

如果想一起交流的話,可以點選這裡一起共同交流成長

系列章節:

首發在掘金,無任何引流的意思。

nextTick 簡介

根據官網的簡單介紹,nextTick是等待下一次 DOM 更新重新整理的工具方法。

型別定義如下:

function nextTick(callback?: () => void): Promise<void> {}

然後再根據官網的詳細介紹,我們可以知道nextTick的大體實現思路和用法:

當你在 Vue 中更改響應式狀態時,最終的 DOM 更新並不是同步生效的,而是由 Vue 將它們快取在一個佇列中,直到下一個“tick”才一起執行。
這樣是為了確保每個元件無論發生多少狀態改變,都僅執行一次更新。

nextTick()可以在狀態改變後立即使用,以等待 DOM 更新完成。
你可以傳遞一個回撥函式作為引數,或者 await 返回的 Promise

官網的解釋已經很詳細了,我就不過度解讀,接下來就是分析環節了。

nextTick 的一些細節和用法

nextTick 的用法

首先根據官網的介紹,我們可以知道nextTick有兩種用法:

  • 傳入回撥函式
nextTick(() => {
  // DOM 更新了
})
  • 返回一個Promise
nextTick().then(() => {
  // DOM 更新了
})

那麼這兩種方法可以混用嗎?

nextTick(() => {
  // DOM 更新了
}).then(() => {
  // DOM 更新了
})

nextTick 的現象

寫了一個很簡單的demo,發現是可以混用的,並且發現一個有意思的現象:

const {createApp, h, nextTick} = Vue;

const app = createApp({
    data() {
        return {
            count: 0
        };
    },
    methods: {
        push() {
            nextTick(() => {
                console.log('callback before');
            }).then(() => {
                console.log('promise before');
            });

            this.count++;

            nextTick(() => {
                console.log('callback after');
            }).then(() => {
                console.log('promise after');
            });
        }
    },
    render() {
        console.log('render', this.count);

        const pushBtn = h("button", {
            innerHTML: "增加",
            onClick: this.push
        });

        const countText = h("p", {
            innerHTML: this.count
        });

        return h("div", {}, [pushBtn, countText]);
    }
});

app.mount("#app");
我這裡為了簡單使用的vue.global.js,使用方式和Vue3一樣,只是沒有使用ESM的方式引入。

執行結果如下:

image.png

在我這個示例裡面,點選增加按鈕,會對count進行加一操作,這個方法裡面可以分為三個部分:

  1. 使用nextTick,並使用回撥函式和Promise的混合使用
  2. count進行加一操作
  3. 使用nextTick,並使用回撥函式和Promise的混合使用

第一個註冊的nextTick,在count加一之前執行,第二個註冊的nextTick,在count加一之後執行。

但是最後的結果卻是非常的有趣:

callback before
render 1
promise before
callback after
promise after

第一個註冊的nextTick,回撥函式是在render之前執行的,而Promise是在render之後執行的。

第二個註冊的nextTick,回撥函式是在render之後執行的,而Promise是在render之後執行的。

並且兩個nextTick的回撥函式都是優先於Promise執行的。

如何解釋這個現象呢?我們將從nextTick的實現開始分析。

nextTick 的實現

nextTick的原始碼在packages/runtime-core/src/scheduler.ts檔案中,只有兩百多行,感興趣的可以直接去看ts版的原始碼,我們還是看打包之後的原始碼。

const resolvedPromise = /*#__PURE__*/ Promise.resolve();
let currentFlushPromise = null;
function nextTick(fn) {
    const p = currentFlushPromise || resolvedPromise;
    return fn ? p.then(this ? fn.bind(this) : fn) : p;
}

猛一看人都傻了,nextTick的程式碼居然就這麼一點?再仔細看看,發現nextTick的實現其實是一個Promise的封裝。

暫時不考慮別的東西,就看看這點程式碼,我們可以知道:

  • nextTick返回的是一個Promise
  • nextTick的回撥函式是在Promisethen方法中執行的

現在回到我們之前的demo,其實我們已經找到一部分的答案了:

nextTick(() => {
    console.log('callback before');
}).then(() => {
    console.log('promise before');
});

this.count++;

上面最終執行的順序,用程式碼表示就是:

function nextTick(fn) {
    // 2. 返回一個 Promise, 並且在 Promise 的 then 方法中執行回撥函式
    return Promise.resolve().then(fn);
}

// 1. 呼叫 nextTick,註冊回撥函式
const p = nextTick(() => {
    console.log('callback before');
})

// 3. 在 Promise 的 then 方法註冊一個新的回撥
p.then(() => {
    console.log('promise before');
});

// 4. 執行 count++
this.count++;

從拆解出來的程式碼中,我們可以看到的是:

  • nextTick返回的是一個Promise
  • nextTick的回撥函式是在Promisethen方法中執行的

而根據Promise的特性,我們知道Promise是可以鏈式呼叫的,所以我們可以這樣寫:

Promise.resolve().then(() => {
    // ...
}).then(() => {
    // ...
}).then(() => {
    // ...
});

而且根據Promise的特性,每次返回的Promise都是一個新的Promise

同時我們也知道Promisethen方法是非同步執行的,所以上面的程式碼的執行順序也就有了一定的猜測,但是現在不下結論,我們繼續深挖。

nextTick 的實現細節

上面的原始碼雖然很短,但是裡面有一個currentFlushPromise變數,並且這個變數是使用let宣告的,所有的變數都使用const宣告,這個變數是用let來宣告的,肯定是有貨的。

透過搜尋,我們可以找到這個變數變數的使用地方,發現有兩個方法在使用這個變數:

  • queueFlush:將currentFlushPromise設定為一個Promise
  • flushJobs:將currentFlushPromise設定為null

queueFlush

// 是否正在重新整理
let isFlushing = false;

// 是否有任務需要重新整理
let isFlushPending = false;

// 重新整理任務佇列
function queueFlush() {
    // 如果正在重新整理,並且沒有任務需要重新整理
    if (!isFlushing && !isFlushPending) {
        
        // 將 isFlushPending 設定為 true,表示有任務需要重新整理
        isFlushPending = true;
        
        // 將 currentFlushPromise 設定為一個 Promise, 並且在 Promise 的 then 方法中執行 flushJobs
        currentFlushPromise = resolvedPromise.then(flushJobs);
    }
}

這些程式碼其實不用寫註釋也很看懂,見名知意,其實這裡已經可以初窺端倪了:

  • queueFlush是一個用來重新整理任務佇列的方法
  • isFlushing表示是否正在重新整理,但是不是在這個方法裡面使用的
  • isFlushPending表示是否有任務需要重新整理,屬於排隊任務
  • currentFlushPromise表示當前就需要重新整理的任務

現在結合上面的nextTick的實現,其實我們會發現一個很有趣的地方,resolvedPromise他們兩個都有在使用:

const resolvedPromise = Promise.resolve();
function nextTick(fn) {
    // nextTick 使用 resolvedPromise 
    return resolvedPromise.then(fn);
}

function queueFlush() {
    // queueFlush 也使用 resolvedPromise
    currentFlushPromise = resolvedPromise.then(flushJobs);
}

上面程式碼再簡化一下,其實是下面這樣的:

const resolvedPromise = Promise.resolve();
resolvedPromise.then(() => {
    // ...
});

resolvedPromise.then(() => {
    // ...
});

其實就是利用Promisethen方法可以註冊多個回撥函式的特性,將需要重新整理的任務都註冊到同一個Promisethen方法中,這樣就可以保證這些任務的執行順序,就是一個佇列。

flushJobs

在上面的queueFlush方法中,我們知道了queueFlush是一個用來重新整理任務佇列的方法;

那麼重新整理什麼任務呢?反正最後傳入的是一個flushJobs方法,同時這個方法裡面也使用到了currentFlushPromise,這不就串起來嗎,趕緊來看看:

// 任務佇列
const queue = [];

// 當前正在重新整理的任務佇列的索引
let flushIndex = 0;

// 重新整理任務
function flushJobs(seen) {
    // 將 isFlushPending 設定為 false,表示當前沒有任務需要等待重新整理了
    isFlushPending = false;
    
    // 將 isFlushing 設定為 true,表示正在重新整理
    isFlushing = true;
    
    // 非生產環境下,將 seen 設定為一個 Map
    if ((process.env.NODE_ENV !== 'production')) {
        seen = seen || new Map();
    }
    
    // 重新整理前,需要對任務佇列進行排序
    // 這樣可以確保:
    // 1. 元件的更新是從父元件到子元件的。
    //    因為父元件總是在子元件之前建立,所以它的渲染優先順序要低於子元件。
    // 2. 如果父元件在更新的過程中解除安裝了子元件,那麼子元件的更新可以被跳過。
    queue.sort(comparator);
    
    // 非生產環境下,檢查是否有遞迴更新
    // checkRecursiveUpdates 方法的使用必須在 try ... catch 程式碼塊之外確定,
    // 因為 Rollup 預設會在 try-catch 程式碼塊中進行 treeshaking 最佳化。
    // 這可能會導致所有警告程式碼都不會被 treeshaking 最佳化。
    // 雖然它們最終會被像 terser 這樣的壓縮工具 treeshaking 最佳化,
    // 但有些壓縮工具會失敗(例如:https://github.com/evanw/esbuild/issues/1610)
    const check = (process.env.NODE_ENV !== 'production')
        ? (job) => checkRecursiveUpdates(seen, job)
        : NOOP;
    
    // 檢測遞迴呼叫是一個非常巧妙的操作,感興趣的可以去看看原始碼,這裡不做講解
    try {
        for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
            const job = queue[flushIndex];
            if (job && job.active !== false) {
                if ((process.env.NODE_ENV !== 'production') && check(job)) {
                    continue;
                }
                
                // 執行任務
                callWithErrorHandling(job, null, 14 /* ErrorCodes.SCHEDULER */);
            }
        }
    }
    finally {
        // 重置 flushIndex
        flushIndex = 0;
        
        // 快速清空佇列,直接給 陣列的 length屬性 賦值為 0 就可以清空陣列
        queue.length = 0;
        
        // 重新整理生命週期的回撥
        flushPostFlushCbs(seen);
        
        // 將 isFlushing 設定為 false,表示當前重新整理結束
        isFlushing = false;
        
        // 將 currentFlushPromise 設定為 null,表示當前沒有任務需要重新整理了
        currentFlushPromise = null;
        
        // pendingPostFlushCbs 存放的是生命週期的回撥,
        // 所以可能在重新整理的過程中又有新的任務需要重新整理
        // 所以這裡需要判斷一下,如果有新新增的任務,就需要再次重新整理
        if (queue.length || pendingPostFlushCbs.length) {
            flushJobs(seen);
        }
    }
}

flushJobs首先會將isFlushPending設定為false,當前批次的任務已經開始重新整理了,所以就不需要等待了,然後將isFlushing設定為true,表示正在重新整理。

這一點和queueFlush方法正好相反,但是它們的功能是相互輝映的,queueFlush表示當前有任務需要屬性,flushJobs表示當前正在重新整理任務。

而任務的執行是透過callWithErrorHandling方法來執行的,裡面的程式碼很簡單,就是執行方法並捕獲執行過程中的錯誤,然後將錯誤交給onErrorCaptured方法來處理。

而重新整理的任務都存放在queue屬性中,這個queue就是我們上面說的任務佇列,這個任務佇列裡面存放的就是我們需要重新整理的任務。

最後清空queue然後執行flushPostFlushCbs方法,flushPostFlushCbs方法通常存放的是生命週期的回撥,比如mountedupdated等。

queue 的任務新增

上面提到了queue,那麼queue是怎麼新增任務的呢?

透過搜尋,我們可以定位到queueJob方法,這個方法就是用來新增任務的:

// 新增任務,這個方法會在下面的 queueFlush 方法中被呼叫
function queueJob(job) {
    // 透過 Array.includes() 的 startIndex 引數來搜尋任務佇列中是否已經存在相同的任務
    // 預設情況下,搜尋的起始索引包含了當前正在執行的任務
    // 所以它不能遞迴地再次觸發自身
    // 如果任務是一個 watch() 回撥,那麼搜尋的起始索引就是 +1,這樣就可以遞迴呼叫了
    // 但是這個遞迴呼叫是由使用者來保證的,不能無限遞迴
    if (!queue.length ||
        !queue.includes(job, isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex)) {
        // 如果任務沒有 id 屬性,那麼就將任務插入到任務佇列中
        if (job.id == null) {
            queue.push(job);
        }
        
        // 如果任務有 id 屬性,那麼就將任務插入到任務佇列的合適位置
        else {
            queue.splice(findInsertionIndex(job.id), 0, job);
        }
        
        // 重新整理任務佇列
        queueFlush();
    }
}

這裡的job是一個函式,也就是我們需要重新整理的任務,但是這個函式會擴充一些屬性,比如idpreactive等。

ts版的原始碼中有對job的型別定義:

export interface SchedulerJob extends Function {
    // id 就是排序的依據
    id?: number
    // 在 id 相同的情況下,pre 為 true 的任務會先執行
    // 這個在重新整理任務佇列的時候,在排序的時候會用到,本文沒有講解這方面的內容
    pre?: boolean
    // 標識這個任務是否明確處於非活動狀態,非活動狀態的任務不會被重新整理
    active?: boolean
    // 標識這個任務是否是 computed 的 getter
    computed?: boolean
    /**
     * 表示 effect 是否允許在由 scheduler 管理時遞迴觸發自身。
     * 預設情況下,scheduler 不能觸發自身,因為一些內建方法呼叫,例如 Array.prototype.push 實際上也會執行讀取操作,這可能會導致令人困惑的無限迴圈。
     * 允許的情況是元件更新函式和 watch 回撥。
     * 元件更新函式可以更新子元件屬性,從而觸發“pre”watch回撥,該回撥會改變父元件依賴的狀態。
     * watch 回撥不會跟蹤它的依賴關係,因此如果它再次觸發自身,那麼很可能是有意的,這是使用者的責任來執行遞迴狀態變更,最終使狀態穩定。
     */
    allowRecurse?: boolean
    /**
     * 在 renderer.ts 中附加到元件的渲染 effect 上用於在報告最大遞迴更新時獲取元件資訊。
     * 僅限開發。
     */
    ownerInstance?: ComponentInternalInstance
}

queueJob方法首先會判斷queue中是否已經存在相同的任務,如果存在相同的任務,那麼就不需要再次新增了。

這裡主要是處理遞迴呼叫的問題,因為這裡存放的任務大多數都是我們在修改資料的時候觸發的;

而修改資料的時候用到了陣列的方法,例如forEachmap等,這些方法在執行的時候,會觸發getter,而getter中又會觸發queueJob方法,這樣就會導致遞迴呼叫。

所以這裡會判斷isFlushing,如果是正在重新整理,那麼就會將flushIndex設定為+1

flushIndex是當前正在重新整理的任務的索引,+1之後就從下一個任務開始搜尋,這樣就不會重複的往裡面新增同一個任務導致遞迴呼叫。

watch的回撥是可以遞迴呼叫的,因為這個是使用者控制的,所以這裡就多了一個allowRecurse屬性,如果是watch的回撥,那麼就會將allowRecurse設定為true

這樣就可以避免遞迴呼叫的問題,是一個非常巧妙的設計。

queueJob最後是被匯出的,這個用於其他模組新增任務,比如watchEffectwatch等。

flushPostFlushCbs

flushPostFlushCbs方法是用來執行生命週期的回撥的,比如mountedupdated等。

flushPostFlushCbs就不多講了,整體的流程和flushJobs差不多;

不同的是flushPostFlushCbs會把任務備份,然後依次執行,並且不會捕獲異常,是直接呼叫的。

感興趣的同學可以自己檢視原始碼。

問題的開始

回到最開始的問題,就是文章最開頭的demo示例,先回顧一下demo的程式碼:

nextTick(() => {
    console.log('callback before');
}).then(() => {
    console.log('promise before');
});

this.count++;

nextTick(() => {
    console.log('callback after');
}).then(() => {
    console.log('promise after');
});

列印的結果是:

callback before
render 1
promise before
callback after
promise after

其實透過翻看原始碼已經很明確了,我們在註冊第一個nextTick的時候,queue中並沒有任何任務;

而且nextTick並不會呼叫queueJob方法,也不會呼叫flushJobs方法,所以這個時候任務佇列是不會被重新整理的。

但是resolvedPromise是一個成功的promise,所以傳入到nextTick裡面的回撥函式會被放到微任務佇列中,等待執行。

nextTick還會返回一個promise,所以我們返回的promisethen回撥函式也會被放到微任務佇列中,但是一定會落後於nextTick中的回撥函式。

接著我們再執行this.count++,這裡面的內部實現邏輯我們還沒接觸到,只需要知道他會觸發queueJob方法,將任務新增到任務佇列中即可。

最後我們又執行了一次nextTick,這個時候queue中已經有了任務,所以會呼叫flushJobs方法,將任務佇列中的任務依次執行。

劃重點:並且這個時候currentFlushPromise有值了,值是resolvedPromise執行完畢之後,返回的Promise

和第一次不同的是,第一次執行nextTick的時候,currentFlushPromiseundefined,使用的是resolvedPromise;

可以理解為第一次執行nextTick的時候,是和flushJobs方法註冊的任務使用的是同一個Promise

第二次執行nextTick的時候,使用的是currentFlushPromise,這個PromiseflushJobs方法註冊的任務不是同一個Promise

這樣就就保證了nextTick註冊的回撥函式會在flushJobs方法註冊的回撥函式之後執行。

具體的流程可以可以看下面的程式碼示例:

const resolvedPromise = Promise.resolve();
let count = 0;

// 第一次註冊 nextTick
resolvedPromise.then(() => {
    console.log('callback before', count);
}).then(() => {
    console.log('promise before', count);
});

// 執行 this.count++
// 這裡會觸發 queueJob 方法,將任務新增到任務佇列中
const currentFlushPromise = resolvedPromise.then(() => {
    count++;
    console.log('render', count);
});

// 第二次註冊 nextTick
currentFlushPromise.then(() => {
    console.log('callback after', count);
}).then(() => {
    console.log('promise after', count);
});

上面的程式碼執行的結果大家可以自己在瀏覽器中執行一下,就會發現和我們的預期是一致的。

具體流程可以看下面的圖:

graph TD
A[resolvedPromise] -->|註冊 nextTick 回撥| B[nextTick callback before]
B -->|在 nextTick 返回的 promise 註冊 then 的回撥| C[nextTick promise then]
A -->|執行 value++ 會觸發 queueJob| D[value++]
D -->|執行 flushJobs 會將 resolvedPromise 返回的 promise 賦值到 currentFlushPromise| E[currentFlushPromise]
E -->|註冊 nextTick 回撥使用的是 currentFlushPromise| F[nextTick callback after]
F -->|在 nextTick 返回的 promise 註冊 then 的回撥| G[nextTick promise after]

上面一個同步的宏任務就執行完成了,接下來就是微任務佇列了,流程如下:

graph TD
A[resolvedPromise] -->|直接呼叫 then 裡面註冊的回撥函式| B[then callbacks]
B -->|註冊了多個,依次執行| C[nextTick callback before]
C -->|註冊了多個,依次執行| D[value++]

這樣第二波任務也結束了,這一次的任務主要是重新整理任務佇列,這裡執行的nextTick其實是上一個任務的tick(現在明白官網上說的直到下一個“tick”才一起執行是什麼意思了吧)。

接著就執行下一個tick(是這麼個意思吧,手動狗頭),流程如下:

graph TD
A[nextTick promise then] -->|因為是先註冊的,所以先執行| B[nextTick promise before]

結束了,沒錯,這次的任務就是執行nextTick返回的promisethen回撥函式;

因為nextTick返回的promisecurrentFlushPromise不是同一個promisenextTick返回的promisethen是單獨一個任務,並且優先順序是高於currentFlushPromise的。

這次的任務結束,就又下一個tick了,流程如下:

graph TD
A[currentFlushPromise then] -->|因為是後註冊的,所以相對於上面的後執行| B[nextTick callback after]

這次的任務就是執行currentFlushPromisethen回撥函式,同時也是呼叫flushJobs,由flushJobsresolvedPromise返回的Promise賦值給currentFlushPromise

這次的任務結束,就是最後一個tick了,流程如下:

graph TD
A[nextTick promise after] -->|最後一個| B[nextTick promise after]

至此流程結束,過程很燒腦,但是理解了之後,發現非常的巧妙,對自己的思維能力有了很大的提升,同時也對非同步的理解有了很大的提升。

總結

這篇文章主要是對Vue3nextTick的實現原理進行了分析,透過分析原始碼,我們發現nextTick的實現原理非常的巧妙。

nextTick的實現原理是透過Promise來實現的,nextTick會返回一個Promise,並且nextTick的回撥函式會被放到微任務佇列中,等待執行。

如果在有任務排隊的情況下注冊nextTick,那麼nextTick的回撥函式會在任務佇列中的任務執行完畢之後執行。

這裡使用的思路非常簡單,就是利用了Promise的可鏈式呼叫的特性,平時開發可能大家都用過,但是沒想到可以這樣用,真的是非常的巧妙。

這次就到這裡了,感謝大家的閱讀,如果有不對的地方,歡迎大家指正。

相關文章