深入淺出理解vm.$nextTick

liu6發表於2020-03-15

使用場景:

在我們開發專案的時候,總會碰到一些場景:當我們使用vue操作更新dom後,需要對新的dom做一些操作時,但是這個時候,我們往往會獲取不到跟新後的DOM.因為這個時候,dom還沒有重新渲染,所以我們就要使用vm.$nextTick方法。

用法:

nextTick接受一個回撥函式作為引數,它的作用將回撥延遲到下次DOM跟新週期之後執行。

methods:{
example:function(){
 //修改資料
 this.message='changed'
//此時dom還沒有跟新,不能獲取新的資料
 this.$nextTick(function(){
   //dom現在跟新了
   //可以獲取新的dom資料,執行操作
   this.doSomeThing()
  })
 }
}
複製程式碼

小思考:

在用法中,我們發現,什麼是下次DOM更新週期之後執行,具體是什麼時候,所以,我們要明白什麼是DOM更新週期。 在Vue當中,當檢視狀態發生變化時,watcher會得到通知,然後觸發虛擬DOM的渲染流程,渲染這個操作不是同步的,是非同步。Vue中有一個佇列,每當渲染時,會將watcher推送這個佇列,在下一次事件迴圈中,讓watcher觸發渲染流程。

為什麼Vue使用非同步更新佇列?

簡單來說,就是提升效能,提升效率。 我們知道Vue2.0使用虛擬dom來進行渲染,變化偵測的通知只傳送到元件上,元件上的任意一個變化都會通知到一個watcher上,然後虛擬DOM會對整個元件進行比對(diff演算法,以後有時間我會詳細研究一下),然後更新DOM.如果在同一輪事件迴圈中有兩個資料發生變化了,那麼元件的watcher會收到兩次通知,從而進行兩次渲染(同步跟新也是兩次渲染),事實上我們並不需要渲染這麼多次,只需要等所有狀態都修改完畢後,一次性將整個元件的DOM渲染到最新即可。

如何解決一次事件迴圈元件多次狀態改變只需要一次渲染更新?

其實很簡單,就是將收到的watcher例項加入佇列裡快取起來,並且再新增佇列之前檢查這個佇列是否已存在相同watcher。不存在時,才將watcher例項新增到佇列中。然後再下一次事件迴圈中,Vue會讓這個佇列中的watcher觸發渲染並清空佇列。這樣就保證一次事件迴圈元件多次狀態改變只需要一次渲染更新。

什麼是事件迴圈?

我們知道js是一門單執行緒非阻塞的指令碼語言,意思是執行js程式碼時,只有一個主執行緒來處理所有任務。非阻塞是指當程式碼需要處理非同步任務時,主執行緒會掛起(pending),當非同步任務處理完畢,主執行緒根據一定的規則去執行回撥。事實上,當任務執行完畢,js會將這個事件加入一個佇列(事件佇列)。被放入佇列中的事件不會立刻執行其回撥,而是當前執行棧中所有任務執行完畢後,主執行緒會去查詢事件佇列中是否有任務。
非同步任務有兩種型別,微任務和巨集任務。不同型別的任務會被分配到不同的任務佇列中。
執行棧中所有任務執行完畢後,主執行緒會去查詢事件佇列中是否有任務,如果存在,依次執行所有佇列中的回撥,只到為空。然後再去巨集任務佇列中取出一個事件,把對應的回撥加入當前執行棧,當前執行棧中所有任務都執行完畢,檢查微任務佇列是否有事件。無線迴圈此過程,叫做事件迴圈。

常見的微任務

  • Promise.then
  • Object.observe
  • MutationObserver

常見的巨集任務

  • setTimeout
  • setInterval
  • setImmediate
  • UI互動事件

在我們使用vm.$nextTick中獲取跟新後DOM時,一定要在更改資料的後面使用nextTick註冊回撥。

methods:{
example:function(){
 //修改資料
 this.message='changed'
//此時dom還沒有跟新,不能獲取新的資料
 this.$nextTick(function(){
   //dom現在跟新了
   //可以獲取新的dom資料,執行操作
   this.doSomeThing()
  })
 }
}
複製程式碼

如果是先使用nextTick註冊回撥,然後修改資料,在微任務佇列中先執行使用nextTick註冊的回撥,然後才執行跟新DOM的回撥,所以回撥中得不到新的DOM,因為還沒有更新。

methods:{
example:function(){
//此時dom還沒有跟新,不能獲取新的資料
 this.$nextTick(function(){
 //dom沒有跟新,不能獲取新的dom
   this.doSomeThing()
  })
   //修改資料
 this.message='changed'
 }
}
複製程式碼

我們知道,新增微任務佇列中的任務執行機制要高於巨集任務的執行機制(下面程式碼必須理解)

methods:{
example:function(){
//先試用setTimeout向巨集任務中註冊回撥
setTimeout(()=>{
//現在DOM已經跟新了,可以獲取最新DOM
})
   //然後修改資料
 this.message='changed'
 }
}
複製程式碼

setTimeout屬於巨集任務,使用它註冊回撥會加入巨集任務中,巨集任務執行要比微任務晚,所以即便是先註冊,也是先跟新DOM後執行setTineout中設定回撥。

理解nextTick的作用後,我們以下來介紹實現原理

實現原理剖析:

