基於原始碼分析Vue的nextTick

0nTheRoad發表於2021-01-28

摘要:本文通過結合官方文件、原始碼和其他文章整理後,對Vue的nextTick做深入解析。理解本文最好有瀏覽器事件迴圈的基礎,建議先閱讀上文《事件迴圈Event loop到底是什麼》。

一、官方定義


實際上在弄清楚瀏覽器的事件迴圈後,Vue的nextTick就非常好理解了,它就是利用了事件迴圈的機制。我們首先來看看nextTick在Vue官方文件中是如何描述的:

Vue在更新DOM時是非同步執行的,只要偵聽到資料變化,Vue將開啟一個佇列,並緩衝在同一事件迴圈中發生的所有資料變更。如果同一個watcher被多次觸發,只會被推入到佇列中一次。這種在緩衝時去除重複資料對於避免不必要的計算和DOM操作是非常重要的。然後,在下一個事件迴圈“tick”中,Vue重新整理佇列並執行實際(已去重的)工作。Vue在內部對非同步佇列嘗試使用原生的Promise.then、MutationObserversetImmediate,如果執行環境不支援,則會採用setTimeout(fn,0)代替。
當重新整理佇列時,元件會在下一個事件迴圈“tick”中更新。多數情況我們不需要關心這個過程,但是如果你想基於更新後的 DOM 狀態來做點什麼,這就可能會有些棘手。雖然 Vue.js 通常鼓勵開發人員使用“資料驅動”的方式思考,避免直接接觸 DOM,但是有時我們必須要這麼做。為了在資料變化之後等待 Vue 完成更新 DOM,可以在資料變化之後立即使用 Vue.nextTick(callback)。

簡單來說,Vue為了保證資料多次變化操作DOM更新的效能,採用了非同步更新DOM的機制,且同一事件迴圈中同一個資料多次修改只會取最後一次修改結果。而這種方式產生一個問題,開發人員無法通過同步程式碼獲取資料更新後的DOM狀態,所以Vue就提供了Vue.nextTick方法,通過這個方法的回撥就能獲取當前DOM更新後的狀態。

但只看官方解釋可能還是會有些疑問,比如描述中說到的下一個事件迴圈“tick”是什麼意思?為什麼會是下一個事件迴圈?接下來我們看原始碼到底是怎麼實現的。

二、原始碼解析


Vue.nextTick的原始碼部分主要分為Watcher部分和NextTick部分,由於Watcher部分的原始碼在前文《深入解析vue響應式原理》中,已經詳細分析過了,所以這裡關於Watcher的原始碼就直接分析觸發update之後的部分。

update

update () {
  /* istanbul ignore else */
  if (this.lazy) {
    this.dirty = true
  } else if (this.sync) {
    this.run()
  } else {
    queueWatcher(this)
  }
}

queueWatcher

export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  if (has[id] == null) {
    has[id] = true
    if (!flushing) {
      queue.push(watcher)
    } else {
      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

      if (process.env.NODE_ENV !== 'production' && !config.async) {
        flushSchedulerQueue()
        return
      }
      nextTick(flushSchedulerQueue)
    }
  }
}

flushSchedulerQueue

function flushSchedulerQueue () {
  currentFlushTimestamp = getNow()
  flushing = true
  let watcher, id

  queue.sort((a, b) => a.id - b.id)
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index]
    if (watcher.before) {
      watcher.before()
    }
    id = watcher.id
    has[id] = null
    watcher.run()
    // in dev build, check and stop circular updates.
    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
      }
    }
  }

  // keep copies of post queues before resetting state
  const activatedQueue = activatedChildren.slice()
  const updatedQueue = queue.slice()

  resetSchedulerState()

  // call component updated and activated hooks
  callActivatedHooks(activatedQueue)
  callUpdatedHooks(updatedQueue)

  // devtool hook
  /* istanbul ignore if */
  if (devtools && config.devtools) {
    devtools.emit('flush')
  }
}

