在使用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的方式引入。
運行結果如下:
在我這個示例裏面,點擊增加按鈕,會對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返回的是一個PromisenextTick的回調函數是在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返回的是一個PromisenextTick的回調函數是在Promise的then方法中執行的
而根據Promise的特性,我們知道Promise是可以鏈式調用的,所以我們可以這樣寫:
Promise.resolve().then(() => {
// ...
}).then(() => {
// ...
}).then(() => {
// ...
});
而且根據Promise的特性,每次返回的Promise都是一個新的Promise;
同時我們也知道Promise的then方法是異步執行的,所以上面的代碼的執行順序也就有了一定的猜測,但是現在不下結論,我們繼續深挖。
nextTick 的實現細節
上面的源碼雖然很短,但是裏面有一個currentFlushPromise變量,並且這個變量是使用let聲明的,所有的變量都使用const聲明,這個變量是用let來聲明的,肯定是有貨的。
通過搜索,我們可以找到這個變量變量的使用地方,發現有兩個方法在使用這個變量:
queueFlush:將currentFlushPromise設置為一個PromiseflushJobs:將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);
});
上面的代碼執行的結果大家可以自己在瀏覽器中執行一下,就會發現和我們的預期是一致的。
具體流程可以看下面的圖:
上面一個同步的宏任務就執行完成了,接下來就是微任務隊列了,流程如下:
這樣第二波任務也結束了,這一次的任務主要是刷新任務隊列,這裏執行的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的可鏈式調用的特性,平時開發可能大家都用過,但是沒想到可以這樣用,真的是非常的巧妙。
這次就到這裏了,感謝大家的閲讀,如果有不對的地方,歡迎大家指正。