1. 程式人生 > 實用技巧 >Vue之nextTick原理

Vue之nextTick原理

前言

我們都知道vue是資料驅動檢視,而vue中檢視更新是非同步的。在業務開發中,有沒有經歷過當改變了資料,檢視卻沒有按照我們的期望渲染?而需要將對應的操作放在nextTick中檢視才能按照預期的渲染,有的時候nextTick也不能生效,而需要利用setTimeout來解決?

搞清楚這些問題,那麼就需要搞明白以下幾個問題:
1、vue中到底是如何來實現非同步更新檢視;
2、vue為什麼要非同步更新檢視;
3、nextTick的原理;
4、nextTick如何來解決資料改變檢視不更新的問題的;
5、nextTick的使用場景。

以下分享我的思考過程。

Vue中的非同步更新DOM

Vue中的檢視渲染思想

vue中每個元件例項都對應一個watcher
例項,它會在元件渲染的過程中把“接觸”過的資料屬性記錄為依賴。之後當依賴項的 setter 觸發時,會通知 watcher,從而使它關聯的元件重新渲染。

如果對vue檢視渲染的思想還不是很清楚,可以參考這篇defineProperty實現檢視渲染用defineProty模擬的Vue的渲染檢視,來了解整個檢視渲染的思想。

Vue非同步渲染思想和意義

但是Vue的檢視渲染是非同步的,非同步的過程是資料改變不會立即更新檢視,當資料全部修改完,最後再統一進行檢視渲染。

在渲染的過程中,中間有一個對虛擬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物件中的更新方法更新檢視。

這個nextTick和我們在業務中呼叫的this.$nextTick()是同一個函式。

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

flushSchedulerQueue重新整理佇列的函式,用於更新檢視

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執行結果如下:

可以看到第二個nextTick中push的值最後渲染在add(8)的值之後,這也就是nextTick的作用了,nextTick的作用就是用來處理需要在資料更新(在vue中手動呼叫nextTick時對應的是dom更新完成後)完才執行的操作。

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:同步程式碼、setImmediate、MessageChannel、setTimeout/setInterval
  • micro task:Promise.then、MutationObserver

而為什麼最後才判斷使用setTimeout?
vue中目的就是要儘可能的快地執行回撥渲染檢視,而setTimeout有最小延遲限制:如果巢狀深度超過5級,setTimeout(回撥,0)就會有4ms的延遲。

所以首先選用執行更快的setImmediate,但是setImmediate有相容性問題,目前只支援Edge、Ie瀏覽器:

可以用同樣執行比setTimeout更快的巨集任務MessageChannel來代替setImmediate。MessageChannel相容性如下:

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

資源搜尋網站大全 https://www.renrenfan.com.cn 廣州VI設計公司https://www.houdianzi.com

總結:

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)
                    })
                }
            },