App下載

Vue 進(jìn)階面試必問,異步更新機(jī)制和 nextTick 原理

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

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

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

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

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

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

1. 異步更新

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

// src/core/observer/watcher.js


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

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

// src/core/observer/scheduler.js


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


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

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

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

// src/core/observer/scheduler.js


/* nextTick的回調(diào)函數(shù),在下一個(gè)tick時(shí)flush掉兩個(gè)隊(duì)列同時(shí)運(yùn)行watchers */
function flushSchedulerQueue () {
  flushing = true
  let watcher, id


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


  for (index = 0; index < queue.length; index++) {   // 不要將length進(jìn)行緩存
    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)境下檢查是否進(jìn)入死循環(huán)
      circular[id] = (circular[id] || 0) + 1 // 比如user watcher訂閱自己的情況
      if (circular[id] > MAX_UPDATE_COUNT) { // 持續(xù)執(zhí)行了一百次watch代表可能存在死循環(huán)
        warn()  // 進(jìn)入死循環(huán)的警告
        break
      }
    }
  }
  resetSchedulerState() // 重置調(diào)度者狀態(tài)
  callActivatedHooks() // 使子組件狀態(tài)都置成active同時(shí)調(diào)用activated鉤子
  callUpdatedHooks() // 調(diào)用updated鉤子
}

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

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

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

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

2. nextTick原理

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

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

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

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

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

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

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

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

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

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


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


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

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

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

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

2.2 nextTick實(shí)現(xiàn)

最后來看看我們平常用到的 nextTick 方法到底是如何實(shí)現(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
    })
  }
}


/* 強(qiáng)制使用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 在這里分為三個(gè)部分,我們一起來看一下;

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

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

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

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

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

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

3. 一個(gè)例子

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

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

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

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

0 人點(diǎn)贊