Vue中$nextTick的理解
阿新 • • 發佈:2020-07-03
# Vue中$nextTick的理解
`Vue`中`$nextTick`方法將回調延遲到下次`DOM`更新迴圈之後執行,也就是在下次`DOM`更新迴圈結束之後執行延遲迴調,在修改資料之後立即使用這個方法,能夠獲取更新後的`DOM`。簡單來說就是當資料更新時,在`DOM`中渲染完成後,執行回撥函式。
## 描述
通過一個簡單的例子來演示`$nextTick`方法的作用,首先需要知道`Vue`在更新`DOM`時是非同步執行的,也就是說在更新資料時其不會阻塞程式碼的執行,直到執行棧中程式碼執行結束之後,才開始執行非同步任務佇列的程式碼,所以在資料更新時,元件不會立即渲染,此時在獲取到`DOM`結構後取得的值依然是舊的值,而在`$nextTick`方法中設定的回撥函式會在元件渲染完成之後執行,取得`DOM`結構後取得的值便是新的值。
```html
Vue
```
## 非同步機制
官方文件中說明,`Vue`在更新`DOM`時是非同步執行的,只要偵聽到資料變化,`Vue`將開啟一個佇列,並緩衝在同一事件迴圈中發生的所有資料變更,如果同一個`watcher`被多次觸發,只會被推入到佇列中一次。這種在緩衝時去除重複資料對於避免不必要的計算和`DOM`操作是非常重要的。然後,在下一個的事件迴圈`tick`中,`Vue`重新整理佇列並執行實際工作。`Vue`在內部對非同步佇列嘗試使用原生的`Promise.then`、`MutationObserver`和`setImmediate`,如果執行環境不支援,則會採用 `setTimeout(fn, 0)`代替。
`Js`是單執行緒的,其引入了同步阻塞與非同步非阻塞的執行模式,在`Js`非同步模式中維護了一個`Event Loop`,`Event Loop`是一個執行模型,在不同的地方有不同的實現,瀏覽器和`NodeJS`基於不同的技術實現了各自的`Event Loop`。瀏覽器的`Event Loop`是在`HTML5`的規範中明確定義,`NodeJS`的`Event Loop`是基於`libuv`實現的。
在瀏覽器中的`Event Loop`由執行棧`Execution Stack`、後臺執行緒`Background Threads`、巨集佇列`Macrotask Queue`、微佇列`Microtask Queue`組成。
* 執行棧就是在主執行緒執行同步任務的資料結構,函式呼叫形成了一個由若干幀組成的棧。
* 後臺執行緒就是瀏覽器實現對於`setTimeout`、`setInterval`、`XMLHttpRequest`等等的執行執行緒。
* 巨集佇列,一些非同步任務的回撥會依次進入巨集佇列,等待後續被呼叫,包括`setTimeout`、`setInterval`、`setImmediate(Node)`、`requestAnimationFrame`、`UI rendering`、`I/O`等操作
* 微佇列,另一些非同步任務的回撥會依次進入微佇列,等待後續呼叫,包括`Promise`、`process.nextTick(Node)`、`Object.observe`、`MutationObserver`等操作
當`Js`執行時,進行如下流程
1. 首先將執行棧中程式碼同步執行,將這些程式碼中非同步任務加入後臺執行緒中
2. 執行棧中的同步程式碼執行完畢後,執行棧清空,並開始掃描微佇列
3. 取出微佇列隊首任務,放入執行棧中執行,此時微佇列是進行了出隊操作
4. 當執行棧執行完成後,繼續出隊微佇列任務並執行,直到微佇列任務全部執行完畢
5. 最後一個微佇列任務出隊並進入執行棧後微佇列中任務為空,當執行棧任務完成後,開始掃面微佇列為空,繼續掃描巨集佇列任務,巨集隊列出隊,放入執行棧中執行,執行完畢後繼續掃描微佇列為空則掃描巨集佇列,出隊執行
6. 不斷往復...
### 例項
```javascript
// Step 1
console.log(1);
// Step 2
setTimeout(() => {
console.log(2);
Promise.resolve().then(() => {
console.log(3);
});
}, 0);
// Step 3
new Promise((resolve, reject) => {
console.log(4);
resolve();
}).then(() => {
console.log(5);
})
// Step 4
setTimeout(() => {
console.log(6);
}, 0);
// Step 5
console.log(7);
// Step N
// ...
// Result
/*
1
4
7
5
2
3
6
*/
```
#### Step 1
```javascript
// 執行棧 console
// 微佇列 []
// 巨集佇列 []
console.log(1); // 1
```
#### Step 2
```javascript
// 執行棧 setTimeout
// 微佇列 []
// 巨集佇列 [setTimeout1]
setTimeout(() => {
console.log(2);
Promise.resolve().then(() => {
console.log(3);
});
}, 0);
```
#### Step 3
```javascript
// 執行棧 Promise
// 微佇列 [then1]
// 巨集佇列 [setTimeout1]
new Promise((resolve, reject) => {
console.log(4); // 4 // Promise是個函式物件,此處是同步執行的 // 執行棧 Promise console
resolve();
}).then(() => {
console.log(5);
})
```
#### Step 4
```javascript
// 執行棧 setTimeout
// 微佇列 [then1]
// 巨集佇列 [setTimeout1 setTimeout2]
setTimeout(() => {
console.log(6);
}, 0);
```
#### Step 5
```javascript
// 執行棧 console
// 微佇列 [then1]
// 巨集佇列 [setTimeout1 setTimeout2]
console.log(7); // 7
```
#### Step 6
```javascript
// 執行棧 then1
// 微佇列 []
// 巨集佇列 [setTimeout1 setTimeout2]
console.log(5); // 5
```
#### Step 7
```javascript
// 執行棧 setTimeout1
// 微佇列 [then2]
// 巨集佇列 [setTimeout2]
console.log(2); // 2
Promise.resolve().then(() => {
console.log(3);
});
```
#### Step 8
```javascript
// 執行棧 then2
// 微佇列 []
// 巨集佇列 [setTimeout2]
console.log(3); // 3
```
#### Step 9
```javascript
// 執行棧 setTimeout2
// 微佇列 []
// 巨集佇列 []
console.log(6); // 6
```
## 分析
在瞭解非同步任務的執行佇列後,回到中`$nextTick`方法,當用戶資料更新時,`Vue`將會維護一個緩衝佇列,對於所有的更新資料將要進行的元件渲染與`DOM`操作進行一定的策略處理後加入緩衝佇列,然後便會在`$nextTick`方法的執行佇列中加入一個`flushSchedulerQueue`方法(這個方法將會觸發在緩衝佇列的所有回撥的執行),然後將`$nextTick`方法的回撥加入`$nextTick`方法中維護的執行佇列,在非同步掛載的執行佇列觸發時就會首先會首先執行`flushSchedulerQueue`方法來處理`DOM`渲染的任務,然後再去執行`$nextTick`方法構建的任務,這樣就可以實現在`$nextTick`方法中取得已渲染完成的`DOM`結構。在測試的過程中發現了一個很有意思的現象,在上述例子中的加入兩個按鈕,在點選`updateMsg`按鈕的結果是`3 2 1`,點選`updateMsgTest`按鈕的執行結果是`2 3 1`。
```html
Vue
```
這裡假設執行環境中`Promise`物件是完全支援的,那麼使用`setTimeout`是巨集佇列在最後執行這個是沒有異議的,但是使用`$nextTick`方法以及自行定義的`Promise`例項是有執行順序的問題的,雖然都是微佇列任務,但是在`Vue`中具體實現的原因導致了執行順序可能會有所不同,首先直接看一下`$nextTick`方法的原始碼,關鍵地方添加了註釋,請注意這是`Vue2.4.2`版本的原始碼,在後期`$nextTick`方法可能有所變更。
```javascript
/**
* Defer a task to execute it asynchronously.
*/
var nextTick = (function () {
// 閉包 內部變數
var callbacks = []; // 執行佇列
var pending = false; // 標識,用以判斷在某個事件迴圈中是否為第一次加入,第一次加入的時候才觸發非同步執行的佇列掛載
var timerFunc; // 以何種方法執行掛載非同步執行佇列,這裡假設Promise是完全支援的
function nextTickHandler () { // 非同步掛載的執行任務,觸發時就已經正式準備開始執行非同步任務了
pending = false; // 標識置false
var copies = callbacks.slice(0); // 建立副本
callbacks.length = 0; // 執行佇列置空
for (var i = 0; i < copies.length; i++) {
copies[i](); // 執行
}
}
// the nextTick behavior leverages the microtask queue, which can be accessed
// via either native Promise.then or MutationObserver.
// MutationObserver has wider support, however it is seriously bugged in
// UIWebView in iOS >= 9.3.3 when triggered in touch event handlers. It
// completely stops working after triggering a few times... so, if native
// Promise is available, we will use it:
/* istanbul ignore if */
if (typeof Promise !== 'undefined' && isNative(Promise)) {
var p = Promise.resolve();
var logError = function (err) { console.error(err); };
timerFunc = function () {
p.then(nextTickHandler).catch(logError); // 掛載非同步任務佇列
// in problematic UIWebViews, Promise.then doesn't completely break, but
// it can get stuck in a weird state where callbacks are pushed into the
// microtask queue but the queue isn't being flushed, until the browser
// needs to do some other work, e.g. handle a timer. Therefore we can
// "force" the microtask queue to be flushed by adding an empty timer.
if (isIOS) { setTimeout(noop); }
};
} else if (typeof MutationObserver !== 'undefined' && (
isNative(MutationObserver) ||
// PhantomJS and iOS 7.x
MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
// use MutationObserver where native Promise is not available,
// e.g. PhantomJS IE11, iOS7, Android 4.4
var counter = 1;
var observer = new MutationObserver(nextTickHandler);
var textNode = document.createTextNode(String(counter));
observer.observe(textNode, {
characterData: true
});
timerFunc = function () {
counter = (counter + 1) % 2;
textNode.data = String(counter);
};
} else {
// fallback to setTimeout
/* istanbul ignore next */
timerFunc = function () {
setTimeout(nextTickHandler, 0);
};
}
return function queueNextTick (cb, ctx) { // nextTick方法真正匯出的方法
var _resolve;
callbacks.push(function () { // 新增到執行佇列中 並加入異常處理
if (cb) {
try {
cb.call(ctx);
} catch (e) {
handleError(e, ctx, 'nextTick');
}
} else if (_resolve) {
_resolve(ctx);
}
});
//判斷在當前事件迴圈中是否為第一次加入,若是第一次加入則置標識為true並執行timerFunc函式用以掛載執行佇列到Promise
// 這個標識在執行佇列中的任務將要執行時便置為false並建立執行佇列的副本去執行執行佇列中的任務,參見nextTickHandler函式的實現
// 在當前事件迴圈中置標識true並掛載,然後再次呼叫nextTick方法時只是將任務加入到執行佇列中,直到掛載的非同步任務觸發,便置標識為false然後執行任務,再次呼叫nextTick方法時就是同樣的執行方式然後不斷如此往復
if (!pending) {
pending = true;
timerFunc();
}
if (!cb && typeof Promise !== 'undefined') {
return new Promise(function (resolve, reject) {
_resolve = resolve;
})
}
}
})();
```
回到剛才提出的問題上,在更新`DOM`操作時會先觸發`$nextTick`方法的回撥,解決這個問題的關鍵在於誰先將非同步任務掛載到`Promise`物件上。
首先對有資料更新的`updateMsg`按鈕觸發的方法進行`debug`,斷點設定在`Vue.js`的`715`行,版本為`2.4.2`,在檢視呼叫棧以及傳入的引數時可以觀察到第一次執行`$nextTick`方法的其實是由於資料更新而呼叫的`nextTick(flushSchedulerQueue);`語句,也就是說在執行`this.msg = "Update";`的時候就已經觸發了第一次的`$nextTick`方法,此時在`$nextTick`方法中的任務佇列會首先將`flushSchedulerQueue`方法加入佇列並掛載`$nextTick`方法的執行佇列到`Promise`物件上,然後才是自行自定義的`Promise.resolve().then(() => console.log(2))`語句的掛載,當執行微任務佇列中的任務時,首先會執行第一個掛載到`Promise`的任務,此時這個任務是執行執行佇列,這個佇列中有兩個方法,首先會執行`flushSchedulerQueue`方法去觸發元件的`DOM`渲染操作,然後再執行`console.log(3)`,然後執行第二個微佇列的任務也就是`() => console.log(2)`,此時微任務佇列清空,然後再去巨集任務佇列執行`console.log(1)`。
接下來對於沒有資料更新的`updateMsgTest`按鈕觸發的方法進行`debug`,斷點設定在同樣的位置,此時沒有資料更新,那麼第一次觸發`$nextTick`方法的是自行定義的回撥函式,那麼此時`$nextTick`方法的執行佇列才會被掛載到`Promise`物件上,很顯然在此之前自行定義的輸出`2`的`Promise`回撥已經被掛載,那麼對於這個按鈕繫結的方法的執行流程便是首先執行`console.log(2)`,然後執行`$nextTick`方法閉包的執行佇列,此時執行佇列中只有一個回撥函式`console.log(3)`,此時微任務佇列清空,然後再去巨集任務佇列執行`console.log(1)`。
簡單來說就是誰先掛載`Promise`物件的問題,在呼叫`$nextTick`方法時就會將其閉包內部維護的執行佇列掛載到`Promise`物件,在資料更新時`Vue`內部首先就會執行`$nextTick`方法,之後便將執行佇列掛載到了`Promise`物件上,其實在明白`Js`的`Event Loop`模型後,將資料更新也看做一個`$nextTick`方法的呼叫,並且明白`$nextTick`方法會一次性執行所有推入的回撥,就可以明白其執行順序的問題了,下面是一個關於`$nextTick`方法的最小化的`DEMO`。
```javascript
var nextTick = (function(){
var pending = false;
const callback = [];
var p = Promise.resolve();
var handler = function(){
pending = true;
callback.forEach(fn => fn());
}
var timerFunc = function(){
p.then(handler);
}
return function queueNextTick(fn){
callback.push(() => fn());
if(!pending){
pending = true;
timerFunc();
}
}
})();
(function(){
nextTick(() => console.log("觸發DOM渲染佇列的方法")); // 註釋 / 取消註釋 來檢視效果
setTimeout(() => console.log(1))
Promise.resolve().then(() => console.log(2))
nextTick(() => {
console.log(3)
})
})();
```
## 每日一題
```
https://github.com/WindrunnerMax/EveryDay
```
## 參考
```
https://www.jianshu.com/p/e7ce7613f630
https://cn.vuejs.org/v2/api/#vm-nextTick
https://segmentfault.com/q/1010000021240464
https://juejin.im/post/5d391ad8f265da1b8d166175
https://juejin.im/post/5ab94ee251882577b45f05c7
https://juejin.im/post/5a45fdeb6fb9a044ff31c