Vue非同步更新佇列原理從入門到放棄

王巍達發表於2017-12-29

宣告:本文章中所有原始碼取自Version: 2.5.13的dev分支上的Vue,不保證文章內觀點的絕對準確性。文章整理自本週我在小組的內部分享。

文章原地址

我們目前的技術棧主要採用Vue,而工作中我們碰到了一種情況是當傳入某些元件內的props被改變時我們需要重置整個元件的生命週期(比如更改IView中datepicker的type,好訊息是目前該元件已經可以不用再使用這麼愚蠢的方法來切換時間顯示器的型別)。為了達成這個目的,於是我們有了如下程式碼

<template>
  <button @click="handleClick">btn</button>
  <someComponent  v-if="show" />
</template>

<script>
  {
    data() {
      return { show: true }
    },
    methods: {
      handleClick() {
        this.show = false
        this.show = true
      }
    }
  }
</script>
複製程式碼

別笑,我們當然知道這段程式碼有多愚蠢,不用嘗試也確定這是錯的,但是憑藉react的經驗我大概知道將this.show = true換成setTimeout(() => { this.show = true }, 0),就應該可以得到想要的結果,果然,元件重置了其生命週期,但是事情還是有點不對頭。我們經過幾次點選發現元件總是會閃一下。邏輯上這很好理解,元件先銷燬後重建有這種情況是很正常的,但是抱歉,我們找到了另一種方式(畢竟谷歌是萬能的),將setTimeout(() => { this.show = true }, 0)換成this.$nextTick(() => { this.show = true }),神奇的事情來了,元件依然重置了其生命週期,但是元件本沒沒有絲毫的閃動。

為了讓親愛的您感受到我這段虛無縹緲的描述,我為您貼心準備了此demo,您可以將handle1依次換為handle2與handle3來體驗元件在閃動與不閃動之間徘徊的快感。

如果您體驗完快感後仍然選擇繼續閱讀那麼我要跟你說的是接下來的內容是會比較長的,因為要想完全弄明白這件事我們必須深入Vue的內部與Javascript的EventLoop兩個方面。

導致此問題的主要原因在於Vue預設採用的是的非同步更新佇列的方式,我們可以從官網上找到以下描述

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

這段話確實精簡的描述了整個流程,但是並不能解決我們的疑惑,接下來是時候展示真正的技術了。需要說明的是以下核心流程如果您沒閱讀過一些介紹原始碼的blog或者是閱讀過原始碼,那麼您可能一臉懵b。但是沒關係在這裡我們最終關心的基本上只是第4步,您只需要大概將其記住,然後將這個流程對應我們後面解析的原始碼就可以了。

Vue的核心流程大體可以分成以下幾步

  1. 遍歷屬性為其增加get,set方法,在get方法中會收集依賴(dev.subs.push(watcher)),而set方法則會呼叫dev的notify方法,此方法的作用是通知subs中的所有的watcher並呼叫watcher的update方法,我們可以將此理解為設計模式中的釋出與訂閱

  2. 預設情況下update方法被呼叫後會觸發queueWatcher函式,此函式的主要功能就是將watcher例項本身加入一個佇列中(queue.push(watcher)),然後呼叫nextTick(flushSchedulerQueue)

  3. flushSchedulerQueue是一個函式,目的是呼叫queue中所有watcher的watcher.run方法,而run方法被呼叫後接下來的操作就是通過新的虛擬dom與老的虛擬dom做diff演算法後生成新的真實dom

  4. 只是此時我們flushSchedulerQueue並沒有執行,第二步的最終做的只是將flushSchedulerQueue又放進一個callbacks佇列中(callbacks.push(flushSchedulerQueue)),然後非同步的將callbacks遍歷並執行(此為非同步更新佇列)

  5. 如上所說flushSchedulerQueue在被執行後呼叫watcher.run(),於是你看到了一個新的頁面

以上所有流程都在vue/src/core資料夾中。

接下來我們按照上面例子中的最後一種情況來分析Vue程式碼的執行過程,其中一些細節我會有所省略,請記住開始的話,我們這裡最關心的只是第四步

當點選按鈕後,繫結在按鈕上的回撥函式被觸發,this.show = false被執行,觸發了屬性中的set函式,set函式中,dev的notify方法被呼叫,導致其subs中每個watcher的update方法都被執行(在本例中subs陣列裡只有一個watcher~),一起來看下watcher的建構函式

class Watcher {
  constructor (vm) {
    // 將vue例項繫結在watcher的vm屬性上
    this.vm = vm 
  }
  update () {
     // 預設情況下都會進入else的分支,同步則直接呼叫watcher的run方法
    if (this.lazy) {
      this.dirty = true
    } else if (this.sync) {
      this.run()
    } else {
      queueWatcher(this)
    }
  }
}
複製程式碼

