首頁>技術>

Vue中$nextTick方法將回調延遲到下次DOM更新迴圈之後執行,也就是在下次DOM更新迴圈結束之後執行延遲迴調,在修改資料之後立即使用這個方法,能夠獲取更新後的DOM。簡單來說就是當資料更新時,在DOM中渲染完成後,執行回撥函式。

描述#

通過一個簡單的例子來演示$nextTick方法的作用,首先需要知道Vue在更新DOM時是非同步執行的,也就是說在更新資料時其不會阻塞程式碼的執行,直到執行棧中程式碼執行結束之後,才開始執行非同步任務佇列的程式碼,所以在資料更新時,元件不會立即渲染,此時在獲取到DOM結構後取得的值依然是舊的值,而在$nextTick方法中設定的回撥函式會在元件渲染完成之後執行,取得DOM結構後取得的值便是新的值。

Copy<!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執行時,進行如下流程

首先將執行棧中程式碼同步執行,將這些程式碼中非同步任務加入後臺執行緒中執行棧中的同步程式碼執行完畢後,執行棧清空,並開始掃描微佇列取出微佇列隊首任務,放入執行棧中執行,此時微佇列是進行了出隊操作當執行棧執行完成後,繼續出隊微佇列任務並執行,直到微佇列任務全部執行完畢最後一個微佇列任務出隊並進入執行棧後微佇列中任務為空,當執行棧任務完成後,開始掃面微佇列為空,繼續掃描巨集佇列任務,巨集隊列出隊,放入執行棧中執行,執行完畢後繼續掃描微佇列為空則掃描巨集佇列,出隊執行不斷往復...例項#
Copy// Step 1console.log(1);// Step 2setTimeout(() => { console.log(2); Promise.resolve().then(() => { console.log(3); });}, 0);// Step 3new Promise((resolve, reject) => { console.log(4); resolve();}).then(() => { console.log(5);})// Step 4setTimeout(() => { console.log(6);}, 0);// Step 5console.log(7);// Step N// ...// Result/* 1 4 7 5 2 3 6*/
Step 1#
Copy// 執行棧 console// 微佇列 []// 巨集佇列 []console.log(1); // 1
Step 2#
Copy// 執行棧 setTimeout// 微佇列 []// 巨集佇列 [setTimeout1]setTimeout(() => { console.log(2); Promise.resolve().then(() => { console.log(3); });}, 0);
Step 3#
Copy// 執行棧 Promise// 微佇列 [then1]// 巨集佇列 [setTimeout1]new Promise((resolve, reject) => { console.log(4); // 4 // Promise是個函式物件,此處是同步執行的 // 執行棧 Promise console resolve();}).then(() => { console.log(5);})
Step 4#
Copy// 執行棧 setTimeout// 微佇列 [then1]// 巨集佇列 [setTimeout1 setTimeout2]setTimeout(() => { console.log(6);}, 0);
Step 5#
Copy// 執行棧 console// 微佇列 [then1]// 巨集佇列 [setTimeout1 setTimeout2]console.log(7); // 7
Step 6#
Copy// 執行棧 then1// 微佇列 []// 巨集佇列 [setTimeout1 setTimeout2]console.log(5); // 5
Step 7#
Copy// 執行棧 setTimeout1// 微佇列 [then2]// 巨集佇列 [setTimeout2]console.log(2); // 2Promise.resolve().then(() => { console.log(3);});
Step 8#
Copy// 執行棧 then2// 微佇列 []// 巨集佇列 [setTimeout2]console.log(3); // 3
Step 9#
Copy// 執行棧 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。

Copy<!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方法可能有所變更。

Copy/** * 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。

Copyvar 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) })})();

Copyvar 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) })})();

最新評論
  • BSA-TRITC(10mg/ml) TRITC-BSA 牛血清白蛋白改性標記羅丹明
  • 前端新工具—vite從入門到實踐