Vue—關于響應式(二、異步更新隊列原理分析)

本節需要準備知識點:Event Loop、Promise

關于Event Loop介紹參考阮一峰老師的文章:

  1. http://www.ruanyifeng.com/blog/2013/10/event_loop.html
  2. https://www.ruanyifeng.com/blog/2014/10/event-loop.html

關于Promise:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Promise

上一節學習了Vue通過Object.defineProperty攔截數據變化的響應式原理,數據變化后會觸發notify方法來通知變更,這一節沿著圖譜繼續往下啃,收到通知后Vue會開啟一個異步更新隊列

1628823615.png

兩個問題:

  1. Vue開啟一個異步更新隊列,為什么不是同步而是異步?

  2. 不知道你有沒有發現在Vue中修改data中的數據時,無論修改幾次,最終模板只渲染一次,這是怎么做到的?

一、異步更新隊列

先來看一段代碼演示

把上一節的代碼拿過來:

let x;
let y;
let f = (n) => n * 100;

let active;
let onXChange = function(cb) {
  active = cb;
  active();
};

class Dep {
  deps = new Set();

  // 收集依賴
  depend() {
    if (active) {
      this.deps.add(active);
    }
  }

  // 通知依賴更新
  notify() {
    this.deps.forEach((dep) => dep());
  }
}

let ref = (initValue) => {
  let value = initValue;
  let dep = new Dep();

  return Object.defineProperty({}, "value", {
    get() {
      dep.depend();
      return value;
    },
    set(newValue) {
      value = newValue;
      dep.notify();
    },
  });
};

x = ref(1);

onXChange(() => {
  y = f(x.value);
  console.log('onXChange', y);
});

x.value = 2;
x.value = 3;

假設我們現在不止依賴x,還有y、z,分別將x、y、z輸出到頁面上。我們現在依賴了x、y、z三個變量,那我們應該把這個onXChange函數名改成watch,就是它可以監聽變化的意思,不單單只是監聽一個x變化。

let x;
let y;
let z;

x = ref(1);
y = ref(2);
z = ref(3);

// 考慮到我們會依賴很多變量,因此將onXChange改成watch比較符合語義
watch(() => {
  document.write(`
    <p>
      x: ${f(x.value)}; y: ${f(y.value)}; z: ${f(z.value)}
    </p>
  `)
});

可以看到這三個值都被打印在頁面上

1628824182.png

現在我們對x、y、z的value進行修改

x.value = 2;
y.value = 3;
z.value = 4;

查看頁面,結果沒有問題,每個數據的變化都被監聽到并且進行了響應

1628824262.png

既然結果是對的,那我們的問題是什么?

這個問題是:每次數據變化都進行了響應,每次都渲染了模板,如果數據變化了一百次、一千次呢?難道要重復渲染一百遍、一千遍嗎?

我們都知道頻繁操作dom會影響網頁性能,涉及重排和重繪的知識感興趣請閱讀阮一峰老師的文章:

https://www.ruanyifeng.com/blog/2015/09/web-page-performance-in-depth.html

因此,既要保證所有的依賴都準確更新,又要保證不能頻繁渲染成為了首要問題,現在我們修改x.value、y.value、z.value都是同步通知依賴進行更新的,有沒有一種機制可以等到我修改這些值之后再執行更新任務呢?

這個答案是——異步。

異步任務會等到同步任務清空后執行,借助這個特點和我們前面的分析,我們需要:

  • 創建一個隊列用來存儲任務
  • 創建一個將任務推入隊列的方法
  • 創建一個用來執行隊列中任務的方法
  • Promise(用來創建微任務)

按照步驟我們創建如下代碼:

// 創建任務隊列
let queue = [];

// 創建添加任務的方法
let queueJob = (job) => {
  // 過濾已經添加的任務
  if (!queue.includes(job)) {
    queue.push(job); // 添加任務
    flushJobs(); // 執行任務,請注意這里現在是偽代碼
  }
};

// 創建執行任務的方法
let flushJobs = () => {
  let job;
  // 依次取出隊列的任務賦值給job并執行,直到清空隊列
  while ((job = queue.shift()) !== undefined) {
    job();
  }
};

// 創建Promise,待定

接著我們需要修改一下notify的代碼,監聽到數據變化后不立即調用依賴進行更新,而是將依賴添加到隊列中

notify() {
  this.deps.forEach(dep => queueJob(dep));
}

回到頁面,我們發現頁面上還是重復渲染了三次模板

1628824737.png

