Vue nextTick 變遷史

官方說明:在下次 DOM 更新循環結束以後執行延遲迴調。在修改數據以後當即使用這個方法,獲取更新後的 DOM。html

既然涉及到執行順序,首先仍是簡要的說下 JS 的執行機制數組

Event Loop

Event Loop 瀏覽器

阮一峯性能優化

  • 全部同步任務都在主線程上執行,造成一個執行棧(execution context stack)。bash

  • 主線程以外,還存在一個"任務隊列"(task queue)。只要異步任務有了運行結果,就在"任務隊列"之中放置一個事件。app

  • 一旦"執行棧"中的全部同步任務執行完畢,系統就會讀取"任務隊列",看看裏面有哪些事件。那些對應的異步任務,因而結束等待狀態,進入執行棧,開始執行。dom

  • 主線程不斷重複上面的第三步。異步

瀏覽器環境下常見的函數

macro-task(宏任務): script, setImmediate, MessageChannel, setTimeout, postMessage,I/Ooop

micro-task(微任務): Promise.then, MutationObserver

這裏只是個簡單的轉述,學習具體內容請點擊上面的連接。

第一版

export const nextTick = (function () {
 // 存儲須要執行的回調函數 
 var callbacks = []
 // 標識是否有 timerFunc 被推入了任務隊列
 var pending = false
 // 函數指針
 var timerFunc
 // 下一個 tick 時循環 callbacks, 依次取出回調函數執行,清空 callbacks 數組
 function nextTickHandler () {
   pending = false
   var copies = callbacks.slice(0)
   callbacks = []
   for (var i = 0; i < copies.length; i++) {
     copies[i]()
   }
 }

 // 檢測 MutationObserver 是否可用
 // 當執行 timerFunc 時,改變監聽值,觸發觀測者將 nextTickHandler 推入任務隊列
 if (typeof MutationObserver !== 'undefined') {
   var counter = 1
   var observer = new MutationObserver(nextTickHandler)
   var textNode = document.createTextNode(counter)
   observer.observe(textNode, {
     characterData: true
   })
   timerFunc = function () {
     counter = (counter + 1) % 2
     textNode.data = counter
   }
 } else {
   // 若是 MutationObserver 不可用
   // timerFunc 指向 setImmediate 或者 setTimeout
   const context = inBrowser
     ? window
     : typeof global !== 'undefined' ? global : {}
   timerFunc = context.setImmediate || setTimeout
 }
 // 返回的函數接受兩個參數,回調函數和傳給回調函數的參數
 return function (cb, ctx) { 
   var func = ctx
     ? function () { cb.call(ctx) }
     : cb
   // 將構造的回調函數壓入 callbacks 中
   callbacks.push(func)
   // 防止 timerFunc 被重複推入任務隊列
   if (pending) return
   pending = true
   // 執行 timerFunc
   timerFunc(nextTickHandler, 0)
 }
})()
複製代碼

初版的 nextTick 實現 timerFunc 順序爲 MutationObserver, setImmediate,setTimeout

nextTick 最開始在 util/env.js 文件中,2.5.2版本遷移到 util/next-tick.js 中維護。

初版到2.5.2版本之間,nextTick 修改了屢次,修改的內容主要是 timerFunc 的實現。

第一次修改是將 MutationObserver 替換爲 postMessage, 給出的理由是 MutationObserver 在 UIWebView (iOS >= 9.3.3) 中不可靠(如今是否有問題不清楚)。後面版本中又恢復了 MutationObserver 的使用,同時對 MutationObserver 使用作了檢測, 非IE環境下且是原生 MutationObserver。

第二次改動是恢復了微任務的優先使用,timerFunc 檢測順序變爲 Promise, MutationObserver, setTimeout. 在使用 Promise 時,針對 IOS 作了特殊處理,添加空的計時器強制刷新微任務隊列。 同時這一版中還有個小的改動, nextTickHandler 方法中對 callbacks 數組重置修改成

callbacks.length = 0
複製代碼

一個小的性能優化,減少空間消耗。

然而這個方案並無持續多久就迎來來一次‘大’改動,微任務所有裁撤,timerFunc 檢測順序變爲 setImmediate, MessageChannel, setTimeout. 緣由是微任務優先級過高了,其中一個 issues 編號爲 #6566, 狀況以下:

<div class="header" v-if="expand">
  <i @click="expand = false, countA++">Expand is True</i>
</div>
<div class="expand" v-if="!expand" @click="expand = true, countB++">
  <i>Expand is False</i>
</div>
複製代碼

上面代碼想完成的效果很容易理解,點擊切換 div。可是實際效果如上圖所示,偏離預期,當點擊一下時,彷佛兩個 click 事件都被觸發了,什麼狀況,一臉懵逼....

這裏給個連接有興趣能夠去點點看 點我點我

尤大對此給出了回覆,簡而言之,點擊 div.header, 觸發標籤 i 上綁定的事件,執行事件後

expand = false
countA = 1
複製代碼

