本節需要準備知識點:Event Loop、Promise
關于Event Loop介紹參考阮一峰老師的文章:
- http://www.ruanyifeng.com/blog/2013/10/event_loop.html
- https://www.ruanyifeng.com/blog/2014/10/event-loop.html
關于Promise:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Promise
上一節學習了Vue通過Object.defineProperty攔截數據變化的響應式原理,數據變化后會觸發notify方法來通知變更,這一節沿著圖譜繼續往下啃,收到通知后Vue會開啟一個異步更新隊列
兩個問題:
Vue開啟一個異步更新隊列,為什么不是同步而是異步?
不知道你有沒有發現在Vue中修改data中的數據時,無論修改幾次,最終模板只渲染一次,這是怎么做到的?
一、異步更新隊列
先來看一段代碼演示
把上一節的代碼拿過來:
let x;
let y;
let f = (n) => n * 100;
let active;
let onXChange = function(cb) {
active = cb;
active();
};
class Dep {
deps = new Set();
// 收集依賴
depend() {
if (active) {
this.deps.add(active);
}
}
// 通知依賴更新
notify() {
this.deps.forEach((dep) => dep());
}
}
let ref = (initValue) => {
let value = initValue;
let dep = new Dep();
return Object.defineProperty({}, "value", {
get() {
dep.depend();
return value;
},
set(newValue) {
value = newValue;
dep.notify();
},
});
};
x = ref(1);
onXChange(() => {
y = f(x.value);
console.log('onXChange', y);
});
x.value = 2;
x.value = 3;
假設我們現在不止依賴x,還有y、z,分別將x、y、z輸出到頁面上。我們現在依賴了x、y、z三個變量,那我們應該把這個onXChange函數名改成watch,就是它可以監聽變化的意思,不單單只是監聽一個x變化。
let x;
let y;
let z;
x = ref(1);
y = ref(2);
z = ref(3);
// 考慮到我們會依賴很多變量,因此將onXChange改成watch比較符合語義
watch(() => {
document.write(`
<p>
x: ${f(x.value)}; y: ${f(y.value)}; z: ${f(z.value)}
</p>
`)
});
可以看到這三個值都被打印在頁面上
現在我們對x、y、z的value進行修改
x.value = 2;
y.value = 3;
z.value = 4;
查看頁面,結果沒有問題,每個數據的變化都被監聽到并且進行了響應
既然結果是對的,那我們的問題是什么?
這個問題是:每次數據變化都進行了響應,每次都渲染了模板,如果數據變化了一百次、一千次呢?難道要重復渲染一百遍、一千遍嗎?
我們都知道頻繁操作dom會影響網頁性能,涉及重排和重繪的知識感興趣請閱讀阮一峰老師的文章:
https://www.ruanyifeng.com/blog/2015/09/web-page-performance-in-depth.html
因此,既要保證所有的依賴都準確更新,又要保證不能頻繁渲染成為了首要問題,現在我們修改x.value、y.value、z.value都是同步通知依賴進行更新的,有沒有一種機制可以等到我修改這些值之后再執行更新任務呢?
這個答案是——異步。
異步任務會等到同步任務清空后執行,借助這個特點和我們前面的分析,我們需要:
- 創建一個隊列用來存儲任務
- 創建一個將任務推入隊列的方法
- 創建一個用來執行隊列中任務的方法
- Promise(用來創建微任務)
按照步驟我們創建如下代碼:
// 創建任務隊列
let queue = [];
// 創建添加任務的方法
let queueJob = (job) => {
// 過濾已經添加的任務
if (!queue.includes(job)) {
queue.push(job); // 添加任務
flushJobs(); // 執行任務,請注意這里現在是偽代碼
}
};
// 創建執行任務的方法
let flushJobs = () => {
let job;
// 依次取出隊列的任務賦值給job并執行,直到清空隊列
while ((job = queue.shift()) !== undefined) {
job();
}
};
// 創建Promise,待定
接著我們需要修改一下notify的代碼,監聽到數據變化后不立即調用依賴進行更新,而是將依賴添加到隊列中
notify() {
this.deps.forEach(dep => queueJob(dep));
}
回到頁面,我們發現頁面上還是重復渲染了三次模板
那我們寫的這段代碼有什么用呢?異步又體現在哪里呢?接著往下看
二、 nextTick原理分析
上面的代碼中雖然我們開啟了一個隊列,并且成功將任務推入隊列中進行執行,但本質上還是同步推入和執行的,我們要讓它變成異步隊列
于是就到了Promise發揮作用的時候了,關于宏任務和微任務的介紹請參考:
https://zhuanlan.zhihu.com/p/78113300
我們創建nextTick函數,nextTick接收一個回調函數,返回一個狀態為fulfilled的Promise,并將回調函數傳給then方法
// 創建Promise
let nextTick = (cb) => Promise.resolve().then(cb);
然后只需要在添加任務時調用nextTick,將執行任務的flushJobs函數傳給nextTick即可
let queueJob = (job) => {
// 過濾已經添加的任務
if (!queue.includes(job)) {
queue.push(job); // 添加任務
nextTick(flushJobs); // 推入微任務
}
};
回到頁面
雖然修改了x、y、z三個變量的value,最后頁面上只渲染了一次。
再來總結一下這段代碼的執行過程:
- 當修改x.value時會觸發dep.notify()通知依賴更新,然后我們會開啟一個隊列將任務(這個任務就是active保存的回調函數)發給queueJob函數,queueJob函數判斷當前任務有沒有添加過,沒有,添加當前任務并執行nextTick(Promise),由于Promise調用then方法時會將then中的回調函數推入微任務隊列,所以flushJobs函數并不會立即執行,而是等到所有的同步任務都執行完成后再執行,也就是說要等到y、z修改value之后(如果后面還有別的同步代碼則要繼續等待),直到Event Loop下一個tick時才會執行flushJobs函數。(這三次通知觸發的都是同一個active,所以queueJob只會往隊列中添加一次任務)
- 因此,無論后面y、z的值進行多少次變更,當前這個更新任務只執行一次,這樣就達到了優化的目的。
這也正是Vue采用的解決方案——異步更新隊列,官方文檔描述的很清楚
文檔地址:https://cn.vuejs.org/v2/guide/reactivity.html#%E5%BC%82%E6%AD%A5%E6%9B%B4%E6%96%B0%E9%98%9F%E5%88%97
三、結合Vue源碼來看nextTick
在Vue中我們可以通過兩種方式來調用nextTick:
- Vue.nextTick()
- this.$nextTick()
(至于什么時候使用nextTick,你不偷懶看了官方文檔的話都能找到答案哈哈)
以下源碼節選自vue2.6.11版本,這兩個API分別在initGlobalAPI函數和renderMixin函數中掛載,它們都引用了nextTick函數
nextTick源碼如下:
在內部它訪問了外部的callbacks,這個callbacks就是前面提到的隊列,nextTick一調用就給隊列push一個回調函數,然后判斷pending(pending的作用就是控制同一時間內只執行一次timerFunc),調用timerFunc(),最后返回了一個Promise(使用過nextTick的應該都知道吧)。
我們來看一下callbacks、pending、timerFunc是如何定義的
可以看到timerFunc函數只是調用了p.then方法并將flushCallbacks函數推入了微任務隊列,而p是一個fulfilled狀態的Promise,與我們自己的nextTick功能一致。
這個flushCallbacks函數又干了什么呢?
flushCallbacks中重新將pending置為初始值,復制callbacks隊列中的任務后將隊列清空,然后依次執行復制的任務,與我們自己的flushJobs函數功能一致。
看完上面的源碼,可以總結出Vue是這么做的,又到了小學語文之——提煉中心思想的時候了
- 監聽到數據變化后調用dep.notify()進行通知,將任務放入隊列,且相同的任務只添加一次
- 調用Promise.resolve().then(flushCallbacks),將執行任務的函數推入微任務隊列,等待所有的同步任務完成后將進行執行
- 所有同步執行完成,執行flushCallbacks函數進行渲染
對比一下我們自己寫的代碼,你學會了嗎?
以上演示代碼已上傳github:
https://github.com/Mr-Jemp/VueStudy/blob/main/vue-reactive-demo/src/assets/js/demo3.js
后面要學習的內容在這里:
本文由博客一文多發平臺 OpenWrite 發布!