动态

详情 返回 返回

【源碼&庫】Vue3 中的 nextTick 魔法背後的原理 - 动态 详情

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

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

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

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

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

系列章節:

  • 【源碼&庫】跟着 Vue3 學習前端模塊化
  • 【源碼&庫】在調用 createApp 時,Vue 為我們做了那些工作?
  • 【源碼&庫】細數 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的可鏈式調用的特性,平時開發可能大家都用過,但是沒想到可以這樣用,真的是非常的巧妙。

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

user avatar kanjianliao 头像 zs_staria 头像 u_16307147 头像 populus 头像 wszgrcy 头像 chuyang_596e1bd19bf8c 头像 fushengruomengweihuanjihe 头像 taotao123 头像 songxianling1992 头像 nidexiaoxiongruantangna 头像 nortyr 头像 fisher_feng 头像
点赞 23 用户, 点赞了这篇动态!
点赞

Add a new 评论

Some HTML is okay.