App下載

Vue 進階面試必問,異步更新機制和 nextTick 原理

猿友 2020-09-17 11:45:43 瀏覽數(shù) (2547)
反饋

以下文章來源于前端下午茶 ,作者SHERlocked93

vue 已是目前國內(nèi)前端web端三分天下之一,同時也作為本人主要技術(shù)棧之一,在日常使用中知其然也好奇著所以然,另外最近的社區(qū)涌現(xiàn)了一大票 vue 源碼閱讀類的文章,在下借這個機會從大家的文章和討論中汲取了一些營養(yǎng),同時對一些閱讀源碼時的想法進行總結(jié),出產(chǎn)一些文章,作為自己思考的輸出

目標(biāo)Vue版本:2.5.17-beta.0

vue 源碼注釋:github.com/SHERlocked93/vue-analysis

聲明:文章中源碼的語法都使用 Flow,并且源碼根據(jù)需要都有刪節(jié)(為了不被迷糊 @_@),如果要看完整版的請進入上面的github地址

1. 異步更新

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

// src/core/observer/watcher.js


/* Subscriber接口,當(dāng)依賴發(fā)生改變的時候進行回調(diào) */
update() {
  if (this.computed) {
    // 一個computed watcher有兩種模式:activated lazy(默認)
    // 只有當(dāng)它被至少一個訂閱者依賴時才置activated,這通常是另一個計算屬性或組件的render function
    if (this.dep.subs.length === 0) { // 如果沒人訂閱這個計算屬性的變化
      // lazy時,我們希望它只在必要時執(zhí)行計算,所以我們只是簡單地將觀察者標(biāo)記為dirty
      // 當(dāng)計算屬性被訪問時,實際的計算在this.evaluate()中執(zhí)行
      this.dirty = true
    } else {
      // activated模式下,我們希望主動執(zhí)行計算,但只有當(dāng)值確實發(fā)生變化時才通知我們的訂閱者
      this.getAndInvoke(() => {
        this.dep.notify() // 通知渲染watcher重新渲染,通知依賴自己的所有watcher執(zhí)行update
      })
    }
  } else if (this.sync) {   // 同步
    this.run()
  } else {
    queueWatcher(this) // 異步推送到調(diào)度者觀察者隊列中,下一個tick時調(diào)用
  }
}

如果不是 computed watcher 也非 sync 會把調(diào)用 update 的當(dāng)前 watcher 推送到調(diào)度者隊列中,下一個 tick 時調(diào)用,看看 queueWatcher

// src/core/observer/scheduler.js


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


/* 重置調(diào)度者狀態(tài) */
function resetSchedulerState () {
  queue.length = 0
  has = {}
  waiting = false
}

這里使用了一個 has 的哈希map用來檢查是否當(dāng)前 watcher 的 id 是否存在,若已存在則跳過,不存在則就 push 到 queue 隊列中并標(biāo)記哈希表 has,用于下次檢驗,防止重復(fù)添加。這就是一個去重的過程,比每次查重都要去 queue 中找要文明,在渲染的時候就不會重復(fù)patch 相同 watcher 的變化,這樣就算同步修改了一百次視圖中用到的 data,異步 patch的時候也只會更新最后一次修改。

這里的 waiting 方法是用來標(biāo)記 flushSchedulerQueue 是否已經(jīng)傳遞給 nextTick 的標(biāo)記位,如果已經(jīng)傳遞則只 push 到隊列中不傳遞 flushSchedulerQueuenextTick,等到 resetSchedulerState 重置調(diào)度者狀態(tài)的時候 waiting 會被置回 false 允許 flushSchedulerQueue 被傳遞給下一個 tick 的回調(diào),總之保證了 flushSchedulerQueue 回調(diào)在一個 tick 內(nèi)只允許被傳入一次。來看看被傳遞給 nextTick 的回調(diào) flushSchedulerQueue 做了什么:

// src/core/observer/scheduler.js


