Vue異步更新隊列原理從入門到放棄

聲明:本文章中全部源碼取自Version: 2.5.13的dev分支上的Vue,不保證文章內觀點的絕對準確性。文章整理自本週我在小組的內部分享。javascript

文章原地址html

咱們目前的技術棧主要採用Vue,而工做中咱們碰到了一種狀況是當傳入某些組件內的props被改變時咱們須要重置整個組件的生命週期(好比更改IView中datepicker的type,好消息是目前該組件已經能夠不用再使用這麼愚蠢的方法來切換時間顯示器的類型)。爲了達成這個目的,因而咱們有了以下代碼vue

<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 }),神奇的事情來了,組件依然重置了其生命週期,可是組件本沒沒有絲毫的閃動。html5

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

若是您體驗完快感後仍然選擇繼續閱讀那麼我要跟你說的是接下來的內容是會比較長的,由於要想徹底弄明白這件事咱們必須深刻Vue的內部與Javascript的EventLoop兩個方面。react

致使此問題的主要緣由在於Vue默認採用的是的異步更新隊列的方式,咱們能夠從官網上找到如下描述git

可能你尚未注意到,Vue 異步執行 DOM 更新。只要觀察到數據變化,Vue 將開啓一個隊列,並緩衝在同一事件循環中發生的全部數據改變。若是同一個 watcher 被屢次觸發,只會被推入到隊列中一次。這種在緩衝時去除重複數據對於避免沒必要要的計算和 DOM 操做上很是重要。而後,在下一個的事件循環「tick」中,Vue 刷新隊列並執行實際 (已去重的) 工做。Vue 在內部嘗試對異步隊列使用原生的 Promise.then 和 MessageChannel,若是執行環境不支持,會採用 setTimeout(fn, 0) 代替。github

這段話確實精簡的描述了整個流程,可是並不能解決咱們的疑惑,接下來是時候展現真正的技術了。須要說明的是如下核心流程若是您沒閱讀過一些介紹源碼的blog或者是閱讀過源碼,那麼您可能一臉懵b。可是不要緊在這裏咱們最終關心的基本上只是第4步,您只須要大概將其記住,而後將這個流程對應咱們後面解析的源碼就能夠了。算法

Vue的核心流程大致能夠分紅如下幾步express

  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?很明顯我既然這麼問了那就是不行的,只是過程您須要本身思考。

最後,感謝閱讀~~~

相關文章
相關標籤/搜索