Vue為何採用非同步渲染

WindrunnerMax發表於2021-02-22

Vue為何採用非同步渲染

Vue在更新DOM時是非同步執行的,只要偵聽到資料變化,Vue將開啟一個佇列,並緩衝在同一事件迴圈中發生的所有資料變更,如果同一個watcher被多次觸發,只會被推入到佇列中一次,這種在緩衝時去除重複資料對於避免不必要的計算和DOM操作是非常重要的,然後,在下一個的事件迴圈tick中,Vue重新整理佇列並執行實際(已去重的)工作,Vue在內部對非同步佇列嘗試使用原生的Promise.thenMutationObserversetImmediate,如果執行環境不支援,則會採用setTimeout(fn, 0)代替。

描述

對於Vue為何採用非同步渲染,簡單來說就是為了提升效能,因為不採用非同步更新,在每次更新資料都會對當前元件進行重新渲染,為了效能考慮,Vue會在本輪資料更新後,再去非同步更新檢視,舉個例子,讓我們在一個方法內重複更新一個值。

this.msg = 1;
this.msg = 2;
this.msg = 3;

事實上,我們真正想要的其實只是最後一次更新而已,也就是說前三次DOM更新都是可以省略的,我們只需要等所有狀態都修改好了之後再進行渲染就可以減少一些效能損耗。
對於渲染方面的問題是很明確的,最終只渲染一次肯定比修改之後即渲染所耗費的效能少,在這裡我們還需要考慮一下非同步更新佇列的相關問題,假設我們現在是進行了相關處理使得每次更新資料只進行一次真實DOM渲染,來讓我們考慮非同步更新佇列的效能優化。
假設這裡是同步更新佇列,this.msg=1,大致會發生這些事: msg值更新 -> 觸發setter -> 觸發Watcherupdate -> 重新呼叫 render -> 生成新的vdom -> dom-diff -> dom更新,這裡的dom更新並不是渲染(即佈局、繪製、合成等一系列步驟),而是更新記憶體中的DOM樹結構,之後再執行this.msg=2,再重複上述步驟,之後的第3次更新同樣會觸發相同的流程,等開始渲染的時候,最新的DOM樹中確實只會存在更新完成3,從這裡來看,前2次對msg的操作以及Vue內部對它的處理都是無用的操作,可以進行優化處理。
如果是非同步更新佇列,會是下面的情況,執行this.msg=1,並不是立即進行上面的流程,而是將對msg有依賴的Watcher都儲存在佇列中,該佇列可能這樣[Watcher1, Watcher2...],當執行this.msg=2後,同樣是將對msg有依賴的Watcher儲存到佇列中,Vue內部會做去重判斷,這次操作後,可以認為佇列資料沒有發生變化,第3次更新也是上面的過程,當然,你不可能只對msg有操作,你可能對該元件中的另一個屬性也有操作,比如this.otherMsg=othermessage,同樣會把對otherMsg有依賴的Watcher新增到非同步更新佇列中,因為有重複判斷操作,這個Watcher也只會在佇列中存在一次,本次非同步任務執行結束後,會進入下一個任務執行流程,其實就是遍歷非同步更新佇列中的每一個Watcher,觸發其update,然後進行重新呼叫render -> new vdom -> dom-diff -> dom更新等流程,但是這種方式和同步更新佇列相比,不管操作多少次msg Vue在內部只會進行一次重新呼叫真實更新流程,所以,對於非同步更新佇列不是節省了渲染成本,而是節省了Vue內部計算及DOM樹操作的成本,不管採用哪種方式,渲染確實只有一次。
此外,元件內部實際使用VirtualDOM進行渲染,也就是說,元件內部其實是不關心哪個狀態發生了變化,它只需要計算一次就可以得知哪些節點需要更新,也就是說,如果更改了N個狀態,其實只需要傳送一個訊號就可以將DOM更新到最新,如果我們更新多個值。

this.msg = 1;
this.age = 2;
this.name = 3;

