在使用Vue
的時候,最讓人著迷的莫過於nextTick
了,它可以讓我們在下一次DOM
更新迴圈結束之後執行延遲迴調。
所以我們想要拿到更新的後的DOM
就上nextTick
,想要在DOM
更新之後再執行某些操作還上nextTick
,不知道頁面什麼時候掛載完成依然上nextTick
。
雖然我不懂Vue
的內部實現,但是我知道有問題上nextTick
就對了,你天天上nextTick
,那麼nextTick
為什麼可以讓你這麼爽你就不好奇嗎?
大家好,這裡是田八的【原始碼&庫】系列,
Vue3
的原始碼閱讀計劃,Vue3
的原始碼閱讀計劃不出意外每週一更,歡迎大家關注。如果想一起交流的話,可以點選這裡一起共同交流成長
系列章節:
首發在掘金,無任何引流的意思。
nextTick 簡介
根據官網的簡單介紹,nextTick
是等待下一次 DOM 更新重新整理的工具方法。
型別定義如下:
然後再根據官網的詳細介紹,我們可以知道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
的方式引入。
執行結果如下:
在我這個示例裡面,點選增加按鈕,會對count
進行加一操作,這個方法裡面可以分為三個部分:
- 使用
nextTick
,並使用回撥函式和Promise
的混合使用 - 對
count
進行加一操作 - 使用
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
的回撥函式是在Promise
的then
方法中執行的
現在回到我們之前的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
的回撥函式是在Promise
的then
方法中執行的
而根據Promise
的特性,我們知道Promise
是可以鏈式呼叫的,所以我們可以這樣寫:
Promise.resolve().then(() => {
// ...
}).then(() => {
// ...
}).then(() => {
// ...
});
而且根據Promise
的特性,每次返回的Promise
都是一個新的Promise
;
同時我們也知道Promise
的then
方法是非同步執行的,所以上面的程式碼的執行順序也就有了一定的猜測,但是現在不下結論,我們繼續深挖。
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(() => {
// ...
});
其實就是利用Promise
的then
方法可以註冊多個回撥函式的特性,將需要重新整理的任務都註冊到同一個Promise
的then
方法中,這樣就可以保證這些任務的執行順序,就是一個佇列。
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
方法通常存放的是生命週期的回撥,比如mounted
、updated
等。
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
是一個函式,也就是我們需要重新整理的任務,但是這個函式會擴充一些屬性,比如id
、pre
、active
等。
在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
中是否已經存在相同的任務,如果存在相同的任務,那麼就不需要再次新增了。
這裡主要是處理遞迴呼叫的問題,因為這裡存放的任務大多數都是我們在修改資料的時候觸發的;
而修改資料的時候用到了陣列的方法,例如forEach
、map
等,這些方法在執行的時候,會觸發getter
,而getter
中又會觸發queueJob
方法,這樣就會導致遞迴呼叫。
所以這裡會判斷isFlushing
,如果是正在重新整理,那麼就會將flushIndex
設定為+1
;
flushIndex
是當前正在重新整理的任務的索引,+1
之後就從下一個任務開始搜尋,這樣就不會重複的往裡面新增同一個任務導致遞迴呼叫。
而watch
的回撥是可以遞迴呼叫的,因為這個是使用者控制的,所以這裡就多了一個allowRecurse
屬性,如果是watch
的回撥,那麼就會將allowRecurse
設定為true
。
這樣就可以避免遞迴呼叫的問題,是一個非常巧妙的設計。
queueJob
最後是被匯出的,這個用於其他模組新增任務,比如watchEffect
、watch
等。
flushPostFlushCbs
flushPostFlushCbs
方法是用來執行生命週期的回撥的,比如mounted
、updated
等。
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
,所以我們返回的promise
中then
回撥函式也會被放到微任務佇列中,但是一定會落後於nextTick
中的回撥函式。
接著我們再執行this.count++
,這裡面的內部實現邏輯我們還沒接觸到,只需要知道他會觸發queueJob
方法,將任務新增到任務佇列中即可。
最後我們又執行了一次nextTick
,這個時候queue
中已經有了任務,所以會呼叫flushJobs
方法,將任務佇列中的任務依次執行。
劃重點:並且這個時候currentFlushPromise
有值了,值是resolvedPromise
執行完畢之後,返回的Promise
。
和第一次不同的是,第一次執行nextTick
的時候,currentFlushPromise
是undefined
,使用的是resolvedPromise
;
可以理解為第一次執行nextTick
的時候,是和flushJobs
方法註冊的任務使用的是同一個Promise
。
第二次執行nextTick
的時候,使用的是currentFlushPromise
,這個Promise
和flushJobs
方法註冊的任務不是同一個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]
上面一個同步的宏任務就執行完成了,接下來就是微任務佇列了,流程如下:
這樣第二波任務也結束了,這一次的任務主要是重新整理任務佇列,這裡執行的nextTick
其實是上一個任務的tick
(現在明白官網上說的直到下一個“tick”才一起執行
是什麼意思了吧)。
接著就執行下一個tick
(是這麼個意思吧,手動狗頭),流程如下:
結束了,沒錯,這次的任務就是執行nextTick
返回的promise
的then
回撥函式;
因為nextTick
返回的promise
和currentFlushPromise
不是同一個promise
,nextTick
返回的promise
的then
是單獨一個任務,並且優先順序是高於currentFlushPromise
的。
這次的任務結束,就又下一個tick
了,流程如下:
這次的任務就是執行currentFlushPromise
的then
回撥函式,同時也是呼叫flushJobs
,由flushJobs
將resolvedPromise
返回的Promise
賦值給currentFlushPromise
。
這次的任務結束,就是最後一個tick
了,流程如下:
至此流程結束,過程很燒腦,但是理解了之後,發現非常的巧妙,對自己的思維能力有了很大的提升,同時也對非同步的理解有了很大的提升。
總結
這篇文章主要是對Vue3
中nextTick
的實現原理進行了分析,透過分析原始碼,我們發現nextTick
的實現原理非常的巧妙。
nextTick
的實現原理是透過Promise
來實現的,nextTick
會返回一個Promise
,並且nextTick
的回撥函式會被放到微任務佇列中,等待執行。
如果在有任務排隊的情況下注冊nextTick
,那麼nextTick
的回撥函式會在任務佇列中的任務執行完畢之後執行。
這裡使用的思路非常簡單,就是利用了Promise
的可鏈式呼叫的特性,平時開發可能大家都用過,但是沒想到可以這樣用,真的是非常的巧妙。
這次就到這裡了,感謝大家的閱讀,如果有不對的地方,歡迎大家指正。