Vue0.11版本源碼閲讀系列五:批量更新是怎麼做的

語言: CN / TW / HK

在第三篇vue0.11版本源碼閲讀系列三:指令編譯裏我們知道如果某個屬性的值變化了,會調用依賴該屬性的watcherupdate方法:

p.update = function () {
  if (!config.async || config.debug) {
    this.run()
  } else {
    batcher.push(this)
  }
}

它沒有直接調用指令的update方法,而是交給了batcher,本篇來看一下這個batcher做了什麼。

顧名思義,batcher是批量的意思,所以就是批量更新,為什麼要批量更新呢,先看一下下面的情況:

<div v-if="show">我出來了</div>
<div v-if="show && true">我也是</div>
window.vm.show = true
window.vm.show = false

比如有兩個指令依賴同一個屬性或者連續修改某個屬性,如果不進行批量異步更新,那麼就會多次修改dom,這顯然是沒必要的,看下面兩個動圖能更直觀的感受到:

沒有進行批量異步更新的時候:

2021-01-12-17-01-46

進行了批量異步更新:

2021-01-12-17-02-21

能清晰的發現通過異步更新能跳過中間不必要的渲染以達到優化性能的效果。

接下來看一下具體實現,首先是push函數:

// 定義了兩個隊列,一個用來存放用户的watcher,一個用來存放指令更新的watcher
var queue = []
var userQueue = []
var has = {}
var waiting = false
var flushing = false
exports.push = function (job) {
  // job就是watcher實例
  var id = job.id
  // 在沒有flushing的情況下has[id]用來跳過同一個watcher的重複添加
  if (!id || !has[id] || flushing) {
    has[id] = 1
    // 首先要説明的是通過$watch方法或者watch選項生成的watcher代表是用户的,user屬性為true
    // 這裏註釋説在執行任務中用户的watcher可能會觸發非user的指令更新,所以要立即更新這個被觸發的指令,否則flushing這個變量是不需要的
    if (flushing && !job.user) {
      job.run()
      return
    }
    // 根據指令的類型添加到不同的隊列裏
    ;(job.user ? userQueue : queue).push(job)
    // 上個隊列未被清空前不會創建新隊列
    if (!waiting) {
      waiting = true
      _.nextTick(flush)
    }
  }
}

push方法做的事情是把watcher添加到隊列quene裏,然後如果沒有扔過flushnextTick或者上次扔給nextTickflush方法已經被執行了,就再給它一個。

flush方法用來遍歷隊列裏的watcher並調用其run方法,run方法最終會調用指令的update方法來更新頁面。

function flush () {
  flushing = true
  run(queue)
  run(userQueue)
  // 清空隊列和復位變量
  reset()
}
function run (queue) {
  // 循環執行watcher實例的run方法,run方法裏會遍歷該watcher實例的指令隊列並執行指令的update方法
  for (var i = 0; i < queue.length; i++) {
    queue[i].run()
  }
}

接下來就是nextTick方法了:

exports.nextTick = (function () {
  var callbacks = []
  var pending = false
  var timerFunc
  function handle () {
    pending = false
    var copies = callbacks.slice(0)
    callbacks = []
    for (var i = 0; i < copies.length; i++) {
      copies[i]()
    }
  }
  // 支持MutationObserver接口的話使用MutationObserver
  if (typeof MutationObserver !== 'undefined') {
    var counter = 1
    var observer = new MutationObserver(handle)
    var textNode = document.createTextNode(counter)
    observer.observe(textNode, {
      characterData: true// 設為 true 表示監視指定目標節點或子節點樹中節點所包含的字符數據的變化
    })
    timerFunc = function () {
      counter = (counter + 1) % 2// counter會在0和1兩者循環變化
      textNode.data = counter// 節點變化會觸發回調handle,
    }
  } else {// 否則使用定時器
    timerFunc = setTimeout
  }
  return function (cb, ctx) {
    var func = ctx
      ? function () { cb.call(ctx) }
      : cb
    callbacks.push(func)
    if (pending) return
    pending = true
    timerFunc(handle, 0)
  }
})()

這是個自執行函數,一般用來定義並保存一些局部變量,返回了一個函數,就是nextTick方法本法了,flush方法會被pushcallbacks數組裏,我們常用的方法this.$nextTick(() => {xxxx})也會把回調添加到這個數組裏,這裏也有一個變量pending來控制重複添加的問題,最後添加到事件循環的隊列裏的是handle方法。

批量很容易理解,都放到一個隊列裏,最後一起執行就是批量執行了,但是要理解MutationObserver的回調或者setTimeout的回調為什麼能異步調用就需要先來了解一下JavaScript語言裏的事件循環Event Loop的原理了。

簡單的説就是因為JavaScript是單線程的,所以任務需要排隊進行執行,前一個執行完了才能執行後面一個,但有些任務比較耗時而且沒必要等着,所以可以先放一邊,先執行後面的,等到了可以執行了再去執行它,比如有些IO操作,像常見的鼠標鍵盤事件註冊、Ajax請求、settimeout定時器、Promise回調等。所以會存在兩個隊列,一個是同步隊列,也就是主線程,另一個是異步隊列,剛才提到的那些事件的回調如果可以被執行了都會被放在異步隊列裏,當主線程上的任務執行完畢後會把異步隊列的任務取過來進行執行,所以同步代碼總是在異步代碼之前執行,執行完了後又會去檢查異步隊列,這樣不斷循環就是Event Loop

但是異步任務裏其實還是分兩種,一種叫宏任務,常見的為:setTimeoutsetInterval,另一種叫微任務,常見的如:PromiseMutationObserver。微任務會在宏任務之前執行,即使宏任務的回調先被添加到隊列裏。

現在可以來分析一下異步更新的原理,就以開頭提到的例子來説:

<div v-if="show">我出來了</div>
<div v-if="show && true">我也是</div>
window.vm.show = true
window.vm.show = false

因為有兩個指令都依賴了show,表達式不一樣,所以會有兩個watcher,這兩個watcher都會被show屬性的dep收集,所以每修改一次show的值都會觸發這兩個watcher的更新,也就是會調兩次batcher.push(this)方法,第一次調用後會執行_.nextTick(flush)註冊一個回調,連續兩次修改show的值,會調用四次上述提到的batcher.push(this)方法,因為重複添加的被過濾掉了,所以最後會有兩個watcher被添加到隊列裏,以上這些操作都是同步任務,所以是連續被執行完的,等這些同步任務都被執行完了後就會把剛才註冊的回調handle拿過來執行,也就是會一次性執行剛才添加的兩個watcher

image-20210112200127418

以上就是vue異步更新的全部內容。