/* nextTick的回調(diào)函數(shù),在下一個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則執(zhí)行
      watcher.before()
    }
    id = watcher.id
    has[id] = null // 將has的標(biāo)記刪除
    watcher.run() // 執(zhí)行watcher
    if (process.env.NODE_ENV !== 'production' && has[id] != null) { // 在dev環(huán)境下檢查是否進入死循環(huán)
      circular[id] = (circular[id] || 0) + 1 // 比如user watcher訂閱自己的情況
      if (circular[id] > MAX_UPDATE_COUNT) { // 持續(xù)執(zhí)行了一百次watch代表可能存在死循環(huán)
        warn()  // 進入死循環(huán)的警告
        break
      }
    }
  }
  resetSchedulerState() // 重置調(diào)度者狀態(tài)
  callActivatedHooks() // 使子組件狀態(tài)都置成active同時調(diào)用activated鉤子
  callUpdatedHooks() // 調(diào)用updated鉤子
}

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

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

在挨個執(zhí)行隊列中的 for 循環(huán)中,index < queue.length 這里沒有將 length 進行緩存,因為在執(zhí)行處理現(xiàn)有 watcher 對象期間,更多的 watcher 對象可能會被 push 進 queue。

那么數(shù)據(jù)的修改從 model 層反映到 view 的過程:數(shù)據(jù)更改 -> setter -> Dep -> Watcher -> nextTick -> patch -> 更新視圖

2. nextTick原理

2.1 宏任務(wù)/微任務(wù)

這里就來看看包含著每個 watcher 執(zhí)行的方法被作為回調(diào)傳入 nextTick 之后,nextTick對這個方法做了什么。不過首先要了解一下瀏覽器中的 EventLoop、macro taskmicro task幾個概念,不了解可以參考一下 JS 與 Node.js 中的事件循環(huán) 這篇文章,這里就用一張圖來表明一下后兩者在主線程中的執(zhí)行關(guān)系:

宏任務(wù)微任務(wù)

解釋一下,當(dāng)主線程執(zhí)行完同步任務(wù)后:

  1. 引擎首先從 macrotask queue 中取出第一個任務(wù),執(zhí)行完畢后,將 microtask queue 中的所有任務(wù)取出,按順序全部執(zhí)行;
  2. 然后再從 macrotask queue 中取下一個,執(zhí)行完畢后,再次將 microtask queue 中的全部取出;
  3. 循環(huán)往復(fù),直到兩個 queue 中的任務(wù)都取完。

瀏覽器環(huán)境中常見的異步任務(wù)種類,按照優(yōu)先級:

  • macro task :同步代碼、setImmediate、MessageChannel、setTimeout/setInterval
  • micro taskPromise.then、MutationObserver

有的文章把 micro task 叫微任務(wù),macro task 叫宏任務(wù),因為這兩個單詞拼寫太像了 -。- ,所以后面的注釋多用中文表示~

先來看看源碼中對 micro taskmacro task 的實現(xiàn):macroTimerFunc、microTimerFunc

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


const callbacks = [] // 存放異步執(zhí)行的回調(diào)
let pending = false // 一個標(biāo)記位,如果已經(jīng)有timerFunc被推送到任務(wù)隊列中去則不需要重復(fù)推送


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


let microTimerFunc // 微任務(wù)執(zhí)行方法
let macroTimerFunc // 宏任務(wù)執(zhí)行方法
let useMacroTask = false // 是否強制為宏任務(wù),默認使用微任務(wù)


// 宏任務(wù)
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)
  }
}


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

flushCallbacks 這個方法就是挨個同步的去執(zhí)行 callbacks 中的回調(diào)函數(shù)們, callbacks 中的回調(diào)函數(shù)是在調(diào)用 nextTick 的時候添加進去的;那么怎么去使用 micro taskmacro task 去執(zhí)行 flushCallbacks 呢,這里他們的實現(xiàn) macroTimerFunc、microTimerFunc 使用瀏覽器中宏任務(wù)/微任務(wù)的 API 對flushCallbacks 方法進行了一層包裝。比如宏任務(wù)方法 macroTimerFunc=()=>{ setImmediate(flushCallbacks) },這樣在觸發(fā)宏任務(wù)執(zhí)行的時候 macroTimerFunc() 就可以在瀏覽器中的下一個宏任務(wù) loop 的時候消費這些保存在 callbacks 數(shù)組中的回調(diào)了,微任務(wù)同理。同時也可以看出傳給 nextTick 的異步回調(diào)函數(shù)是被壓成了一個同步任務(wù)在一個 tick 執(zhí)行完的,而不是開啟多個異步任務(wù)。

注意這里有個比較難理解的地方,第一次調(diào)用 nextTick 的時候 pending 為 false ,此時已經(jīng) push 到瀏覽器 event loop 中一個宏任務(wù)或微任務(wù)的 task,如果在沒有 flush 掉的情況下繼續(xù)往 callbacks 里面添加,那么在執(zhí)行這個占位 queue 的時候會執(zhí)行之后添加的回調(diào),所以 macroTimerFuncmicroTimerFunc 相當(dāng)于 task queue 的占位,以后 pending 為 true 則繼續(xù)往占位 queue 里面添加,event loop 輪到這個 task queue 的時候?qū)⒁徊?zhí)行。執(zhí)行 flushCallbackspending 置 false,允許下一輪執(zhí)行 nextTick 時往 event loop 占位。

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

  1. macroTimerFuncsetImmediate -> MessageChannel -> setTimeout。首先檢測是否原生支持 setImmediate,這個方法只在 IE、Edge 瀏覽器中原生實現(xiàn),然后檢測是否支持 MessageChannel,如果對 MessageChannel 不了解可以參考一下這篇文章,還不支持的話最后使用 setTimeout;為什么優(yōu)先使用 setImmediateMessageChannel 而不直接使用 setTimeout呢,是因為 HTML5 規(guī)定 setTimeout 執(zhí)行的最小延時為4ms,而嵌套的 timeout 表現(xiàn)為10ms,為了盡可能快的讓回調(diào)執(zhí)行,沒有最小延時限制的前兩者顯然要優(yōu)于 setTimeout。
  2. microTimerFuncPromise.then -> macroTimerFunc 。首先檢查是否支持Promise,如果支持的話通過 Promise.then 來調(diào)用 flushCallbacks 方法,否則退化為 macroTimerFunc ;vue2.5之后 nextTick 中因為兼容性原因刪除了微任務(wù)平穩(wěn)退化的 MutationObserver 的方式。

2.2 nextTick實現(xiàn)

最后來看看我們平常用到的 nextTick 方法到底是如何實現(xiàn)的:

// 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 回調(diào)函數(shù)用 try-catch 包裹后放在一個匿名函數(shù)中推入callbacks數(shù)組中,這么做是因為防止單個 cb 如果執(zhí)行錯誤不至于讓整個JS線程掛掉,每個 cb 都包裹是防止這些回調(diào)函數(shù)如果執(zhí)行錯誤不會相互影響,比如前一個拋錯了后一個仍然可以執(zhí)行。
  2. 然后檢查 pending 狀態(tài),這個跟之前介紹的 queueWatcher 中的 waiting 是一個意思,它是一個標(biāo)記位,一開始是 false 在進入 macroTimerFunc、microTimerFunc方法前被置為 true,因此下次調(diào)用 nextTick 就不會進入 macroTimerFuncmicroTimerFunc 方法,這兩個方法中會在下一個 macro/micro tick 時候flushCallbacks 異步的去執(zhí)行callbacks隊列中收集的任務(wù),而 flushCallbacks 方法在執(zhí)行一開始會把 pendingfalse,因此下一次調(diào)用 nextTick 時候又能開啟新一輪的 macroTimerFunc、microTimerFunc,這樣就形成了vue中的 event loop。
  3. 最后檢查是否傳入了 cb,因為 nextTick 還支持Promise化的調(diào)用:nextTick().then(() => {}),所以如果沒有傳入 cb 就直接return了一個Promise實例,并且把resolve傳遞給_resolve,這樣后者執(zhí)行的時候就跳到我們調(diào)用的時候傳遞進 then 的方法中。

Vue源碼中 next-tick.js 文件還有一段重要的注釋,這里就翻譯一下:

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

為什么默認優(yōu)先使用 micro task 呢,是利用其高優(yōu)先級的特性,保證隊列中的微任務(wù)在一次循環(huán)全部執(zhí)行完畢。

強制 macro task 的方法是在綁定 DOM 事件的時候,默認會給回調(diào)的 handler 函數(shù)調(diào)用withMacroTask 方法做一層包裝 handler = withMacroTask(handler),它保證整個回調(diào)函數(shù)執(zhí)行過程中,遇到數(shù)據(jù)狀態(tài)的改變,這些改變都會被推到 macro task 中。以上實現(xiàn)在 src/platforms/web/runtime/modules/events.js 的 add 方法中,可以自己看一看具體代碼。

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

3. 一個例子

說這么多,不如來個例子,執(zhí)行參見 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>

執(zhí)行以下看看結(jié)果:

同步方式:SHERlocked93
setter前:SHERlocked93
setter后:name改嘍
Promise方式:name改嘍
setTimeout方式:name改嘍

為什么是這樣的結(jié)果呢,解釋一下:

  1. 同步方式: 當(dāng)把data中的name修改之后,此時會觸發(fā)name的 setter 中的 dep.notify 通知依賴本data的render watcher去 update,update 會把flushSchedulerQueue 函數(shù)傳遞給 nextTick,render watcher在 flushSchedulerQueue 函數(shù)運行時 watcher.run 再走 diff -> patch 那一套重渲染 re-render 視圖,這個過程中會重新依賴收集,這個過程是異步的;所以當(dāng)我們直接修改了name之后打印,這時異步的改動還沒有被 patch 到視圖上,所以獲取視圖上的DOM元素還是原來的內(nèi)容。
  2. setter前: setter前為什么還打印原來的是原來內(nèi)容呢,是因為 nextTick 在被調(diào)用的時候把回調(diào)挨個push進callbacks數(shù)組,之后執(zhí)行的時候也是 for 循環(huán)出來挨個執(zhí)行,所以是類似于隊列這樣一個概念,先入先出;在修改name之后,觸發(fā)把render watcher填入 schedulerQueue 隊列并把他的執(zhí)行函數(shù) flushSchedulerQueue 傳遞給nextTick ,此時callbacks隊列中已經(jīng)有了 setter前函數(shù) 了,因為這個 cb 是在 setter前函數(shù) 之后被push進callbacks隊列的,那么先入先出的執(zhí)行callbacks中回調(diào)的時候先執(zhí)行 setter前函數(shù),這時并未執(zhí)行render watcher的 watcher.run,所以打印DOM元素仍然是原來的內(nèi)容。
  3. setter后: setter后這時已經(jīng)執(zhí)行完 flushSchedulerQueue,這時render watcher已經(jīng)把改動 patch 到視圖上,所以此時獲取DOM是改過之后的內(nèi)容。
  4. Promise方式: 相當(dāng)于 Promise.then 的方式執(zhí)行這個函數(shù),此時DOM已經(jīng)更改。
  5. setTimeout方式: 最后執(zhí)行macro task的任務(wù),此時DOM已經(jīng)更改。

注意,在執(zhí)行 setter前函數(shù) 這個異步任務(wù)之前,同步的代碼已經(jīng)執(zhí)行完畢,異步的任務(wù)都還未執(zhí)行,所有的 $nextTick 函數(shù)也執(zhí)行完畢,所有回調(diào)都被push進了callbacks隊列中等待執(zhí)行,所以在setter前函數(shù) 執(zhí)行的時候,此時callbacks隊列是這樣的:[setter前函數(shù),flushSchedulerQueue,setter后函數(shù)Promise方式函數(shù)],它是一個micro task隊列,執(zhí)行完畢之后執(zhí)行macro task setTimeout,所以打印出上面的結(jié)果。

另外,如果瀏覽器的宏任務(wù)隊列里面有setImmediateMessageChannel、setTimeout/setInterval 各種類型的任務(wù),那么會按照上面的順序挨個按照添加進event loop中的順序執(zhí)行,所以如果瀏覽器支持MessageChannelnextTick 執(zhí)行的是macroTimerFunc,那么如果 macrotask queue 中同時有 nextTick 添加的任務(wù)和用戶自己添加的 setTimeout 類型的任務(wù),會優(yōu)先執(zhí)行 nextTick 中的任務(wù),因為MessageChannel 的優(yōu)先級比 setTimeout的高,setImmediate 同理。

以上就是W3Cschool編程獅關(guān)于Vue 進階面試必問,異步更新機制和 nextTick 原理的相關(guān)介紹了,希望對大家有所幫助。

0 人點贊