Vue原始碼閱讀 - 批量非同步更新與nextTick原理

SHERlocked93發表於2018-07-19

vue已是目前國內前端web端三分天下之一,同時也作為本人主要技術棧之一,在日常使用中知其然也好奇著所以然,另外最近的社群湧現了一大票vue原始碼閱讀類的文章,在下借這個機會從大家的文章和討論中汲取了一些營養,同時對一些閱讀原始碼時的想法進行總結,出產一些文章,作為自己思考的輸出,本人水平有限,歡迎留言討論~

目標Vue版本:2.5.17-beta.0

vue原始碼註釋:github.com/SHERlocked9…

宣告:文章中原始碼的語法都使用 Flow,並且原始碼根據需要都有刪節(為了不被迷糊 @_@),如果要看完整版的請進入上面的github地址,本文是系列文章,文章地址見底部~

1. 非同步更新

上一篇文章我們在依賴收集原理的響應式化方法 defineReactive 中的 setter 訪問器中有派發更新 dep.notify() 方法,這個方法會挨個通知在 depsubs 中收集的訂閱自己變動的watchers執行update。一起來看看 update 方法的實現:

// src/core/observer/watcher.js

/* Subscriber介面,當依賴發生改變的時候進行回撥 */
update() {
  if (this.computed) {
    // 一個computed watcher有兩種模式:activated lazy(預設)
    // 只有當它被至少一個訂閱者依賴時才置activated,這通常是另一個計算屬性或元件的render function
    if (this.dep.subs.length === 0) {       // 如果沒人訂閱這個計算屬性的變化
      // lazy時,我們希望它只在必要時執行計算,所以我們只是簡單地將觀察者標記為dirty
      // 當計算屬性被訪問時,實際的計算在this.evaluate()中執行
      this.dirty = true
    } else {
      // activated模式下,我們希望主動執行計算,但只有當值確實發生變化時才通知我們的訂閱者
      this.getAndInvoke(() => {
        this.dep.notify()     // 通知渲染watcher重新渲染,通知依賴自己的所有watcher執行update
      })
    }
  } else if (this.sync) {	  // 同步
    this.run()
  } else {
    queueWatcher(this)        // 非同步推送到排程者觀察者佇列中,下一個tick時呼叫
  }
}
複製程式碼

如果不是 computed watcher 也非 sync 會把呼叫update的當前watcher推送到排程者佇列中,下一個tick時呼叫,看看 queueWatcher

// src/core/observer/scheduler.js

/* 將一個觀察者物件push進觀察者佇列,在佇列中已經存在相同的id則
 * 該watcher將被跳過,除非它是在佇列正被flush時推送
 */
export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  if (has[id] == null) {     // 檢驗id是否存在,已經存在則直接跳過,不存在則標記雜湊表has,用於下次檢驗
    has[id] = true
    queue.push(watcher)      // 如果沒有正在flush,直接push到佇列中
    if (!waiting) {          // 標記是否已傳給nextTick
      waiting = true
      nextTick(flushSchedulerQueue)
    }
  }
}

/* 重置排程者狀態 */
function resetSchedulerState () {
  queue.length = 0
  has = {}
  waiting = false
}
複製程式碼

這裡使用了一個 has 的雜湊map用來檢查是否當前watcher的id是否存在,若已存在則跳過,不存在則就push到 queue 佇列中並標記雜湊表has,用於下次檢驗,防止重複新增。這就是一個去重的過程,比每次查重都要去queue中找要文明,在渲染的時候就不會重複 patch 相同watcher的變化,這樣就算同步修改了一百次檢視中用到的data,非同步 patch 的時候也只會更新最後一次修改。

這裡的 waiting 方法是用來標記 flushSchedulerQueue 是否已經傳遞給 nextTick 的標記位,如果已經傳遞則只push到佇列中不傳遞 flushSchedulerQueuenextTick,等到 resetSchedulerState 重置排程者狀態的時候 waiting 會被置回 false 允許 flushSchedulerQueue 被傳遞給下一個tick的回撥,總之保證了 flushSchedulerQueue 回撥在一個tick內只允許被傳入一次。來看看被傳遞給 nextTick 的回撥 flushSchedulerQueue 做了什麼:

