根據$nextTick一個怪異的現象經過窺探源碼發現vue驚天地泣鬼神的神來之筆

事情是這樣的, 這是一個在某天的默默的開發中, 筆者發現了一個驚天地泣鬼神的抓破腦殼都想不破的問題, 然後在這個月黑風高的晚上, 通過對源碼的窺探終於發現原因的悲慘故事

我們先來看一個demo, 關於$nextTick的使用這裏就不再贅述了

<!-- html結構非常的easy,  在vue接管的id爲app的dom區域內渲染了msg -->
<div id='#app'>
    {{ msg }}
</div>
const vm = new Vue({
    el: '#app',
    data: {
        msg: 'helloWorld'
    }
})

// 1. 首先頁面中一定會渲染出helloWorld

// 2. 第一個$nextTick
vm.$nextTick(() => {
    console.log('我是第一個$nextTick的輸出', vm.msg, vm.$el.innerHTML);
})

// 3. 更改msg的值
vm.msg = 'yes i do';
console.log(vm.msg, vm.$el.innerHTML);

// 4. 第二個$nextTick
vm.$nextTick(() => {
    console.log('我是第二個$nextTick的輸出',vm.msg, vm.$el.innerHTML);
})

console.log(vm.msg, vm.$el.innerHTML);

我們可以看到輸出結果如下

$nextTick輸出結果

筆者一開始是真實的一臉懵逼, 我當時唯一可以確定的是$nextTick是異步的, 當然Vue對於界面的更新本來就是異步的, 這個等於是在說廢話, 但是兩個nextTick的結果竟然不一樣?說好的會延遲到下一次dom更新才執行呢? 於是筆者去Vue的官網仔細的翻了翻

來自vue官方:

Vue 在更新 DOM 時是異步執行的。只要偵聽到數據變化,Vue 將開啓一個隊列,並緩衝在同一事件循環中發生的所有數據變更。如果同一個 watcher 被多次觸發,只會被推入到隊列中一次。這種在緩衝時去除重複數據對於避免不必要的計算和 DOM 操作是非常重要的。然後,在下一個的事件循環“tick”中,Vue 刷新隊列並執行實際 (已去重的) 工作。

翻譯成人話就是 vue的每次數據更新都會在下一個事件循環(eventloop)中執行

那麼結合我們上方的輸出結果, 筆者短暫認爲$nextTick是在下一次事件循環中dom更新完畢後執行


那麼問題來了: 既然$nextTick會在dom更新後執行, 爲何第一個中打印dom的值依舊沒有發生改變呢?既然沒改變就意味着他沒有在dom更新後執行啊? 這到底啥情況

如果你不看源碼 一定是百思不得其解的, 因爲vue有時候確實設計的非常精妙

筆者來用自己的方法給你寫一寫你能夠看的明白的$nextTick, 跟着註釋看我相信你是不會迷路的

const nextTick = (function() {
   let callbacks = [];  // 最後所有在nextTick中傳遞過來的函數都會進入這個數組 
   
   let timerHandler = () => { // 這個函數用來延遲nexTick傳遞進來的函數的執行
     // Promise.resolve這句話往這裏一站, 你就知道這哥們後面的那行then代碼要等待了,
       const p = Promise.resolve(); 
       p.then(releaseCallbacks); // 等同步任務執行完畢這哥們會執行
   }
   
  function releaseCallbacks() { // 作爲p.then的回調 releaseCallbacks肯定也會在微隊列中等待
      
      for(let i = 0; i < callbacks.length; i++) {
          callbacks[i](); // 所有存儲進callbacks的函數挨個執行
      }
      callbacks.length = 0;
  }

  // 真正暴露給用戶的回調函數
  return function(cb) {
      callbacks.push(cb);
      timerHandler();
  } 
}())

調用我們自己的的nextTick方法,其他語句都不變 我們走一遍輸出發現輸出結果如下

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-M06r2HMg-1591801267706)(./自己寫的nextTick.png)]

確實發現所有交付給nextTick的函數都按照異步執行了, 但是並沒有如我們所想象的那樣, 相反連nextTick真正的作用都發揮不上了, 我們不再可以監聽到msg被更改, 於是我們來看看被筆者進行註釋過後的真正的$nextTick源碼(當然, 前提是上面筆者的這份簡化版源碼你已經看懂了, 不然vue源碼會更加頭大)

export let isUsingMicroTask = false // 這是vue用來判斷是否啓用微任務的鎖, 如果不懂沒關係他不重要

const callbacks = [] // 同樣, 最後所有在nextTick中傳遞過來的函數都會進入這個數組
let pending = false // 異步鎖, 如果同步任務未執行完, 異步鎖肯定是鎖住的

function flushCallbacks () { // 最終執行callbacks的函數
  pending = false // 重置異步鎖

  // 這裏我們發現將callbacks複製了一份給copies, 最終循環操作的也是copies, 這是因爲不想造成nextTick嵌套調用的衝突
  const copies = callbacks.slice(0) 
  callbacks.length = 0
  for (let i = 0; i < copies.length; i++) {
    copies[i]()
  }
}

let timerFunc // 相當於上面的timerHandler


// 判斷當前環境支不支持原生的Promise構造函數
if (typeof Promise !== 'undefined' && isNative(Promise)) {
    // 如果支持會走上面的timerHandler的流程
  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]'
)) {
    // 判斷是不是IE
  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)) {
  // Fallback to setImmediate.
  // Technically it leverages the (macro) task queue,
  // but it is still a better choice than setTimeout.
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  // Fallback to setTimeout.
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

// 真正暴露出去的nextTick方法
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 這個是新加的, 如果沒有傳遞cb參數則返回一個新的promoise出去
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

拋開一些兼容性寫法和一些容錯機制來說, vue的nextTick和我們寫的nextTick沒有什麼差別, 但是爲什麼會產生截然不同的效果呢?

繼續閱讀源碼筆者有發現, vue中還存在一個queueWatcher方法, 如下

  function queueWatcher (watcher) {
    var 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.
        var 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;

        if (!config.async) {
          flushSchedulerQueue();
          return
        }
        nextTick(flushSchedulerQueue);
      }
    }
  }

你不用將他看懂, 但是筆者可以告訴你這哥們的作用就是用來更改nextTick的執行順序的

本身我們執行nextTick他的效果跟一般的異步任務沒什麼太大的區別, 無非就是nextTick會被置於微任務, 而queueWatcher方法和他帶來的一些騷操作則改變了nextTick的運行軌跡

  1. 如果在$nextTick前沒有更改vue監控的屬性值的情況發生, 那麼nextTick中的代碼按照正常異步微任務走掉

  2. 如果在$nextTick前有更改了vue所監控的屬性值的情況, 則queueWatcher會調換nextTick的執行順序, nextTick將會在下一次事件循環vue刷新頁面後執行

有些東西你不看源碼是真的想破頭都想不出來他到底是什麼原因, 這也是我們作爲開發者一直要追逐的事情, 共勉

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章