[精讀原始碼系列]Vue中DOM的非同步更新和Vue.nextTick()

前端古力士發表於2019-09-05

前言

vue的DOM更新時非同步執行的,只要監聽到資料變化,Vue將開啟一個佇列,並快取在同一事件迴圈中發生的所有資料變更,如果同一個Watcher被多次觸發,只會被推入到佇列中一次,避免了不必要的重複計算和頻繁的DOM操作,然後在下一個事件迴圈"tick"中(注意下一個tick可能是當前的tick微任務執行階段執行,也可能在下一個tick執行,主要取決於nextTick函式使用的是Promise/MutationObserver還是setTimeout),Vue重新整理佇列並執行更新試圖等操作.

例如, 當你設定vm.somData = 'new value',該元件不會立即重新渲染,當重新整理元件時,元件會在下一個事件迴圈的"tick"中更新.雖然大多數情況下,我們並不需要關心這個過程,但是如果我們想在資料改變之後進行獲取更新後的DOM,我們就需要呼叫Vue.nextTick(callback),這樣回撥函式會在DOM更新完成後呼叫.

<div id="example">{{message}}</div>
var vm = new Vue({
    el: '#example',
    data: {
        message: '123'
    }
})
vm.message = 'new message' // 更新資料
console.log(vm.$el.textContent) // '123'
Vue.nextTick(function() {
    console.log(vm.$el.textContent) // 'new message'
})
複製程式碼

這就很像家裡有一個熊孩子今天要開party,可想而知會把家裡弄得亂七八糟,烏煙瘴氣,你要負責事後打掃戰場,但是你要是弄亂一點去收拾一點,就很浪費精力,得不償失.所以正確的方式應該是任由他折騰,等戰鬥結束完全結束後,再去清洗和整理.

非同步更新DOM

Watcher佇列

閱讀過vue原始碼的都知道,當某個響應式資料發生變化的時候,它的setter函式就會通知閉包中的Dep,Dep則會觸發對應的Watcher物件的update方法,我們來看一下update的實現:

update() {
    if(this.lazy) {
        this.dirty = true
    } else if(this.sync) {
        /*同步執行則run直接渲染檢視*/
        this.run()
    } else {
        /*非同步則推送到觀察者佇列中,下一個tick時呼叫*/
        queueWatcher(this)
    }
}
// queueWatcher函式
// 將觀察者物件push進佇列,並記錄觀察者的id
// 如果對應的觀察者已存在,則跳過,避免重複的計算
export function queueWatcher(watcher: Watcher) {
    const id = watcher.id
    if(!has[id]) {
        has[id] = true
        if(!flushing) {
            /*如果沒有被flush掉,直接push到佇列中即可*/
            queue.push(watcher)
        } else {
            //...
        }
        // queue the flush
        if(!wating) {
            wating = true
            nextTick(flushSchedulerQueue)
        }
    }
}
複製程式碼

通過檢視原始碼我們發現,watcher的update操作都被存入一個佇列queue了,等到下一個tick執行時,這些watcher會被遍歷執行,更新檢視.

那麼, 什麼是下一個tick?

Event Loop

想要知道什麼是下一個tick,我們先要了解下Event Loop(事件迴圈).js執行時單執行緒的,它是基於事件迴圈的,事件迴圈機制控制著js所有任務的有序執行,js中的任務分為同步任務和非同步任務,事件迴圈大致分為以下步驟:

  1. 所有同步任務在主執行緒上執行,形成一個執行棧.
  2. 主執行緒之外,還有一個任務佇列,這個佇列用於存放非同步任務, 只要非同步任務有了執行結果,就在"任務佇列"之中放置一個事件.
  3. 執行棧上的同步任務執行完畢後,主執行緒會讀取任務佇列中的任務執行,對應的非同步任務結束等待狀態,進入執行棧,開始執行.
  4. 主執行緒不斷重複以上操作,形成事件迴圈.