// src/core/observer/scheduler.js

/* nextTick的回撥函式,在下一個tick時flush掉兩個佇列同時執行watchers */
function flushSchedulerQueue () {
  flushing = true
  let watcher, id

  queue.sort((a, b) => a.id - b.id)					// 排序

  for (index = 0; index < queue.length; index++) {	 // 不要將length進行快取
    watcher = queue[index]
    if (watcher.before) {         // 如果watcher有before則執行
      watcher.before()
    }
    id = watcher.id
    has[id] = null                // 將has的標記刪除
    watcher.run()                 // 執行watcher
    if (process.env.NODE_ENV !== 'production' && has[id] != null) {  // 在dev環境下檢查是否進入死迴圈
      circular[id] = (circular[id] || 0) + 1     // 比如user watcher訂閱自己的情況
      if (circular[id] > MAX_UPDATE_COUNT) {     // 持續執行了一百次watch代表可能存在死迴圈
        warn()								  // 進入死迴圈的警告
        break
      }
    }
  }
  resetSchedulerState()           // 重置排程者狀態
  callActivatedHooks()            // 使子元件狀態都置成active同時呼叫activated鉤子
  callUpdatedHooks()              // 呼叫updated鉤子
}
複製程式碼

nextTick 方法中執行 flushSchedulerQueue 方法,這個方法挨個執行 queue 中的watcher的 run 方法。我們看到在首先有個 queue.sort() 方法把佇列中的watcher按id從小到大排了個序,這樣做可以保證:

  1. 元件更新的順序是從父元件到子元件的順序,因為父元件總是比子元件先建立。
  2. 一個元件的user watchers(偵聽器watcher)比render watcher先執行,因為user watchers往往比render watcher更早建立
  3. 如果一個元件在父元件watcher執行期間被銷燬,它的watcher執行將被跳過

在挨個執行佇列中的for迴圈中,index < queue.length 這裡沒有將length進行快取,因為在執行處理現有watcher物件期間,更多的watcher物件可能會被push進queue。

那麼資料的修改從model層反映到view的過程:資料更改 -> setter -> Dep -> Watcher -> nextTick -> patch -> 更新檢視

2. nextTick原理

2.1 巨集任務/微任務

這裡就來看看包含著每個watcher執行的方法被作為回撥傳入 nextTick 之後,nextTick 對這個方法做了什麼。不過首先要了解一下瀏覽器中的 EventLoopmacro taskmicro task幾個概念,不瞭解可以參考一下 JS與Node.js中的事件迴圈 這篇文章,這裡就用一張圖來表明一下後兩者在主執行緒中的執行關係:

Vue原始碼閱讀 - 批量非同步更新與nextTick原理

解釋一下,當主執行緒執行完同步任務後:

  1. 引擎首先從macrotask queue中取出第一個任務,執行完畢後,將microtask queue中的所有任務取出,按順序全部執行;
  2. 然後再從macrotask queue中取下一個,執行完畢後,再次將microtask queue中的全部取出;
  3. 迴圈往復,直到兩個queue中的任務都取完。

瀏覽器環境中常見的非同步任務種類,按照優先順序:

  • macro task :同步程式碼、setImmediateMessageChannelsetTimeout/setInterval
  • micro taskPromise.thenMutationObserver

有的文章把 micro task 叫微任務,macro task 叫巨集任務,因為這兩個單詞拼寫太像了 -。- ,所以後面的註釋多用中文表示~

先來看看原始碼中對 micro taskmacro task 的實現: macroTimerFuncmicroTimerFunc

// src/core/util/next-tick.js

const callbacks = []     // 存放非同步執行的回撥
let pending = false      // 一個標記位,如果已經有timerFunc被推送到任務佇列中去則不需要重複推送

