[精讀源碼系列]Vue中DOM的異步更新和Vue.nextTick()

前言

vue的DOM更新時異步執行的,只要監聽到數據變化,Vue將開啓一個隊列,並緩存在同一事件循環中發生的全部數據變動,若是同一個Watcher被屢次觸發,只會被推入到隊列中一次,避免了沒必要要的重複計算和頻繁的DOM操做,而後在下一個事件循環"tick"中(注意下一個tick多是當前的tick微任務執行階段執行,也可能在下一個tick執行,主要取決於nextTick函數使用的是Promise/MutationObserver仍是setTimeout),Vue刷新隊列並執行更新試圖等操做.前端

例如, 當你設置vm.somData = 'new value',該組件不會當即從新渲染,當刷新組件時,組件會在下一個事件循環的"tick"中更新.雖然大多數狀況下,咱們並不須要關心這個過程,可是若是咱們想在數據改變以後進行獲取更新後的DOM,咱們就須要調用Vue.nextTick(callback),這樣回調函數會在DOM更新完成後調用.vue

<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,可想而知會把家裏弄得亂七八糟,烏煙瘴氣,你要負責過後打掃戰場,可是你要是弄亂一點去收拾一點,就很浪費精力,得不償失.因此正確的方式應該是任由他折騰,等戰鬥結束徹底結束後,再去清洗和整理.node

異步更新DOM

Watcher隊列

閱讀過vue源碼的都知道,當某個響應式數據發生變化的時候,它的setter函數就會通知閉包中的Dep,Dep則會觸發對應的Watcher對象的update方法,咱們來看一下update的實現:git

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會被遍歷執行,更新視圖.es6

那麼, 什麼是下一個tick?github

Event Loop

想要知道什麼是下一個tick,咱們先要了解下Event Loop(事件循環).js執行時單線程的,它是基於事件循環的,事件循環機制控制着js全部任務的有序執行,js中的任務分爲同步任務和異步任務,事件循環大體分爲如下步驟:web

  1. 全部同步任務在主線程上執行,造成一個執行棧.
  2. 主線程以外,還有一個任務隊列,這個隊列用於存放異步任務, 只要異步任務有了運行結果,就在"任務隊列"之中放置一個事件.
  3. 執行棧上的同步任務執行完畢後,主線程會讀取任務隊列中的任務執行,對應的異步任務結束等待狀態,進入執行棧,開始執行.
  4. 主線程不斷重複以上操做,造成事件循環.

咱們平時用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了.這樣咱們很天然的想到把這些回調邏輯放入任務隊列中去執行.vue-router

主線程的執行過程就是一個tick,全部的異步結果都是經過"任務隊列"來調度,可想而知Vue中的DOM的異步更新任務也是存放在任務隊列中的,下面咱們就來看看nextTick的具體實現邏輯.segmentfault

JS任務隊列

js中的任務隊列分爲宏任務(macrotask)隊列和微任務(microtask )隊列,每次事件循環結束後,都會先清空微任務隊列中的微任務,而後纔會開始執行下一個宏任務,微任務比宏任務有着更高的優先級.(注: 瀏覽器和NodeJs的事件循環的執行邏輯不同,這裏咱們只研究瀏覽器中事件循環的執行邏輯,想要了解nodejs中的執行邏輯,可參考: segmentfault.com/a/119000001….)api

因此事實上,咱們調用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與我交流~

擴展閱讀

相關文章
相關標籤/搜索