瀏覽器事件循環機制與Vue nextTick的實現

瀏覽器事件循環機制

先上一段簡單的代碼javascript

console.log('aa');
setTimeout(() => { 
    console.log('bb')}, 
0);
Promise.resolve().then(() => console.log('cc'));
複製代碼

執行結果老是以下:html

aa
cc
bb
複製代碼

爲何呢?爲何一樣是異步,Promise.then 就是 比 setTimeout 先執行呢。前端

這就涉及到瀏覽器事件循環機制了。vue

  1. 之前瀏覽器只有一類事件循環,都是基於當前執行環境上下文, 官方用語叫 browsing-context連接在此。咱們能夠理解爲一個window就是一個執行環境上下文,若是有iframe, 那麼iframe內就是另外一個執行環境了。
  2. 2017年新版的HTML規範新增了一個事件循環,就是web workers。這個暫時先不討論。

事件循環機制涉及到兩個知識點 macroTaskmicroTask,通常咱們會稱之爲宏任務微任務。不論是macroTask仍是microTask,他們都是以一種任務隊列的形式存在。java

macroTask

script(總體代碼), setTimeout, setIntervalsetImmediate(僅IE支持), I/O, UI-renderingreact

注:此處的 I/O 是一個抽象的概念,並非說必定指輸入/輸出,應該包括DOM事件的觸發,例如click事件,mouseover事件等等。這是個人理解,若是有誤,還請指出。ios

microTask

包括:Promises,process.nextTick, Object.observe(已廢棄),MutationObserver(監聽DOM改變)web

如下內容摘抄於知乎何幻的回答segmentfault

一個瀏覽器環境(unit of related similar-origin browsing contexts.)只能有一個事件循環(Event loop),而一個事件循環能夠多個任務隊列(Task queue),每一個任務都有一個任務源(Task source)。api

相同任務源的任務,只能放到一個任務隊列中。

不一樣任務源的任務,能夠放到不一樣任務隊列中。

對上面的幾句話進行總結:事件循環只有一個,圍繞着調用棧,macroTaskmicroTaskmacroTaskmicroTask是一個大的任務容器,裏面能夠有多個任務隊列。不一樣的任務源,任務會被放置到不一樣的任務隊列。那任務源是什麼呢,好比setTimeoutsetIntervalsetImmediate,這都是不一樣的任務源,雖然都是在macroTask中,但確定是放置在不一樣的任務隊列中的。 最後,具體瀏覽器內部怎麼對不一樣任務源的任務隊列進行排序和取數,這個目前我還不清楚,若是正在看文章的你知道的話,請告訴下我。

接下來咱們繼續分析macroTaskmicroTask的執行順序,這兩個隊列的行爲與瀏覽器具體的實現有關,這裏只討論被業界普遍認同和接受的隊列執行行爲。

macroTaskmicroTask 的循環順序以下:

注意: 總體代碼算一個 macroTask

  1. 先執行一個 macroTask 任務(例如執行整個js文件內的代碼)
  2. 執行完 macroTask 任務後,找到microTask隊列內的全部任務,按前後順序取出並執行
  3. 執行完microTask內的全部任務後,再從macroTask取出一個任務,執行。
  4. 重複:2,3 步驟。

如今,咱們來解釋文章開始時的那串代碼,爲何Promise老是優先於setTimeout

console.log('aa');
setTimeout(() => { 
    console.log('bb')}, 
0);
Promise.resolve().then(() => console.log('cc'));
複製代碼
  1. 瀏覽器加載總體代碼並執行算一個macroTask
  2. 在執行這段代碼的過程當中,解析到 setTimeout時,會將setTimeout內的代碼添加到 macroTask 隊列中。
  3. 接下來,又解析到Promise, 因而將 Promise.then()內的代碼 添加到 microTask 隊列中。
  4. 代碼執行完畢,也就是第一個 macroTask 完成後,去 microTask 任務隊列中,找出全部任務並執行, 此時執行了 console.log('cc');
  5. microTask 任務隊列執行完畢後,又取出下一個 macroTask 任務並執行,也就是執行setTimeout內的代碼console.log('bb')

從廣義上一句話總結: 一個宏任務執行完後,會執行完全部的微任務,再又執行一個宏任務。依此循環,這也就是事件循環。

若是對事件循環機制仍是不怎麼理解的話,能夠看下這篇文章,圖文並茂,講的挺細的。

Vue nextTick函數的實現

調用 nextTick 的方式
// 第一種,Vue全局方法調用
Vue.nextTick(fn, context);

// 第二種,在實例化vue時,內部調用
this.$nextTick(fn);
複製代碼

其實這兩種方式都是調用的 Vue 內部提供的一個nextTick 方法,Vue內部對這個方法作了些簡單的封裝