[精讀原始碼系列]Vue中DOM的非同步更新和Vue.nextTick()
我們平時用setTimeout來執行非同步程式碼,其實就是在任務佇列的末尾加入了一個task,待前面的任務執行完後在執行它,每次事件迴圈後,就會有一個UI Render步驟,也就是更新dom操作,那麼為什麼要這麼設計呢?程式碼示例:

for(let i =0; i < 100; i++) {
    dom.style.left = i + 'px'
}
複製程式碼

瀏覽器會進行100次dom更新嗎?顯然這樣太損耗效能了,事實上這100次for迴圈同屬一個task,瀏覽器只會在改task執行完後進行一次DOM更新.這也就意味著,只要讓nextTick中的回撥放在UI Render後執行,就可以訪問到更新後的DOM了.這樣我們很自然的想到把這些回撥邏輯放入任務佇列中去執行.

主執行緒的執行過程就是一個tick,所有的非同步結果都是通過"任務佇列"來排程,可想而知Vue中的DOM的非同步更新任務也是存放在任務佇列中的,下面我們就來看看nextTick的具體實現邏輯.

JS任務佇列

js中的任務佇列分為巨集任務(macrotask)佇列和微任務(microtask )佇列,每次事件迴圈結束後,都會先清空微任務佇列中的微任務,然後才會開始執行下一個巨集任務,微任務比巨集任務有著更高的優先順序.(注: 瀏覽器和NodeJs的事件迴圈的執行邏輯不一樣,這裡我們只研究瀏覽器中事件迴圈的執行邏輯,想要了解nodejs中的執行邏輯,可參考: segmentfault.com/a/119000001….)

所以事實上,我們呼叫nextTick的時候,就是在更新DOM那個microtask後執行了我們傳入的回撥函式,從而確保我們的程式碼在DOM更新後執行

Vue.nextTick()

nextTick的原始碼, 建議大家對照著原始碼來閱讀接下來的內容.

vue是如何監聽到DOM更新完畢,並執行我們傳入的回撥函式呢? HTML5新增了一個屬性MutationObserver,用於監聽DOM修改事件,能夠監聽到節點的屬性,文字內容,子節點等的改動,是一個功能強大的利器,基本用法如下:

// MO基本用法
var observer = new MutationObserver(function() {
    // 這裡是回撥函式
    console.log("DOM 被修改了!");
}); 
var article = document.querySelector('article');
observer.observer(article);  // 監聽dom改變後執行回撥
複製程式碼

那麼vue是不是用MO來監聽DOM更新完畢的呢? 開啟vue的原始碼,確實看到這樣的程式碼:

// MutationObserver 有更廣泛的支援,但在iOS上的觸控事件處理程式中存在bug
// 所以我們優先採用原生的promise.來建立微任務

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) ||
  // PhantomJS and iOS 7.x
  MutationObserver.toString() === '[object MutationObserverConstructor]'
)) {
  // Promise是es6新增的api,在不支援原生Promise的瀏覽器中,我們採用HTML5的新屬性MutationObserver來監聽DOM更新
  // e.g. PhantomJS, iOS7, Android 4.4
  // (#6466 MutationObserver is unreliable in IE11)
  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)) {
  // setImmediate, 目前只有IE和Node.js支援
  // 技術上它是利用巨集任務佇列,
  // 但是它仍是比setTimeout更好的選擇
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  // setTimeout是以上方案都不支援的最後的選擇
  // 儘管它有執行延遲,可能造成多次渲染
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}
// 暴露出nextTick方法,控制在下一個tick中執行傳入的回撥
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
    })
  }
}
複製程式碼

總結

關於Vue中DOM的非同步更新以及Vue.nextTick的原理解析就說到這兒了,後續會推出vue-router的原始碼解析,持續關注奧~如果你有什麼建議,困惑或想法,歡迎留言或者加微信lj_de_wei_xin與我交流~

擴充套件閱讀

相關文章