而後由於微任務優先級過高,在事件冒泡到外層 div 時就已經觸發,更新期間,click listener 加到了外層div, 由於 dom 結構一致,div 和 i 標籤都被重用,而後 click 事件冒泡到 div, 觸發了第二次更新

expand = true
countB = 1
複製代碼

因此出現瞭如圖所示的尷尬結果。若是對這塊想了解的更多,能夠去找一下這個issue: #6566. 這裏又要提到 JS 的事件機制了,task 依次執行, UI Render 可能在 task 之間執行, 微任務在 JS 執行棧爲空時會清空隊列。

以後又作了次小改動,在 MessageChannel 後添加了 Promise 處理 non-DOM envirment.

2.5.2+

import { noop } from 'shared/util'
import { handleError } from './error'
import { isIOS, isNative } from './env'

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 microTimerFunc
let macroTimerFunc
let useMacroTask = false

if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  macroTimerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else if (typeof MessageChannel !== 'undefined' && (
  isNative(MessageChannel) ||
  // PhantomJS
  MessageChannel.toString() === '[object MessageChannelConstructor]'
)) {
  const channel = new MessageChannel()
  const port = channel.port2
  channel.port1.onmessage = flushCallbacks
  macroTimerFunc = () => {
    port.postMessage(1)
  }
} else {
  macroTimerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  microTimerFunc = () => {
    p.then(flushCallbacks)
    if (isIOS) setTimeout(noop)
  }
} else {
  // fallback to macro
  microTimerFunc = macroTimerFunc
}

export function withMacroTask (fn: Function): Function {
  return fn._withTask || (fn._withTask = function () {
    useMacroTask = true
    const res = fn.apply(null, arguments)
    useMacroTask = false
    return res
  })
}

export function nextTick (cb?: Function, ctx?: Object): ?Promise {
  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
    if (useMacroTask) {
      macroTimerFunc()
    } else {
      microTimerFunc()
    }
  }
  // 若是不傳入回調函數就直接返回一個 Promise
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}
複製代碼

這一版抽到單獨文件維護,而且引入 microTimerFunc, macroTimerFunc 分別對應微任務,宏任務。 macroTimerFunc 檢測順序爲 setImmediate, Messagechannel, setTimeout, 微任務首先檢測 Promise, 若是不支持 Promise 就直接指向 macroTimerFunc. 對外暴露了兩個方法 nextTick 和 withMacroTask. nextTick 和以前邏輯變化不大,withMacroTask 對傳入的函數作一層包裝,保證函數內部代碼觸發狀態變化,執行 nextTick 的時候強制走 macroTimerFunc。

此次修改的一個主要緣由是在任何地方都使用宏任務會產生一些很奇妙的問題,其中表明 issue:#6813。 點我點我

從上圖能夠看到列表的 display 有兩個控制:

  • 媒體查詢,當頁面寬度大於 1000px 時,li 顯示類型爲行內框
  • showList 爲 false 時,ul 的 display 值切換爲 none

初始狀態:

當快速拖動網頁邊框縮小頁面寬度時,會先顯示第一張圖,而後快速的隱藏

這個過程也比較好理解,以前優先使用宏任務,在兩個 task 之間,會進行 UI Render ,這時,li 的行內框設置失效,展現爲塊級框,在以後的 task 運行了 watcher.run 更新狀態,再一次 UI Render 時,ul 的 display 的值切換爲 none,列表隱藏。

2.6+

import { noop } from 'shared/util'
import { handleError } from './error'
import { isIE, isIOS, isNative } from './env'

export let isUsingMicroTask = false

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 timerFunc

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]'
)) {
 
  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)) {
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

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()
  }
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}
複製代碼

驚奇的發現彷佛又回到了第一版,以前由於微任務優先級過高,太快的執行致使了非預期的問題,而此次的迴歸主要緣由是由於宏任務執行時間太靠後致使一些沒法規避的問題,而微任務高優先級致使的問題是有變通的方法的,權衡以後,決定改回高優先級的微任務。

幾個有意思的點

  1. 第一次用 task 替換 microtask
<div @click>
  <i @click>Test</i>
</div>
複製代碼

給出相似的 DOM 結構,點擊 i 標籤,觸發回調事件,事件中對組件狀態作了修改,當前 task 執行完成,檢查微任務隊列並所有執行,其中就會執行 flushSchedulerQueue 方法,flushSchedulerQueue 會執行全部收集到的 watcher 的 run 方法(這裏涉及到響應式原理)以更新 DOM。而後 UI 從新渲染。而後取出下一個 task 執行,假設就是冒泡到 div 的click事件, 以後流程和上面的執行過程基本一致。因此就致使一次點擊更新兩次。

  1. MutationObserver 的使用
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)
  }
複製代碼

寫法很樸素,感受很親切。執行 timerFunc 讓 textNode 的值在 0/1 變換,每次變化觸發 observe 回調,在當前微任務隊列後面添加一個 microtask 。 microtask 在執行過程當中產生的微任務會添加到當前隊列後面等待執行,以前看過一篇文章說這個限制大約是1000,但暫時沒有找到相關規範。

相關文章
相關標籤/搜索