那我們寫的這段代碼有什么用呢?異步又體現在哪里呢?接著往下看

二、 nextTick原理分析

上面的代碼中雖然我們開啟了一個隊列,并且成功將任務推入隊列中進行執行,但本質上還是同步推入和執行的,我們要讓它變成異步隊列

于是就到了Promise發揮作用的時候了,關于宏任務和微任務的介紹請參考:
https://zhuanlan.zhihu.com/p/78113300

我們創建nextTick函數,nextTick接收一個回調函數,返回一個狀態為fulfilled的Promise,并將回調函數傳給then方法

// 創建Promise
let nextTick = (cb) => Promise.resolve().then(cb);

然后只需要在添加任務時調用nextTick,將執行任務的flushJobs函數傳給nextTick即可

let queueJob = (job) => {
  // 過濾已經添加的任務
  if (!queue.includes(job)) {
    queue.push(job); // 添加任務
    nextTick(flushJobs); // 推入微任務
  }
};

回到頁面

1628825748.png

雖然修改了x、y、z三個變量的value,最后頁面上只渲染了一次。

再來總結一下這段代碼的執行過程:

  • 當修改x.value時會觸發dep.notify()通知依賴更新,然后我們會開啟一個隊列將任務(這個任務就是active保存的回調函數)發給queueJob函數,queueJob函數判斷當前任務有沒有添加過,沒有,添加當前任務并執行nextTick(Promise),由于Promise調用then方法時會將then中的回調函數推入微任務隊列,所以flushJobs函數并不會立即執行,而是等到所有的同步任務都執行完成后再執行,也就是說要等到y、z修改value之后(如果后面還有別的同步代碼則要繼續等待),直到Event Loop下一個tick時才會執行flushJobs函數。(這三次通知觸發的都是同一個active,所以queueJob只會往隊列中添加一次任務)
  • 因此,無論后面y、z的值進行多少次變更,當前這個更新任務只執行一次,這樣就達到了優化的目的。

這也正是Vue采用的解決方案——異步更新隊列,官方文檔描述的很清楚

1628825919.png

文檔地址:https://cn.vuejs.org/v2/guide/reactivity.html#%E5%BC%82%E6%AD%A5%E6%9B%B4%E6%96%B0%E9%98%9F%E5%88%97

三、結合Vue源碼來看nextTick

在Vue中我們可以通過兩種方式來調用nextTick:

  • Vue.nextTick()
  • this.$nextTick()

(至于什么時候使用nextTick,你不偷懶看了官方文檔的話都能找到答案哈哈)

以下源碼節選自vue2.6.11版本,這兩個API分別在initGlobalAPI函數和renderMixin函數中掛載,它們都引用了nextTick函數

1628826037.png
1628826053.png

nextTick源碼如下:

1628826081.png

在內部它訪問了外部的callbacks,這個callbacks就是前面提到的隊列,nextTick一調用就給隊列push一個回調函數,然后判斷pending(pending的作用就是控制同一時間內只執行一次timerFunc),調用timerFunc(),最后返回了一個Promise(使用過nextTick的應該都知道吧)。

我們來看一下callbacks、pending、timerFunc是如何定義的

1628826236.png
1628826247.png

可以看到timerFunc函數只是調用了p.then方法并將flushCallbacks函數推入了微任務隊列,而p是一個fulfilled狀態的Promise,與我們自己的nextTick功能一致。

這個flushCallbacks函數又干了什么呢?

1628826956.png

flushCallbacks中重新將pending置為初始值,復制callbacks隊列中的任務后將隊列清空,然后依次執行復制的任務,與我們自己的flushJobs函數功能一致。

看完上面的源碼,可以總結出Vue是這么做的,又到了小學語文之——提煉中心思想的時候了

  1. 監聽到數據變化后調用dep.notify()進行通知,將任務放入隊列,且相同的任務只添加一次
  2. 調用Promise.resolve().then(flushCallbacks),將執行任務的函數推入微任務隊列,等待所有的同步任務完成后將進行執行
  3. 所有同步執行完成,執行flushCallbacks函數進行渲染

對比一下我們自己寫的代碼,你學會了嗎?

以上演示代碼已上傳github:

https://github.com/Mr-Jemp/VueStudy/blob/main/vue-reactive-demo/src/assets/js/demo3.js

后面要學習的內容在這里:

Vue—關于響應式(三、Diff Patch原理分析)

Vue—關于響應式(四、深入學習Vue響應式源碼)

本文由博客一文多發平臺 OpenWrite 發布!

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容