再來看下queueWatcher

/**
 * 將watcher例項推入queue(一個陣列)中,
 * 被has物件標記的watcher不會重複被加入到佇列
 */
export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  // 判斷watcher是否被標記過,has為一個物件,此方案類似陣列去重時利用object儲存陣列值
  if (has[id] == null) {
    // 沒被標記過的watcher進入分支後被標記上
    has[id] = true
    if (!flushing) {
      // 推入到佇列中
      queue.push(watcher)
    } else {
      // 如果是在flush佇列時被加入,則根據其watcher的id將其插入正確的位置
      // 如果不幸該watcher已經錯過了被呼叫的時機則會被立即呼叫
      // 稍後看flushSchedulerQueue這個函式會理解這兩段註釋的意思
      let i = queue.length - 1
      while (i > index && queue[i].id > watcher.id) {
        i--
      }
      queue.splice(i + 1, 0, watcher)
    }
    // queue the flush
    if (!waiting) {
      waiting = true
     // 我們關心的重點nextTick函式,其實我們寫的this.$nextTick也是呼叫的此函式
      nextTick(flushSchedulerQueue)
    }
  }
}
複製程式碼

這個函式執行後,我們的watcher進入到了queue佇列中(本例中queue內部也只被新增這一個watcher),然後呼叫nextTick(flushSchedulerQueue),這裡我們先來看下flushSchedulerQueue函式的原始碼

/**
 * flush整個佇列,呼叫watcher
 */