由於nextTick會將回撥新增到任務佇列中延遲執行,所以在回撥執行之前,如果反覆使用nextTick,Vue並不會將回撥新增到任務佇列中,只會新增一個任務。Vue內部有一個列表來儲存nextTick引數中提供的回撥,當任務觸發時,以此執行列表裡的所有回撥並清空列表,其程式碼如下(簡易版):

const callbacks=[]
let pending=false

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

let microTimeFun
const p=Promise.resolve()
microTimeFun=()=>{
  p.then(flushCallBacks)
}

export function nextTick(cb,ctx){
  callbacks.push(()=>{
    if(cb){
      cb.call(ctx)
    }
  })
  if(!pending){
    pending=true
    microTimeFun()
  }
}
複製程式碼

理解相關變數:

  • callbacks:用來儲存使用者註冊的回撥函式(獲得了更新後DOM所進行的操作)
  • pending:用來標記是否向任務佇列新增任務,pending為false,表示任務佇列沒有nextTIck任務,需要新增nextTick任務,當新增一個nextTick任務時,pending為ture,在回撥執行之前還有nextTick時,並不會重複新增任務到任務佇列,當回撥函式開始執行時,pending為flase,進行新的一輪事件迴圈。
  • flushCallbacks:就是我們所說的被註冊在任務佇列中的任務,當這個函式執行,callbacks中所有函式依次執行,然後清空callbacks,並重置pending為false,所以說,一輪事件迴圈中,flushCallbacks只會執行一次。
  • microTimerFunc:它的作用就是使用Promise.then將flushCallbacks新增到微任務佇列中。

下圖給出nextTick內部註冊流程和執行流程。

深入淺出理解vm.$nextTick
官方文件裡面還有這麼一句話,如果沒有提供回撥且支援Promise的環境下,則返回一個Promise。也就是說。可以這樣使用nextTick

this.$nextTick().then(function(){
    //dom跟新了
})
複製程式碼

要實現這個功能,只需要在nextTIck中判斷,如果沒有提供回撥且當前支援Promise,那麼返回Promise,並且在callbacks中新增一個函式,當這個函式執行時,執行Promise的resolve,即可,程式碼如下

  function nextTick (cb, ctx) {
    var _resolve;
    callbacks.push(function () {
     if (cb) {
        cb.call(ctx);
      } else if (_resolve) {
        _resolve(ctx);
      }
    });
    if (!pending) {
      pending = true;
      timerFunc();
    }
    if (!cb && typeof Promise !== 'undefined') {
      return new Promise(function (resolve) {
        _resolve = resolve;
      })
    }
  }
複製程式碼

nextTick原始碼檢視

到此,nextTick原理基本上已經講完了。那我們現在可以看看真正vue中關於nextTick中的原始碼,大概我們都能理解的過來了,原始碼如下。

var timerFunc;

  // The nextTick behavior leverages the microtask queue, which can be accessed
  // via either native Promise.then or MutationObserver.
  // MutationObserver has wider support, however it is seriously bugged in
  // UIWebView in iOS >= 9.3.3 when triggered in touch event handlers. It
  // completely stops working after triggering a few times... so, if native
  // Promise is available, we will use it:
  /* istanbul ignore next, $flow-disable-line */
  if (typeof Promise !== 'undefined' && isNative(Promise)) {
    var p = Promise.resolve();
    timerFunc = function () {
      p.then(flushCallbacks);
      // In problematic UIWebViews, Promise.then doesn't completely break, but
      // it can get stuck in a weird state where callbacks are pushed into the
      // microtask queue but the queue isn't being flushed, until the browser
      // needs to do some other work, e.g. handle a timer. Therefore we can
      // "force" the microtask queue to be flushed by adding an empty timer.
      if (isIOS) { setTimeout(noop); }
    };
    isUsingMicroTask = true;
  } else if (!isIE && typeof MutationObserver !== 'undefined' && (
    isNative(MutationObserver) ||
    // PhantomJS and iOS 7.x
    MutationObserver.toString() === '[object MutationObserverConstructor]'
  )) {
    // Use MutationObserver where native Promise is not available,
    // e.g. PhantomJS, iOS7, Android 4.4
    // (#6466 MutationObserver is unreliable in IE11)
    var counter = 1;
    var observer = new MutationObserver(flushCallbacks);
    var textNode = document.createTextNode(String(counter));
    observer.observe(textNode, {
      characterData: true
    });
    timerFunc = function () {
      counter = (counter + 1) % 2;
      textNode.data = String(counter);
    };
    isUsingMicroTask = true;
  } else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
    // Fallback to setImmediate.
    // Technically it leverages the (macro) task queue,
    // but it is still a better choice than setTimeout.
    timerFunc = function () {
      setImmediate(flushCallbacks);
    };
  } else {
    // Fallback to setTimeout.
    timerFunc = function () {
      setTimeout(flushCallbacks, 0);
    };
  }

  function nextTick (cb, ctx) {
    var _resolve;
    callbacks.push(function () {
      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(function (resolve) {
        _resolve = resolve;
      })
    }
  }
複製程式碼

總結

這篇文章大概花了兩天時間才寫出來的,充分的參考了<深入淺出vue.js>這本書,充分了理解書上關於vm.$nextTick中的每一句話,同時也對js中的事件迴圈有了進一步認識,對js執行機制也進一步加深。作為前端小白,不想只侷限於呼叫各種API,更要知道其原理,每天進步一小步。希望大家能多多與我討論交流。

相關文章