根據前文《深入解析vue響應式原理》可以知道,資料變化後會首先觸發關聯Dep的notify方法,然後會呼叫所有依賴該資料的Watcher.update方法。接下來的步驟總結如下:

  1. update又呼叫了queueWatcher方法;
  2. queueWatcher方法中使用靜態全域性Watcher陣列queue來儲存當前的watcher,並且如果Watcher重複,只會保留一次;
  3. 然後是flushSchedulerQueue方法,簡單來說,flushSchedulerQueue方法中主要就是遍歷queue陣列,依次執行了所有的Watcher.run,操作DOM更新;
  4. 但flushSchedulerQueue並不會立即執行,而是作為nextTick引數進入下一層。

重點來到了nextTick這一層。

nextTick

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
    timerFunc()
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

timerFunc

let timerFunc
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  timerFunc = () => {
    p.then(flushCallbacks)
    if (isIOS) setTimeout(noop)
  }
  isUsingMicroTask = true
} else if (!isIE && typeof MutationObserver !== 'undefined' && (
  isNative(MutationObserver) || MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
  let counter = 1
  const observer = new MutationObserver(flushCallbacks)
  const textNode = document.createTextNode(String(counter))
  observer.observe(textNode, {
    characterData: true
  })
  timerFunc = () => {
    counter = (counter + 1) % 2
    textNode.data = String(counter)
  }
  isUsingMicroTask = true
} else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  // Fallback to setTimeout.
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

flushCallbacks

function flushCallbacks () {
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

nextTick程式碼流程總結如下:

  1. 結合前面程式碼分析來看,遍歷Watcher執行DOM更新的方法傳入了nextTick,在nextTick中被新增到了callbacks陣列,隨後執行了timerFunc方法;
  2. timerFunc方法使用了flushCallbacks方法,flushCallbacks執行了flushSchedulerQueue方法,即執行Watcher關聯的DOM更新。
  3. 而timerFunc是根據瀏覽器支援情況,將flushCallbacks(DOM更新操作)作為引數傳遞給Promise.then、MutationObserver、setImmediatesetTimeout(fn,0)

到這裡我們明白了,原來在Vue中資料變更觸發DOM更新操作也是使用了nextTick來實現非同步執行的,而Vue提供給開發者使用的nextTick是同一個nextTick。所以官方文件強調了要在資料變化之後立即使用 Vue.nextTick(callback),這樣就能保證callback是插入佇列裡DOM更新操作的後面,並在同一個事件迴圈中按順序完成,因為開發者插入的callback在隊尾,那麼始終是在DOM操作後立即執行。

而針對官方文件“在下一個事件迴圈"tick"中,Vue重新整理佇列並執行實際(已去重的)工作”的描述我覺得是不夠嚴謹的,原因在於,根據瀏覽器的支援情況,結合瀏覽器事件迴圈巨集任務和微任務的概念,如果nextTick使用的是Promise.then或MutationObserver,那就是和script(整體程式碼)是同一個事件迴圈;如果使用的是setImmediate或setTimeout(fn,0)),那才在下一個事件迴圈。
同時,聰明的你或許已經想到了,那按這個原理實際我不需要使用nextTick好像也可以達到同樣的效果,比如使用setTimeout(fn,0),那我們直接用一個例子來看一下吧。

<template>
  <div class="box">{{msg}}</div>
</template>

<script>
export default {
  name: 'index',
  data () {
    return {
      msg: 'hello'
    }
  },
  mounted () {
    this.msg = 'world'
    let box = document.getElementsByClassName('box')[0]
    setTimeout(() => {
      console.log(box.innerHTML) // world
    })
  }
}

結果確實符合我們的想象,不過仔細分析一下,雖然能達到同樣的效果,但跟nextTick有點細微差異的。這個差異就在於,如果使用nextTick是能保證DOM更新操作和callback是放到同一種任務(巨集/微任務)佇列來執行的,但使用setTimeout(fn,0)就很可能跟DOM更新操作沒有在同一個任務佇列,而不在同一事件迴圈執行。不過這點細微差異目前還沒發現有什麼問題,反正是可以正確獲取DOM更新後狀態的。

相關文章