從JS事件循環(Event Loop)機制到vue.nextTick的實現

前言

衆所周知,爲了與瀏覽器進行交互,Javascript是一門非阻塞單線程腳本語言。
複製代碼
  1. 爲什麼單線程? 由於若是在DOM操做中,有兩個線程一個添加節點,一個刪除節點,瀏覽器並不知道以哪一個爲準,因此只能選擇一個主線程來執行代碼,以防止衝突。雖然現在添加了webworker等新技術,但其依然只是主線程的子線程,並不能執行諸如I/O類的操做。長期來看,JS將一直是單線程。javascript

  2. 爲什麼非阻塞?由於單線程意味着任務須要排隊,任務按順序執行,若是一個任務很耗時,下一個任務不得不等待。因此爲了不這種阻塞,咱們須要一種非阻塞機制。這種非阻塞機制是一種異步機制,即須要等待的任務不會阻塞主執行棧中同步任務的執行。這種機制是以下運行的:vue

    • 全部同步任務都在主線程上執行,造成一個執行棧(execution context stack)
    • 等待任務的回調結果進入一種任務隊列(task queue)
    • 當主執行棧中的同步任務執行完畢後纔會讀取任務隊列,任務隊列中的異步任務(即以前等待任務的回調結果)會塞入主執行棧,
    • 異步任務執行完畢後會再次進入下一個循環。此即爲今天文章的主角事件循環(Event Loop)

    用一張圖展現這個過程: java

    Markdown

正文

1.macro task與micro task

在實際狀況中,上述的任務隊列(task queue)中的異步任務分爲兩種:微任務(micro task)宏任務(macro task)ios

  • micro task事件:Promises(瀏覽器實現的原生Promise)MutationObserverprocess.nextTick

  • macro task事件:setTimeoutsetIntervalsetImmediateI/OUI rendering 這裏注意:script(總體代碼)即一開始在主執行棧中的同步代碼本質上也屬於macrotask,屬於第一個執行的task

microtask和macotask執行規則:git

  • macrotask按順序執行,瀏覽器的ui繪製會插在每一個macrotask之間
  • microtask按順序執行,會在以下狀況下執行:
    • 每一個callback以後,只要沒有其餘的JS在主執行棧中
    • 每一個macrotask結束時

下面來個簡單例子:github

console.log(1);

setTimeout(function() {
  console.log(2);
}, 0);

new Promise(function(resolve,reject){
    console.log(3)
    resolve()
}).then(function() {
  console.log(4);
}).then(function() {
  console.log(5);
});

console.log(6);
複製代碼

一步一步分析以下:web

  • 1.同步代碼做爲第一個macrotask,按順序輸出:1 3 6
  • 2.microtask按順序執行:4 5
  • 3.microtask清空後執行下一個macrotask:2

再來一個複雜的例子:promise

// Let's get hold of those elements
var outer = document.querySelector('.outer');
var inner = document.querySelector('.inner');

// Let's listen for attribute changes on the
// outer element
new MutationObserver(function() {
  console.log('mutate');
}).observe(outer, {
  attributes: true
});

// Here's a click listener…
function onClick() {
  console.log('click');

  setTimeout(function() {
    console.log('timeout');
  }, 0);

  Promise.resolve().then(function() {
    console.log('promise');
  });

  outer.setAttribute('data-random', Math.random());
}

// …which we'll attach to both elements
inner.addEventListener('click', onClick);
outer.addEventListener('click', onClick);
複製代碼

假設咱們建立一個有裏外兩部分的正方形盒子,裏外都綁定了點擊事件,此時點擊內部,代碼會如何執行?一步一步分析以下:瀏覽器

  • 1.觸發內部click事件,同步輸出:click
  • 2.將setTimeout回調結果放入macrotask隊列
  • 3.將promise回調結果放入microtask
  • 4.將Mutation observers放入microtask隊列,主執行棧中onclick事件結束,主執行棧清空
  • 5.依序執行microtask隊列中任務,輸出:promise mutate
  • 6.注意此時事件冒泡,外部元素再次觸發onclick回調,因此按照前5步再次輸出:click promise mutate(咱們能夠注意到事件冒泡甚至會在microtask中的任務執行以後,microtask優先級很是高)
  • 7.macrotask中第一個任務執行完畢,依次執行macrotask中剩下的任務輸出:timeout timeout

2.vue.nextTick實現

在 Vue.js 裏是數據驅動視圖變化,因爲 JS 執行是單線程的,在一個 tick 的過程當中,它可能會屢次修改數據,但 Vue.js 並不會傻到每修改一次數據就去驅動一次視圖變化,它會把這些數據的修改所有 push 到一個隊列裏,而後內部調用 一次 nextTick 去更新視圖,因此數據到 DOM 視圖的變化是須要在下一個 tick 才能完成。這即是咱們爲何須要vue.nextTick.app

這樣一個功能和事件循環很是類似,在每一個 task 運行完之後,UI 都會重渲染,那麼很容易想到在 microtask 中就完成數據更新,當前 task 結束就能夠獲得最新的 UI 了。反之若是新建一個 task 來作數據更新,那麼渲染就會進行兩次。

因此在vue 2.4以前使用microtask實現nextTick,直接上源碼

var counter = 1
var observer = new MutationObserver(nextTickHandler)
var textNode = document.createTextNode(String(counter))
observer.observe(textNode, {
    characterData: true
})
timerFunc = () => {
    counter = (counter + 1) % 2
    textNode.data = String(counter)
}
複製代碼

能夠看到使用了MutationObserver

然而到了vue 2.4以後卻混合使用microtask macrotask來實現,源碼以下

/* @flow */
/* globals MessageChannel */

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]()
  }
}

// Here we have async deferring wrappers using both micro and macro tasks.
// In < 2.4 we used micro tasks everywhere, but there are some scenarios where
// micro tasks have too high a priority and fires in between supposedly
// sequential events (e.g. #4521, #6690) or even between bubbling of the same
// event (#6566). However, using macro tasks everywhere also has subtle problems
// when state is changed right before repaint (e.g. #6813, out-in transitions).
// Here we use micro task by default, but expose a way to force macro task when
// needed (e.g. in event handlers attached by v-on).
let microTimerFunc
let macroTimerFunc
let useMacroTask = false

// Determine (macro) Task defer implementation.
// Technically setImmediate should be the ideal choice, but it's only available
// in IE. The only polyfill that consistently queues the callback after all DOM
// events triggered in the same loop is by using MessageChannel.
/* istanbul ignore if */
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 {
  /* istanbul ignore next */
  macroTimerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

// Determine MicroTask defer implementation.
/* istanbul ignore next, $flow-disable-line */
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  microTimerFunc = () => {
    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)
  }
} else {
  // fallback to macro
  microTimerFunc = macroTimerFunc
}

/** * Wrap a function so that if any code inside triggers state change, * the changes are queued using a Task instead of a MicroTask. */
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) {
  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()
    }
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}
複製代碼

能夠看到使用setImmediate、MessageChannel等mascrotask事件來實現nextTick。

爲何會如此修改,其實看以前的事件冒泡例子就能夠知道,因爲microtask優先級過高,甚至會比冒泡快,因此會形成一些詭異的bug。如 issue #4521#6690#6556;可是若是所有都改爲 macro task,對一些有重繪和動畫的場景也會有性能影響,如 issue #6813。因此最終 nextTick 採起的策略是默認走 micro task,對於一些 DOM 交互事件,如 v-on 綁定的事件回調函數的處理,會強制走 macro task。

參考資料
相關文章
相關標籤/搜索