【重學前端框架】Vue之nextTick原理

前言

咱們都知道vue是數據驅動視圖,而vue中視圖更新是異步的。在業務開發中,有沒有經歷過當改變了數據,視圖卻沒有按照咱們的指望渲染?而須要將對應的操做放在nextTick中視圖才能按照預期的渲染,有的時候nextTick也不能生效,而須要利用setTimeout來解決?html

搞清楚這些問題,那麼就須要搞明白如下幾個問題:
一、vue中究竟是如何來實現異步更新視圖;
二、vue爲何要異步更新視圖;
三、nextTick的原理;
四、nextTick如何來解決數據改變視圖不更新的問題的;
五、nextTick的使用場景。vue

如下分享個人思考過程。react

Vue中的異步更新DOM

Vue中的視圖渲染思想

vue中每一個組件實例都對應一個 watcher實例,它會在組件渲染的過程當中把「接觸」過的數據屬性記錄爲依賴。以後當依賴項的 setter 觸發時,會通知 watcher,從而使它關聯的組件從新渲染。

若是對vue視圖渲染的思想還不是很清楚,能夠參考這篇defineProperty實現視圖渲染用defineProty模擬的Vue的渲染視圖,來了解整個視圖渲染的思想。git

Vue異步渲染思想和意義

可是Vue的視圖渲染是異步的,異步的過程是數據改變不會當即更新視圖,當數據所有修改完,最後再統一進行視圖渲染。github

image.png

在渲染的過程當中,中間有一個對虛擬dom進行差別化的計算過程(diff算法),大量的修改帶來頻繁的虛擬dom差別化計算,從而致使渲染性能下降,異步渲染正是對視圖渲染性能的優化。算法

Vue異步渲染視圖的原理

  • 依賴數據改變就會觸發對應的watcher對象中的update
/**
   * Subscriber interface.
   * Will be called when a dependency changes.
   */
  update () {
    /* istanbul ignore else */
    if (this.lazy) {
      this.dirty = true
    } else if (this.sync) {
      this.run()
    } else {
      queueWatcher(this)
    }
  }
  • 默認的調用queueWatcher將watcher對象加入到一個隊列中
/**
 * Push a watcher into the watcher queue.
 * Jobs with duplicate IDs will be skipped unless it's
 * pushed when the queue is being flushed.
 */
export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  if (has[id] == null) {
    has[id] = true
    if (!flushing) {
      queue.push(watcher)
    } else {
      // if already flushing, splice the watcher based on its id
      // if already past its id, it will be run next immediately.
      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(flushSchedulerQueue)
    }
  }
}

當第一次依賴有變化就會調用nextTick方法,將更新視圖的回調設置成微任務或宏任務,而後後面依賴更新對應的watcher對象都只是被加入到隊列中,只有當nextTick回調執行以後,纔會遍歷調用隊列中的watcher對象中的更新方法更新視圖。chrome

這個nextTick和咱們在業務中調用的this.$nextTick()是同一個函數。express

if (!waiting) {
      waiting = true
      nextTick(flushSchedulerQueue)
    }

flushSchedulerQueue刷新隊列的函數,用於更新視圖segmentfault

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

  // Sort queue before flush.
  // This ensures that:
  // 1. Components are updated from parent to child. (because parent is always
  //    created before the child)
  // 2. A component's user watchers are run before its render watcher (because
  //    user watchers are created before the render watcher)
  // 3. If a component is destroyed during a parent component's watcher run,
  //    its watchers can be skipped.
  queue.sort((a, b) => a.id - b.id)

  // do not cache length because more watchers might be pushed
  // as we run existing watchers
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index]
    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
      }
    }
  }

那麼nextTick究竟是個什麼東西呢?數組

nextTick的原理

vue 2.5中nextTick的源碼以下(也能夠跳過源碼直接看後面的demo,來理解nextTick的用處):

/**
 * Defer a task to execute it asynchronously.
 */
