Vue中$nextTick的理解
Vue
中$nextTick
方法將回撥延遲到下次DOM
更新迴圈之後執行,也就是在下次DOM
更新迴圈結束之後執行延遲迴調,在修改資料之後立即使用這個方法,能夠獲取更新後的DOM
。簡單來說就是當資料更新時,在DOM
中渲染完成後,執行回撥函式。
描述
通過一個簡單的例子來演示$nextTick
方法的作用,首先需要知道Vue
在更新DOM
時是非同步執行的,也就是說在更新資料時其不會阻塞程式碼的執行,直到執行棧中程式碼執行結束之後,才開始執行非同步任務佇列的程式碼,所以在資料更新時,元件不會立即渲染,此時在獲取到DOM
結構後取得的值依然是舊的值,而在$nextTick
方法中設定的回撥函式會在元件渲染完成之後執行,取得DOM
結構後取得的值便是新的值。
<!DOCTYPE html>
<html>
<head>
<title>Vue</title>
</head>
<body>
<div id="app"></div>
</body>
<script src="https://cdn.bootcss.com/vue/2.4.2/vue.js"></script>
<script type="text/javascript">
var vm = new Vue({
el: '#app',
data: {
msg: 'Vue'
},
template:`
<div>
<div ref="msgElement">{{msg}}</div>
<button @click="updateMsg">updateMsg</button>
</div>
`,
methods:{
updateMsg: function(){
this.msg = "Update";
console.log("DOM未更新:", this.$refs.msgElement.innerHTML)
this.$nextTick(() => {
console.log("DOM已更新:", this.$refs.msgElement.innerHTML)
})
}
},
})
</script>
</html>
非同步機制
官方文件中說明,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
執行時,進行如下流程
- 首先將執行棧中程式碼同步執行,將這些程式碼中非同步任務加入後臺執行緒中
- 執行棧中的同步程式碼執行完畢後,執行棧清空,並開始掃描微佇列
- 取出微佇列隊首任務,放入執行棧中執行,此時微佇列是進行了出隊操作
- 當執行棧執行完成後,繼續出隊微佇列任務並執行,直到微佇列任務全部執行完畢
- 最後一個微佇列任務出隊並進入執行棧後微佇列中任務為空,當執行棧任務完成後,開始掃面微佇列為空,繼續掃描巨集佇列任務,巨集佇列出隊,放入執行棧中執行,執行完畢後繼續掃描微佇列為空則掃描巨集佇列,出隊執行
- 不斷往復...
例項
// 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
// 執行棧 console
// 微佇列 []
// 巨集佇列 []
console.log(1); // 1
Step 2
// 執行棧 setTimeout
// 微佇列 []
// 巨集佇列 [setTimeout1]
setTimeout(() => {
console.log(2);
Promise.resolve().then(() => {
console.log(3);
});
}, 0);
Step 3
// 執行棧 Promise
// 微佇列 [then1]
// 巨集佇列 [setTimeout1]
new Promise((resolve, reject) => {
console.log(4); // 4 // Promise是個函式物件,此處是同步執行的 // 執行棧 Promise console
resolve();
}).then(() => {
console.log(5);
})
Step 4
// 執行棧 setTimeout
// 微佇列 [then1]
// 巨集佇列 [setTimeout1 setTimeout2]
setTimeout(() => {
console.log(6);
}, 0);
Step 5
// 執行棧 console
// 微佇列 [then1]
// 巨集佇列 [setTimeout1 setTimeout2]
console.log(7); // 7
Step 6
// 執行棧 then1
// 微佇列 []
// 巨集佇列 [setTimeout1 setTimeout2]
console.log(5); // 5
Step 7
// 執行棧 setTimeout1
// 微佇列 [then2]
// 巨集佇列 [setTimeout2]
console.log(2); // 2
Promise.resolve().then(() => {
console.log(3);
});
Step 8
// 執行棧 then2
// 微佇列 []
// 巨集佇列 [setTimeout2]
console.log(3); // 3
Step 9
// 執行棧 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
。
<!DOCTYPE html>
<html>
<head>
<title>Vue</title>
</head>
<body>
<div id="app"></div>
</body>
<script src="https://cdn.bootcss.com/vue/2.4.2/vue.js"></script>
<script type="text/javascript">
var vm = new Vue({
el: '#app',
data: {
msg: 'Vue'
},
template:`
<div>
<div ref="msgElement">{{msg}}</div>
<button @click="updateMsg">updateMsg</button>
<button @click="updateMsgTest">updateMsgTest</button>
</div>
`,
methods:{
updateMsg: function(){
this.msg = "Update";
setTimeout(() => console.log(1))
Promise.resolve().then(() => console.log(2))
this.$nextTick(() => {
console.log(3)
})
},
updateMsgTest: function(){
setTimeout(() => console.log(1))
Promise.resolve().then(() => console.log(2))
this.$nextTick(() => {
console.log(3)
})
}
},
})
</script>
</html>
這裡假設執行環境中Promise
物件是完全支援的,那麼使用setTimeout
是巨集佇列在最後執行這個是沒有異議的,但是使用$nextTick
方法以及自行定義的Promise
例項是有執行順序的問題的,雖然都是微佇列任務,但是在Vue
中具體實現的原因導致了執行順序可能會有所不同,首先直接看一下$nextTick
方法的原始碼,關鍵地方新增了註釋,請注意這是Vue2.4.2
版本的原始碼,在後期$nextTick
方法可能有所變更。
/**
* 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
。
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/5a45fdeb6fb9a044ff31c9a8