function flushSchedulerQueue () {
  // 將flush置為true,請聯絡上文
  flushing = true
  let watcher, id

  // flush佇列前先排序
  // 目的是
  // 1.Vue中的元件的建立與更新有點類似於事件捕獲,都是從最外層向內層延伸,所以要先
  // 呼叫父元件的建立與更新
  // 2. userWatcher比renderWatcher建立要早(抱歉並不能給出我的解釋,我沒理解)
  // 3. 如果父元件的watcher呼叫run時將父元件幹掉了,那其子元件的watcher也就沒必要呼叫了
  queue.sort((a, b) => a.id - b.id)
  
  // 此處不快取queue的length,因為在迴圈過程中queue依然可能被新增watcher導致length長度的改變
  for (index = 0; index < queue.length; index++) {
    // 取出每個watcher
    watcher = queue[index]
    id = watcher.id
    // 清掉標記
    has[id] = null
    // 更新dom走起
    watcher.run()
    // dev環境下,檢測是否為死迴圈
    if (process.env.NODE_ENV !== 'production' && has[id] != null) {
      circular[id] = (circular[id] || 0) + 1
      if (circular[id] > MAX_UPDATE_COUNT) {
        warn(
          'You may have an infinite update loop ' + (
            watcher.user
              ? `in watcher with expression "${watcher.expression}"`
              : `in a component render function.`
          ),
          watcher.vm
        )
        break
      }
    }
  }
複製程式碼

仍然要記得,此時我們的flushSchedulerQueue 還沒執行,它只是被當作回撥傳入了nextTick中,接下來我們就來說說我們本次的重點nextTick,建議您整體的看一下nextTick的原始碼,雖然我也都會解釋到

我們首先從next-tick.js中提取出來withMacroTask這個函式來說明,很抱歉我把這個函式放到了最後,因為我想讓親愛的您知道,最重要的東西總是要壓軸登場的。但是從整體流程來說當我們點選btn的時候,其實第一步應該是呼叫此函式。

/**
 * 包裝引數fn,讓其使用marcotask
 * 這裡的fn為我們在事件上繫結的回撥函式
 */
export function withMacroTask (fn: Function): Function {
  return fn._withTask || (fn._withTask = function () {
    useMacroTask = true
    const res = fn.apply(null, arguments)
    useMacroTask = false
    return res
  })
}
複製程式碼

沒錯,其實您繫結在onclick上的回撥函式是在這個函式內以apply的形式觸發的,請您先去在此處打一個斷點來驗證。好的,我現在相信您已經證明了我所言非虛,但是其實那不重要,因為重要的是我們在此處立了一個flag,useMacroTask = true ,這才是很關鍵的東西,谷歌翻譯一下我們可以知道它的具體含義,用巨集任務

黑人問號

OK,這就要從我們文章開頭所說的第二部分EventLoop講起了。

其實這部分內容相信對已經看到這裡的您來說早就接觸過了,如果還真的不太清除的話推薦您仔細的看一下阮一封老師的這篇文章,我們只會大概的做一個總結

  1. 我們的同步任務的呼叫形成了一個棧結構
  2. 除此之外我們還有一個任務佇列,當一個非同步任務有了結果後會向佇列中新增一個任務,每個任務都對應著一個回撥函式
  3. 當我們的棧結構為空時,就會讀取任務佇列,同時呼叫其對應的回撥函式
  4. 重複

這個總結目前來說對於我們比較欠缺的資訊就是佇列中的任務其實是分為兩種的,巨集任務(macrotask)與微任務(microtask)。 當主執行緒上執行的所有同步任務結束後會從任務佇列中抽取出所有微任務執行,當微任務也執行完畢後一輪事件迴圈就結束了,然後瀏覽器會重新渲染(請謹記這點,因為正是此原因才會導致文章開頭所說的問題)。之後再從佇列中取出巨集任務繼續下一輪的事件迴圈,值得注意的一點是執行微任務時仍然可以繼續產生微任務在本輪事件迴圈中不停的執行。所以本質上微任務的優先順序是高於巨集任務的。

如果您想更詳細的瞭解巨集任務與微任務那麼推薦您閱讀這篇文章,這或許是東半球關於這個問題解釋的最好,最易懂,最詳細的文章了。

巨集任務與微任務產生的方式並不相同,瀏覽器環境下setImmediate,MessageChannel,setTimeout會產生巨集任務,而MutationObserver ,Promise則會產生微任務。而這也是Vue中採取的非同步方式,Vue會根據useMacroTask的布林值來判斷是要產生巨集任務還是產生微任務來非同步更新佇列,我們會稍後看到這部分,現在我們還是走回我們原來的邏輯吧。

當fn在withMacroTask函式中被呼叫後就產生了我們以上所講的所有步驟,現在是時候來真正看下nextTick函式都幹了什麼

export function nextTick (cb?: Function, ctx?: Object) {
  let _resolve
  // callbacks為一個陣列,此處將cb推進陣列,本例中此cb為剛才還未執行的flushSchedulerQueue
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  // 標記位,保證之後如果有this.$nextTick之類的操作不會再次執行以下程式碼
  if (!pending) {
    pending = true
    // 用微任務還是用巨集任務,此例中執行到現在為止Vue的選擇是用巨集任務
    // 其實我們可以理解成所有用v-on繫結事件所直接產生的資料變化都是採用巨集任務的方式
    // 因為我們繫結的回撥都經過了withMacroTask的包裝,withMacroTask中會使useMacroTask為true
    if (useMacroTask) {
      macroTimerFunc()
    } else {
      microTimerFunc()
    }
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}
複製程式碼

執行完以上程式碼最後只剩下兩個結果,呼叫macroTimerFunc或者microTimerFunc,本例中到目前為止,會呼叫macroTimerFunc。這兩個函式的目的其實都是要以非同步的形式去遍歷callbacks中的函式,只不過就像我們上文所說的,他們採取的方式並不一樣,一個是巨集任務達到非同步,一個是微任務達到非同步。另外我要適時的提醒你引起以上所有流程的原因只是執行了一行程式碼this.show = falsethis.$nextTick(() => { this.show = true })還沒開始執行,不過別絕望,也快輪到它了。好的,回到正題來看看macroTimerFuncmicroTimerFunc吧。

/**
 * macroTimerFunc
 */
// 如果當前環境支援setImmediate,就用此來產生巨集任務達到非同步效果
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  macroTimerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else if (typeof MessageChannel !== 'undefined' && (
  // 否則MessageChannel
  isNative(MessageChannel) ||
  // PhantomJS
  MessageChannel.toString() === '[object MessageChannelConstructor]'
)) {
  const channel = new MessageChannel()
  const port = channel.port2
  channel.port1.onmessage = flushCallbacks
  macroTimerFunc = () => {
    port.postMessage(1)
  }
} else {
  // 再不行的話就只能setTimeout了
  /* istanbul ignore next */
  macroTimerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}
複製程式碼
/**
 * microTimerFunc
 */
// 如果支援Promise則用Promise來產生微任務
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  microTimerFunc = () => {
    p.then(flushCallbacks)
    // 對IOS做相容性處理,(IOS中存在一些問題,具體可以看尤大大自己的解釋)
    if (isIOS) setTimeout(noop)
  }
} else {
  // 降級
  microTimerFunc = macroTimerFunc
}
複製程式碼

截止到目前為止應該有一個比較清晰的認識了,其實nextTick最終希望達到的效果就是採用非同步的方式去呼叫flushCallbacks,至於是用巨集任務還是微任務,Vue內部已經幫我們處理掉了,並不用我們去決定。至於flushCallbacks光看名字就知道是迴圈剛才的callbacks並執行。

function flushCallbacks () {
  pending = false
  // 將callbacks做一次複製
  const copies = callbacks.slice(0)
 // 置空callbacks
  callbacks.length = 0
  // 遍歷並執行
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}
複製程式碼

請注意,雖然我們在這裡解釋了flushCallbacks是幹嘛的,但是要記住它是被非同步處理的,而當前同步任務還並沒有執行完,所以這個函式此時並沒有被呼叫,真正要做的是走完整個同步任務,也就是我們的this.$nextTick(() => { this.show = true })終於要被呼叫了,感謝老天爺。 當this.$nextTick被呼叫後() => { this.show = true }同樣被當做引數推入了callbacks中,此時可以理解為callbacks長這樣[flushSchedulerQueue, () => { this.show = true }],然後在withMacroTask中fn.apply呼叫完畢useMacroTask被變回false,整個同步任務結束。

此時還記得我們在eventLoop中所講的嗎,我們會從任務佇列中尋找所有的微任務,而到目前為止任務佇列中並沒有微任務,於是一輪事件迴圈完成了,瀏覽器重新渲染,不過此時我們的dom結構沒有發生絲毫變化,所以就算瀏覽器沒重新渲染也並不會有絲毫影響。接下來就是執行任務佇列中的巨集任務了,它對應的回撥就是我們剛才註冊的flushCallbacks。首先執行flushSchedulerQueue,其中的watcher被呼叫了run方法,由於此時我們的data中的show被改變成了false,所以新老虛擬dom對比後真實dom中移除掉了繫結v-if="show"的元件。

重點來了,雖然dom中移除掉了該元件,但是其實在瀏覽器上這個元件是依然顯示的,因為我們的事件迴圈還沒有完成,其中還有剩餘的同步任務需要被執行,瀏覽器並沒開始重新繪製。(如果您對此段有疑問,我個人覺得您可能是沒搞懂dom與瀏覽器上顯示的區別,您可以將dom理解成控制檯中elements模組內所有的節點,瀏覽器的中顯示的內容不是與其時刻保持一致的)

剩下需要被執行的就是() => { this.show = true },而當執行this.show = true時我們前文所有的流程又通通執行了一遍,其中只有一些細節是與剛才不同的,我們來看一下。

  1. 此函式並沒有被withMacroTask包裝,它是callbacks被flush時被呼叫的,所以useMacrotask並沒有被改變依然是其預設值false

  2. 由於第一點原因我們再這次執行巨集任務macrotask時產生了微任務microtask來處理本次的flushCallbacks(也就是呼叫了microTimerFunc

所以當本次macrotask結束時,本次的事件迴圈還沒有結束,我們還留下了微任務需要處理,依然是呼叫flushSchedulerQueue,然後watcher.run,因為此次show已經為true了,所以對比新老虛擬dom,重新生成該元件,生命週期完成重置。此時,本輪事件迴圈結束,瀏覽器重新渲染。希望您還記得,我們的瀏覽器本身現在的狀態就是該元件顯示在可視區內,重新渲染後該元件依然顯示,所以自然不會出現元件閃動的情況。

現在我相信您自己也能想清楚為什麼我們的例子中使用setTimeout會有閃動,但是我還是說一下原因來看一下您與我的想法是否一致。因為setTimeout產生的是巨集任務,當一輪事件迴圈完成後,巨集任務並不會直接處理,中間插入了瀏覽器的繪製。瀏覽器重新繪製後會將顯示的元件移除掉,所以區域內出現一片空白,緊接著下一次事件迴圈開始,巨集任務被執行元件dom又被重新建立,事件迴圈結束,瀏覽器重繪,又在可視區域上將該元件顯示。所以在您的視覺效果上,該元件會有閃動,整個過程結束。

終於我們想說的都說完了,如果您能堅持看到這裡,十分感謝您。不過還有幾點是我們依然要考慮的。

  1. Vue幹嘛要使用非同步佇列更新,這明明很TM麻煩又很繞

其實文件已經告訴我們了

這種在緩衝時去除重複資料對於避免不必要的計算和 DOM 操作上非常重要。

我們假設flushSchedulerQueue並沒有通過nextTick而是直接被呼叫,那麼第一種寫法this.show = false; this.show = true都會觸發watcher.run方法,導致的結果就是這種寫法也可以重置元件的生命週期,您可以在Vue原始碼中註釋掉nextTick(flushSchedulerQueue)改用flushSchedulerQueue()打斷點來更加明確的體驗一下流程。要知道這僅僅是一個簡單的例子,實際工作中我們可能因為這種問題使dom白白被改變了巨多次,我們都知道dom的操作是昂貴的,所以Vue幫我們再框架內優化了該步驟。您不妨再想一下直接flushSchedulerQueue()這種情況下,元件會不會閃動,來鞏固我們剛才講過的東西。

  1. 既然nextTick的使用的微任務是由Promise.then().resolve()生成的,我們可不可以直接在回撥函式中寫this.show = false; Promise.then().resolve(() => { this.show = true })來代替this.$nextTick?很明顯我既然這麼問了那就是不行的,只是過程您需要自己思考。

最後,感謝閱讀~~~

相關文章