export const nextTick = (function () {
  const callbacks = []
  let pending = false
  let timerFunc

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

  // An asynchronous deferring mechanism.
  // In pre 2.4, we used to use microtasks (Promise/MutationObserver)
  // but microtasks actually has too high a priority and fires in between
  // supposedly sequential events (e.g. #4521, #6690) or even between
  // bubbling of the same event (#6566). Technically setImmediate should be
  // the ideal choice, but it's not available everywhere; and the only polyfill
  // that consistently queues the callback after all DOM events triggered in the
  // same loop is by using MessageChannel.
  /* istanbul ignore if */
  if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
    timerFunc = () => {
      setImmediate(nextTickHandler)
    }
  } else if (typeof MessageChannel !== 'undefined' && (
    isNative(MessageChannel) ||
    // PhantomJS
    MessageChannel.toString() === '[object MessageChannelConstructor]'
  )) {
    const channel = new MessageChannel()
    const port = channel.port2
    channel.port1.onmessage = nextTickHandler
    timerFunc = () => {
      port.postMessage(1)
    }
  } else
  /* istanbul ignore next */
  if (typeof Promise !== 'undefined' && isNative(Promise)) {
    // use microtask in non-DOM environments, e.g. Weex
    const p = Promise.resolve()
    timerFunc = () => {
      p.then(nextTickHandler)
    }
  } else {
    // fallback to setTimeout
    timerFunc = () => {
      setTimeout(nextTickHandler, 0)
    }
  }

  return function queueNextTick (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, reject) => {
        _resolve = resolve
      })
    }
  }
})()

用下面這個demo來感覺依賴更新時和nextTick的關係以及nextTick的用處:

function isNative(Ctor) {
     return typeof Ctor === 'function' && /native code/.test(Ctor.toString())
 }

 const nextTick = (function () {
     let pending = false;
     let callbacks = []
     let timerFunc

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

     if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
         timerFunc = () => {
             setImmediate(nextTickHandler)
         }
     } else if (typeof MessageChannel !== 'undefined' && (
             isNative(MessageChannel) ||
             // PhantomJS
             MessageChannel.toString() === '[object MessageChannelConstructor]'
         )) {
         const channel = new MessageChannel()
         const port = channel.port2
         channel.port1.onmessage = nextTickHandler
         timerFunc = () => {
             port.postMessage(1)
         }
     } else
         /* istanbul ignore next */
         if (typeof Promise !== 'undefined' && isNative(Promise)) {
             // use microtask in non-DOM environments, e.g. Weex
             const p = Promise.resolve()
             timerFunc = () => {
                 p.then(nextTickHandler)
             }
         } else {
             // fallback to setTimeout
             timerFunc = () => {
                 setTimeout(nextTickHandler, 0)
             }
         }

     console.log('timerFunc:', timerFunc)
     return function queueNextTick(cb, ctx) {
         callbacks.push(() => {
           if (cb) {
             cb.call(ctx)
            }
         })
         // console.log('callbacks:', callbacks)
         if (!pending) {
             pending = true
             console.log('pending...', true)
             timerFunc()
         }
     }
 })()

 //  模擬異步視圖更新
 // 第一次先將對應新值添加到一個數組中,而後調用一次nextTick,將讀取數據的回調做爲nextTick的參數
 // 後面的新值直接添加到數組中
 console.time()
 let arr = []
 arr.push(99999999)
 nextTick(() => {
     
     console.log('nextTick one:', arr, arr.length)
 })

 function add(len) {
     for (let i = 0; i < len; i++) {
         arr.push(i)
         console.log('i:', i)
     }
 }

 add(4)
 //  console.timeEnd()
 //  add()
 //  add()
 nextTick(() => {
     arr.push(888888)
     console.log('nextTick two:', arr, arr.length)
 })
 add(8)的值以後
 console.timeEnd()

在chrome運行結果以下:
image.png

能夠看到第二個nextTick中push的值最後渲染在add(8)的值以後,這也就是nextTick的做用了,nextTick的做用就是用來處理須要在數據更新(在vue中手動調用nextTick時對應的是dom更新完成後)完才執行的操做。

image.png

nextTick的原理:
首先nextTick會將外部傳進的函數回調存在內部數組中,nextTick內部有一個用來遍歷這個內部數組的函數nextTickHandler,而這個函數的執行是異步的,何時執行取決於這個函數是屬於什麼類型的異步任務:微任務or宏任務。