/* 挨個同步執行callbacks中回撥 */
function flushCallbacks() {
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

let microTimerFunc        // 微任務執行方法
let macroTimerFunc        // 巨集任務執行方法
let useMacroTask = false  // 是否強制為巨集任務,預設使用微任務

// 巨集任務
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  macroTimerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else if (typeof MessageChannel !== 'undefined' && (
  isNative(MessageChannel) ||
  MessageChannel.toString() === '[object MessageChannelConstructor]'  // PhantomJS
)) {
  const channel = new MessageChannel()
  const port = channel.port2
  channel.port1.onmessage = flushCallbacks
  macroTimerFunc = () => {
    port.postMessage(1)
  }
} else {
  macroTimerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

// 微任務
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  microTimerFunc = () => {
    p.then(flushCallbacks)
  }
} else {
  microTimerFunc = macroTimerFunc      // fallback to macro
}
複製程式碼

flushCallbacks 這個方法就是挨個同步的去執行callbacks中的回撥函式們,callbacks中的回撥函式是在呼叫 nextTick 的時候新增進去的;那麼怎麼去使用 micro taskmacro task 去執行 flushCallbacks 呢,這裡他們的實現 macroTimerFuncmicroTimerFunc 使用瀏覽器中巨集任務/微任務的API對flushCallbacks 方法進行了一層包裝。比如巨集任務方法 macroTimerFunc=()=>{ setImmediate(flushCallbacks) },這樣在觸發巨集任務執行的時候 macroTimerFunc() 就可以在瀏覽器中的下一個巨集任務loop的時候消費這些儲存在callbacks陣列中的回撥了,微任務同理。同時也可以看出傳給 nextTick 的非同步回撥函式是被壓成了一個同步任務在一個tick執行完的,而不是開啟多個非同步任務。

注意這裡有個比較難理解的地方,第一次呼叫 nextTick 的時候 pending 為false,此時已經push到瀏覽器event loop中一個巨集任務或微任務的task,如果在沒有flush掉的情況下繼續往callbacks裡面新增,那麼在執行這個佔位queue的時候會執行之後新增的回撥,所以 macroTimerFuncmicroTimerFunc 相當於task queue的佔位,以後 pending 為true則繼續往佔位queue裡面新增,event loop輪到這個task queue的時候將一併執行。執行 flushCallbackspending 置false,允許下一輪執行 nextTick 時往event loop佔位。

可以看到上面 macroTimerFuncmicroTimerFunc 進行了在不同瀏覽器相容性下的平穩退化,或者說降級策略

  1. macroTimerFuncsetImmediate -> MessageChannel -> setTimeout。首先檢測是否原生支援 setImmediate,這個方法只在 IE、Edge 瀏覽器中原生實現,然後檢測是否支援 MessageChannel,如果對 MessageChannel 不瞭解可以參考一下這篇文章,還不支援的話最後使用 setTimeout; 為什麼優先使用 setImmediateMessageChannel 而不直接使用 setTimeout 呢,是因為HTML5規定setTimeout執行的最小延時為4ms,而巢狀的timeout表現為10ms,為了儘可能快的讓回撥執行,沒有最小延時限制的前兩者顯然要優於 setTimeout
  2. microTimerFuncPromise.then -> macroTimerFunc 。首先檢查是否支援 Promise,如果支援的話通過 Promise.then 來呼叫 flushCallbacks 方法,否則退化為 macroTimerFunc ; vue2.5之後 nextTick 中因為相容性原因刪除了微任務平穩退化的 MutationObserver 的方式。

2.2 nextTick實現

最後來看看我們平常用到的 nextTick 方法到底是如何實現的:

// src/core/util/next-tick.js

export function nextTick(cb?: Function, ctx?: Object) {
  let _resolve
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e) {
        handleError(e, ctx, 'nextTick')
      }
    } else if (_resolve) {
      _resolve(ctx)
    }
  })
  if (!pending) {
    pending = true
    if (useMacroTask) {
      macroTimerFunc()
    } else {
      microTimerFunc()
    }
  }
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

/* 強制使用macrotask的方法 */
export function withMacroTask(fn: Function): Function {
  return fn._withTask || (fn._withTask = function() {
    useMacroTask = true
    const res = fn.apply(null, arguments)
    useMacroTask = false
    return res
  })
}
複製程式碼

