Vue中$nextTick的理解
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="//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="//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)
})
})();
每日一題
//github.com/WindrunnerMax/EveryDay
參考
//www.jianshu.com/p/e7ce7613f630
//cn.vuejs.org/v2/api/#vm-nextTick
//segmentfault.com/q/1010000021240464
//juejin.im/post/5d391ad8f265da1b8d166175
//juejin.im/post/5ab94ee251882577b45f05c7
//juejin.im/post/5a45fdeb6fb9a044ff31c9a8