主線程執行完,就會去任務隊列中取任務到主線程中執行,任務隊列中包含了微任務和宏任務,首先會取微任務,微任務執行完就會取宏任務執行,依此循環。nextTickHandler設置成微任務或宏任務就能保證其老是在數據修改完或者dom更新完而後再執行。(js執行機制能夠看promise時序問題&js執行機制

爲何vue中對設置函數nextTickHandler的異步任務類型會有以下幾種判斷?

if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
         timerFunc = () => {
             setImmediate(nextTickHandler)
         }
     } else if (typeof MessageChannel !== 'undefined' && (
             isNative(MessageChannel) ||
             // PhantomJS
             MessageChannel.toString() === '[object MessageChannelConstructor]'
         )) {
         const channel = new MessageChannel()
         const port = channel.port2
         channel.port1.onmessage = nextTickHandler
         timerFunc = () => {
             port.postMessage(1)
         }
     } else
         /* istanbul ignore next */
         if (typeof Promise !== 'undefined' && isNative(Promise)) {
             // use microtask in non-DOM environments, e.g. Weex
             const p = Promise.resolve()
             timerFunc = () => {
                 p.then(nextTickHandler)
             }
         } else {
             // fallback to setTimeout
             timerFunc = () => {
                 setTimeout(nextTickHandler, 0)
             }
         }

瀏覽器環境中常見的異步任務種類,按照優先級:

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

而爲何最後才判斷使用setTimeout?
vue中目的就是要儘量的快地執行回調渲染視圖,而setTimeout有最小延遲限制:若是嵌套深度超過5級,setTimeout(回調,0)就會有4ms的延遲。

image.png

因此首先選用執行更快的setImmediate,可是setImmediate有兼容性問題,目前只支持Edge、Ie瀏覽器:

image.png

能夠用一樣執行比setTimeout更快的宏任務MessageChannel來代替setImmediate。MessageChannel兼容性以下:

image.png

當以上都不支持的時候,就使用new Promise().then(),將回調設置成微任務,Promise不支持才使用setTimeout。

總結:

nextTick就是利用了js機制執行任務的規則,將nextTick的回調函數設置成宏任務或微任務來達到在主線程的操做執行完,再執行的目的。

在vue中主要提供對依賴Dom更新完成後再作操做的狀況的支持

nextTick的使用場景

當改變數據,視圖沒有按預期渲染時;都應該考慮是不是由於本須要在dom執行完再執行,然而實際卻在dom沒有執行完就執行了代碼,若是是就考慮使用將邏輯放到nextTick中,有的時候業務操做複雜,有些操做可能須要更晚一些執行,放在nextTick中仍然沒有達到預期效果,這個時候能夠考慮使用setTimeout,將邏輯放到宏任務中。

基於以上分析,能夠列舉幾個nextTick經常使用到的使用場景:

  • 在created、mounted等鉤子函數中使用時。
  • 對dom進行操做時,例如:使用$ref讀取元素時
// input 定位
        scrollToInputBottom() {
            this.$nextTick(() => {
                this.$refs.accept_buddy_left.scrollTop =
                    this.$refs.accept_buddy_left.scrollTop + 135
                this.$refs.accept_buddy_ipt[
                    this.$refs.accept_buddy_ipt.length - 1
                ].$refs.ipt.focus()
            })
        },
  • 計算頁面元素高度時:
// 監聽來自 url 的期數變化,跳到該期數
        urlInfoTerm: {
            immediate: true,
            handler(val) {
                
                if (val !== 0) {
                    this.$nextTick(function() {
                        //     計算期數所在位置的高度
                        this.setCellsHeight()
                        //設置滾動距離
                        this.spaceLenght = this.getColumnPositionIndex(
                            this.list,
                        )
                        setTimeout(() => {
                            this.setScrollPosition(val)
                        }, 800)
                    })
                }
            },

參考資料

【Vue源碼】Vue中DOM的異步更新策略以及nextTick機制

異步更新隊列

nextTick原理

MessageChannel

相關文章
相關標籤/搜索