nextTick 在這裡分為三個部分,我們一起來看一下;

  1. 首先 nextTick 把傳入的 cb 回撥函式用 try-catch 包裹後放在一個匿名函式中推入callbacks陣列中,這麼做是因為防止單個 cb 如果執行錯誤不至於讓整個JS執行緒掛掉,每個 cb 都包裹是防止這些回撥函式如果執行錯誤不會相互影響,比如前一個拋錯了後一個仍然可以執行。
  2. 然後檢查 pending 狀態,這個跟之前介紹的 queueWatcher 中的 waiting 是一個意思,它是一個標記位,一開始是 false 在進入 macroTimerFuncmicroTimerFunc 方法前被置為 true,因此下次呼叫 nextTick 就不會進入 macroTimerFuncmicroTimerFunc 方法,這兩個方法中會在下一個 macro/micro tick 時候 flushCallbacks 非同步的去執行callbacks佇列中收集的任務,而 flushCallbacks 方法在執行一開始會把 pendingfalse,因此下一次呼叫 nextTick 時候又能開啟新一輪的 macroTimerFuncmicroTimerFunc,這樣就形成了vue中的 event loop
  3. 最後檢查是否傳入了 cb,因為 nextTick 還支援Promise化的呼叫:nextTick().then(() => {}),所以如果沒有傳入 cb 就直接return了一個Promise例項,並且把resolve傳遞給_resolve,這樣後者執行的時候就跳到我們呼叫的時候傳遞進 then 的方法中。

Vue原始碼中 next-tick.js 檔案還有一段重要的註釋,這裡就翻譯一下:

