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