此處我們分三次修改了三種狀態,但其實Vue只會渲染一次,因為VIrtualDOM只需要一次就可以將整個元件的DOM更新到最新,它根本不會關心這個更新的訊號到底是從哪個具體的狀態發出來的。
而為了達到這個目的,我們需要將渲染操作推遲到所有的狀態都修改完成,為了做到這一點只需要將渲染操作推遲到本輪事件迴圈的最後或者下一輪事件迴圈,也就是說,只需要在本輪事件迴圈的最後,等前面更新狀態的語句都執行完之後,執行一次渲染操作,它就可以無視前面各種更新狀態的語法,無論前面寫了多少條更新狀態的語句,只在最後渲染一次就可以了。
將渲染推遲到本輪事件迴圈的最後執行渲染的時機會比推遲到下一輪快很多,所以Vue優先將渲染操作推遲到本輪事件迴圈的最後,如果執行環境不支援會降級到下一輪,Vue的變化偵測機制(setter)決定了它必然會在每次狀態發生變化時都會發出渲染的訊號,但Vue會在收到訊號之後檢查佇列中是否已經存在這個任務,保證佇列中不會有重複,如果佇列中不存在則將渲染操作新增到佇列中,之後通過非同步的方式延遲執行佇列中的所有渲染的操作並清空佇列,當同一輪事件迴圈中反覆修改狀態時,並不會反覆向佇列中新增相同的渲染操作,所以我們在使用Vue時,修改狀態後更新DOM都是非同步的。
當資料變化後會呼叫notify方法,將watcher遍歷,呼叫update方法通知watcher進行更新,這時候watcher並不會立即去執行,在update中會呼叫queueWatcher方法將watcher放到了一個佇列裡,在queueWatcher會根據watcher的進行去重,若多個屬性依賴一個watcher,則如果佇列中沒有該watcher就會將該watcher新增到佇列中,然後便會在$nextTick方法的執行佇列中加入一個flushSchedulerQueue方法(這個方法將會觸發在緩衝佇列的所有回撥的執行),然後將$nextTick方法的回撥加入$nextTick方法中維護的執行佇列,flushSchedulerQueue中開始會觸發一個before的方法,其實就是beforeUpdate,然後watcher.run()才開始真正執行watcher,執行完頁面就渲染完成,更新完成後會呼叫updated鉤子。

$nextTick

在上文中談到了對於Vue為何採用非同步渲染,假如此時我們有一個需求,需要在頁面渲染完成後取得頁面的DOM元素,而由於渲染是非同步的,我們不能直接在定義的方法中同步取得這個值的,於是就有了vm.$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.thenMutationObserversetImmediate,如果執行環境不支援,則會採用 setTimeout(fn, 0)代替。
Js是單執行緒的,其引入了同步阻塞與非同步非阻塞的執行模式,在Js非同步模式中維護了一個Event LoopEvent Loop是一個執行模型,在不同的地方有不同的實現,瀏覽器和NodeJS基於不同的技術實現了各自的Event Loop。瀏覽器的Event Loop是在HTML5的規範中明確定義,NodeJSEvent Loop是基於libuv實現的。
在瀏覽器中的Event Loop由執行棧Execution Stack、後臺執行緒Background Threads、巨集佇列Macrotask Queue、微佇列Microtask Queue組成。

  • 執行棧就是在主執行緒執行同步任務的資料結構,函式呼叫形成了一個由若干幀組成的棧。
  • 後臺執行緒就是瀏覽器實現對於setTimeoutsetIntervalXMLHttpRequest等等的執行執行緒。
  • 巨集佇列,一些非同步任務的回撥會依次進入巨集佇列,等待後續被呼叫,包括setTimeoutsetIntervalsetImmediate(Node)requestAnimationFrameUI renderingI/O等操作。
  • 微佇列,另一些非同步任務的回撥會依次進入微佇列,等待後續呼叫,包括Promiseprocess.nextTick(Node)Object.observeMutationObserver等操作。

Js執行時,進行如下流程:

  1. 首先將執行棧中程式碼同步執行,將這些程式碼中非同步任務加入後臺執行緒中。
  2. 執行棧中的同步程式碼執行完畢後,執行棧清空,並開始掃描微佇列。
  3. 取出微佇列隊首任務,放入執行棧中執行,此時微佇列是進行了出隊操作。
  4. 當執行棧執行完成後,繼續出隊微佇列任務並執行,直到微佇列任務全部執行完畢。
  5. 最後一個微佇列任務出隊並進入執行棧後微佇列中任務為空,當執行棧任務完成後,開始掃面微佇列為空,繼續掃描巨集佇列任務,巨集佇列出隊,放入執行棧中執行,執行完畢後繼續掃描微佇列為空則掃描巨集佇列,出隊執行。
  6. 不斷往復...

例項

// 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.js715行,版本為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物件上,很顯然在此之前自行定義的輸出2Promise回撥已經被掛載,那麼對於這個按鈕繫結的方法的執行流程便是首先執行console.log(2),然後執行$nextTick方法閉包的執行佇列,此時執行佇列中只有一個回撥函式console.log(3),此時微任務佇列清空,然後再去巨集任務佇列執行console.log(1)
簡單來說就是誰先掛載Promise物件的問題,在呼叫$nextTick方法時就會將其閉包內部維護的執行佇列掛載到Promise物件,在資料更新時Vue內部首先就會執行$nextTick方法,之後便將執行佇列掛載到了Promise物件上,其實在明白JsEvent 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://zhuanlan.zhihu.com/p/29631893
https://github.com/berwin/Blog/issues/22
https://juejin.cn/post/6899822303022956552
https://segmentfault.com/a/1190000015698196
https://cn.vuejs.org/v2/guide/reactivity.html
https://blog.csdn.net/weixin_46396187/article/details/107462329

相關文章