// src/core/instance/render.js ---- line 57
// 這裏調用 nextTick 時自動把當前vue實例對象做爲第二個參數傳入,因此咱們調用 this.$nextTick時,不須要傳第二個參數
Vue.prototype.$nextTick = function (fn) {
    return nextTick(fn, this)
};

// src/core/global-api/index.js ---- line 45
// 直接將 nextTick 暴露出去,做爲Vue全局方法
Vue.nextTick = nextTick;
複製代碼

也就是說,這兩種調用方式,都是執行的Vue內部提供的nextTick方法。這個nextTick方法,Vue用了一個單獨的文件維護。

文件在vue項目下 src/core/util/next-tick.js

flushCallbacks - 執行回調

首先文件頭部,定義了一個觸發回調的函數 flushCallbacks

這個flushCallbacks 永遠是被異步執行的。至於爲何,接下來會講到。

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]()
  }
}
複製代碼

這部分代碼的意思,就是依次觸發 callbacks內的函數。那麼 callbacks 數組是存放什麼的?其實就是存放咱們調用this.$nextTick(fn) 是傳入的fn,只不過對它作了一層做用域包裝和異常捕獲。

nextTick 函數的定義

nextTick 函數 定義在文件的末尾,代碼以下。注意看我加的註釋。

export function nextTick (cb?: Function, ctx?: Object) {
    let _resolve
    // 將傳入的函數包裝一層,綁定做用域,並try-catch捕獲錯誤
    // 若是沒傳入函數,且瀏覽器原生支持 Promise 的狀況下,讓 Promise resolve;
    callbacks.push(() => {
        if (cb) {
            try {
                cb.call(ctx)
            } catch (e) {
                handleError(e, ctx, 'nextTick')
            }
        } else if (_resolve) {
            _resolve(ctx)
        }
    })
    // pending 是一個開關,每次執行 flushCallbacks 後,會將 pending 重置爲 fasle
    if (!pending) {
        pending = true
        if (useMacroTask) {
            // 以 macroTask 的方式,執行 flushCallbacks
            // 這裏雖然代碼是執行了,可是 macroTimerFunc 內部的代碼是異步執行,這個點很關鍵
            macroTimerFunc()
        } else {
            // 以 microTask 的方式,執行 flushCallbacks
            // 這裏雖然代碼是執行了,可是 microTimerFunc 內部的代碼是異步執行,這個點很關鍵
            microTimerFunc()
        }
    }
    // $flow-disable-line
    // 這裏返回一個 Promise, 因此咱們能夠這樣調用,$this.nextTick().then(xxx)
    if (!cb && typeof Promise !== 'undefined') {
        return new Promise(resolve => {
            _resolve = resolve
        })
    }
}
複製代碼

上面的代碼的 pending 有點意思, 它是Vue作的一個性能優化吧。用來處理同時調用多個 nextTick 的業務場景, 例如

new Vue({
    // 省略
    created() {
        // 執行第一個時,首先 fn1 會被 push 進 callbacks,再往下走
        // pending 爲 false, 因此會進入 if (!pending),而後 pending 被設爲true, 執行 macroTimerFunc 或 microTimerFunc
        this.$nextTick(fn1);
        // 執行第二 個時,pending爲true,這時就不會進入 if (!pending) 了,
        // 可是 callbacks.push 是會執行的,也就是說會把 fn2 push進 callbacks 數組
        this.$nextTick(fn2);
        // 同第二個
        this.$nextTick(fn3);
    }
})
複製代碼

若是是這樣調用, 那麼Vue會怎麼作呢,Vue是會將這三個fn所有pushcallbacks,在下次執行macroTaskmicroTask的任務時,一塊兒執行的。 緣由是由於第一次執行 this.$nextTick時,不管是執行的macroTimerFunc仍是microTimerFunc, flushCallbacks都是被異步執行,macroTimerFunc是用macroTask的方式,而microTimerFunc是用microTask的方式。例如:

macroTimerFunc = () => {
    setTimeout(flushCallbacks, 0)
}

microTimerFunc = () => {
    Promise.resolve().then(flushCallbacks);
}
複製代碼

因此這三個this.$nextTick執行完後,其實就至關於往callbackspush了三個fn。在下次執行macroTaskmicroTask的任務時,flushCallbacks內的代碼纔會執行,也就是執行咱們傳入的fn

由於

一個宏任務執行完後,會執行完全部的微任務,再又執行一個宏任務。依此循環

看到這裏的同窗估計會有個疑問點,useMacroTask是什麼,macroTimerFunc 是什麼, microTimerFunc又是什麼。接下來會一一解開。