在vue2.5之前的版本中,nextTick基本上基於 micro task 來實現的,但是在某些情況下 micro task 具有太高的優先順序,並且可能在連續順序事件之間(例如#4521#6690)或者甚至在同一事件的事件冒泡過程中之間觸發(#6566)。但是如果全部都改成 macro task,對一些有重繪和動畫的場景也會有效能影響,如 issue #6813。vue2.5之後版本提供的解決辦法是預設使用 micro task,但在需要時(例如在v-on附加的事件處理程式中)強制使用 macro task

為什麼預設優先使用 micro task 呢,是利用其高優先順序的特性,保證佇列中的微任務在一次迴圈全部執行完畢。

強制 macro task 的方法是在繫結 DOM 事件的時候,預設會給回撥的 handler 函式呼叫 withMacroTask 方法做一層包裝 handler = withMacroTask(handler),它保證整個回撥函式執行過程中,遇到資料狀態的改變,這些改變都會被推到 macro task 中。以上實現在 src/platforms/web/runtime/modules/events.jsadd 方法中,可以自己看一看具體程式碼。

剛好在寫這篇文章的時候思否上有人問了個問題 vue 2.4 和2.5 版本的@input事件不一樣 ,這個問題的原因也是因為2.5之前版本的DOM事件採用 micro task ,而之後採用 macro task,解決的途徑參考 < Vue.js 升級踩坑小記> 中介紹的幾個辦法,這裡就提供一個在mounted鉤子中用 addEventListener 新增原生事件的方法來實現,參見 CodePen

3. 一個例子

說這麼多,不如來個例子,執行參見 CodePen

<div id="app">
  <span id='name' ref='name'>{{ name }}</span>
  <button @click='change'>change name</button>
  <div id='content'></div>
</div>
<script>
  new Vue({
    el: '#app',
    data() {
      return {
        name: 'SHERlocked93'
      }
    },
    methods: {
      change() {
        const $name = this.$refs.name
        this.$nextTick(() => console.log('setter前:' + $name.innerHTML))
        this.name = ' name改嘍 '
        console.log('同步方式:' + this.$refs.name.innerHTML)
        setTimeout(() => this.console("setTimeout方式:" + this.$refs.name.innerHTML))
        this.$nextTick(() => console.log('setter後:' + $name.innerHTML))
        this.$nextTick().then(() => console.log('Promise方式:' + $name.innerHTML))
      }
    }
  })
</script>
複製程式碼

執行以下看看結果:

同步方式:SHERlocked93 
setter前:SHERlocked93 
setter後:name改嘍 
Promise方式:name改嘍 
setTimeout方式:name改嘍
複製程式碼

為什麼是這樣的結果呢,解釋一下:

  1. 同步方式: 當把data中的name修改之後,此時會觸發name的 setter 中的 dep.notify 通知依賴本data的render watcher去 updateupdate 會把 flushSchedulerQueue 函式傳遞給 nextTick,render watcher在 flushSchedulerQueue 函式執行時 watcher.run 再走 diff -> patch 那一套重渲染 re-render 檢視,這個過程中會重新依賴收集,這個過程是非同步的;所以當我們直接修改了name之後列印,這時非同步的改動還沒有被 patch 到檢視上,所以獲取檢視上的DOM元素還是原來的內容。
  2. setter前: setter前為什麼還列印原來的是原來內容呢,是因為 nextTick 在被呼叫的時候把回撥挨個push進callbacks陣列,之後執行的時候也是 for 迴圈出來挨個執行,所以是類似於佇列這樣一個概念,先入先出;在修改name之後,觸發把render watcher填入 schedulerQueue 佇列並把他的執行函式 flushSchedulerQueue 傳遞給 nextTick ,此時callbacks佇列中已經有了 setter前函式 了,因為這個 cb 是在 setter前函式 之後被push進callbacks佇列的,那麼先入先出的執行callbacks中回撥的時候先執行 setter前函式,這時並未執行render watcher的 watcher.run,所以列印DOM元素仍然是原來的內容。
  3. setter後: setter後這時已經執行完 flushSchedulerQueue,這時render watcher已經把改動 patch 到檢視上,所以此時獲取DOM是改過之後的內容。
  4. Promise方式: 相當於 Promise.then 的方式執行這個函式,此時DOM已經更改。
  5. setTimeout方式: 最後執行macro task的任務,此時DOM已經更改。

注意,在執行 setter前函式 這個非同步任務之前,同步的程式碼已經執行完畢,非同步的任務都還未執行,所有的 $nextTick 函式也執行完畢,所有回撥都被push進了callbacks佇列中等待執行,所以在setter前函式 執行的時候,此時callbacks佇列是這樣的:[setter前函式flushSchedulerQueuesetter後函式Promise方式函式],它是一個micro task佇列,執行完畢之後執行macro task setTimeout,所以列印出上面的結果。

另外,如果瀏覽器的巨集任務佇列裡面有setImmediateMessageChannelsetTimeout/setInterval 各種型別的任務,那麼會按照上面的順序挨個按照新增進event loop中的順序執行,所以如果瀏覽器支援MessageChannelnextTick 執行的是 macroTimerFunc,那麼如果 macrotask queue 中同時有 nextTick 新增的任務和使用者自己新增的 setTimeout 型別的任務,會優先執行 nextTick 中的任務,因為MessageChannel 的優先順序比 setTimeout的高,setImmediate 同理。


本文是系列文章,隨後會更新後面的部分,共同進步~

  1. Vue原始碼閱讀 - 檔案結構與執行機制
  2. Vue原始碼閱讀 - 依賴收集原理
  3. Vue原始碼閱讀 - 批量非同步更新與nextTick原理

網上的帖子大多深淺不一,甚至有些前後矛盾,在下的文章都是學習過程中的總結,如果發現錯誤,歡迎留言指出~

參考:

  1. Vue2.1.7原始碼學習
  2. Vue.js 技術揭祕
  3. 剖析 Vue.js 內部執行機制
  4. Vue.js 文件
  5. 記錄:window.MessageChannel那些事
  6. MDN - MessageChannel
  7. JS與Node.js中的事件迴圈
  8. 黃軼 - Vue.js 升級踩坑小記
  9. Vue nextTick 機制

PS:歡迎大家關注我的公眾號【前端下午茶】,一起加油吧~

Vue原始碼閱讀 - 批量非同步更新與nextTick原理

相關文章