useMacroTask
// Here we have async deferring wrappers using both microtasks and (macro) tasks.
// In < 2.4 we used microtasks everywhere, but there are some scenarios where
// microtasks have too high a priority and fire 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 microtask 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
複製代碼

這裏的註釋須要仔細看下,翻譯摘抄於這裏, 大體意思以下

Vue2.4以前的版本中,nextTick幾乎都是基於microTask實現的,可是因爲microTask的執行優先級很是高,在某些場景之下它甚至要比事件冒泡還要快,就會致使一些詭異的問題;可是若是所有都改爲macroTask,對一些有重繪和動畫的場景也會有性能的影響。因此最終nextTick採起的策略是默認走microTask,對於一些DOM的交互事件,如v-on綁定的事件回調處理函數的處理,會強制走macroTask

useMacroTask 表示是否啓用 macroTask 的方式執行回調。

macroTimerFunc

接下來,macroTimerFunc 的定義是,在 下一個macroTask 中執行 flushCallbacks

// 優先 setImmediate, 
// 而後是 MessageChannel
// 最後纔是 setTimeout
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  macroTimerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else if (typeof MessageChannel !== 'undefined' && (
  isNative(MessageChannel) ||
  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)
  }
}
複製代碼

爲何採用的順序是 setImmediate --> MessageChannel --> setTimeout ? 緣由是由於:

在支持MessageChannelsetImmediate的狀況下,他們的執行順序是優先於setTimeout的(在IE11/Edge中,setImmediate延遲能夠在1ms之內,而setTimeout有最低4ms的延遲,因此setImmediatesetTimeout(0)更早執行回調函數。

MessageChannel的延遲也是會小於setTimeout的, 有人比較過。 至於 MessageChannelsetImmediate 誰快誰慢,這個我不清楚。

microTimerFunc

再是microTimerFunc 的定義是,若是瀏覽器支持原生 Promise 的話,在 下一個microTask 中執行 flushCallbacks

// 若是瀏覽器支持原生 Promise 的話,把 flushCallbacks 放入 microTask 中執行
if (typeof Promise !== 'undefined' && isNative(Promise)) {
    const p = Promise.resolve()
    microTimerFunc = () => {
    p.then(flushCallbacks)
    // 這時爲了處理 iOS microtask 沒有被刷新的bug
    if (isIOS) setTimeout(noop)
    }
} else {
    // 若是沒有Promise,就把 macroTimerFunc 賦值 給 microTimerFunc, 也就是在 `macroTask` 中執行 `flushCallbacks`
    microTimerFunc = macroTimerFunc
}
複製代碼
withMacroTask

withMacroTask 是DOM事件函數的一個包裝器, Vue給DOM添加事件時,會用到它。

這個方法就是爲了解決 Vue 2.4 版本以前 nextTick 的bug。

/** * Wrap a function so that if any code inside triggers state change, * the changes are queued using a (macro) task instead of a microtask. */
export function withMacroTask (fn: Function): Function {
    return fn._withTask || (fn._withTask = function () {
        // 注意這裏,這裏開啓了useMacroTask,
        // 也就是說,若是是經過DOM事件添加的代碼,代碼內就算有nextTick,那nextTick內的代碼也會被強制走 macroTask 方式
        useMacroTask = true
        const res = fn.apply(null, arguments)
        useMacroTask = false
        return res
    })
}

// 在這裏會被調用
// src/platforms/web/runtime/modules/events.js ---- line41
function add ( event: string, handler: Function, once: boolean, capture: boolean, passive: boolean ) {
  handler = withMacroTask(handler)
  if (once) handler = createOnceHandler(handler, event, capture)
  target.addEventListener(
    event,
    handler,
    supportsPassive
      ? { capture, passive }
      : capture
  )
}
複製代碼

至此,Vue nextTick的流程算是分析完了。

這些分析都是我看了源碼和一些文章後的我的理解,若是有誤的話,請道友指出。謝謝。

最後上一段代碼,出自Google 2018GDD大會,歡迎探討並說出緣由。

button.addEventListener('click', () => {
  Promise.resolve().then(() => console.log('microtask 1'))
  console.log('listener 1')
})

button.addEventListener('click', () => {
  Promise.resolve().then(() => console.log('microtask 2'))
  console.log('listener 2')
})

1. 手動點擊,結果是什麼
2. 用測試代碼 button.click() 觸發,結果是什麼
複製代碼

答案在這篇文章

參考並推薦幾篇好文:

event-loop

vue技術內幕

深刻瀏覽器的事件循環 (GDD@2018)

【Vue源碼】Vue中DOM的異步更新策略以及nextTick機制

前端基礎進階(十二):深刻核心,詳解事件循環機制

相關文章
相關標籤/搜索