1. 程式人生 > >瀏覽器事件迴圈機制與Vue nextTick的實現

瀏覽器事件迴圈機制與Vue nextTick的實現

瀏覽器事件迴圈機制

先上一段簡單的程式碼

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

執行結果總是如下:

aa
cc
bb
複製程式碼

為什麼呢?為什麼同樣是非同步,Promise.then 就是 比 setTimeout 先執行呢。

這就涉及到瀏覽器事件迴圈機制了。

  1. 以前瀏覽器只有一類事件迴圈,都是基於當前執行環境上下文, 官方用語叫 browsing-context
    連結在此。我們可以理解為一個window就是一個執行環境上下文,如果有iframe, 那麼iframe內就是另一個執行環境了。
  2. 2017年新版的HTML規範新增了一個事件迴圈,就是web workers。這個暫時先不討論。

事件迴圈機制涉及到兩個知識點 macroTaskmicroTask,一般我們會稱之為巨集任務微任務。不管是macroTask還是microTask,他們都是以一種任務佇列的形式存在。

macroTask

script(整體程式碼), setTimeout, setIntervalsetImmediate(僅IE支援), I/O, UI-rendering

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

microTask

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

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

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

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

不同任務源的任務,可以放到不同任務佇列中。

對上面的幾句話進行總結:事件迴圈只有一個,圍繞著呼叫棧,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

首先檔案頭部,定義了一個觸發回撥的函式

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 函式 定義在檔案的末尾,程式碼如下。注意看我加的註釋。

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) {
      macroTimerFunc()
    } else {
      microTimerFunc()
    }
  }
  // $flow-disable-line
  // 這裡返回一個 Promise, 所以我們可以這樣呼叫,$this.nextTick().then(xxx)
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}
複製程式碼

上面的程式碼的 pending 有點意思, 它是為什麼處理同時呼叫多個 nextTick 的業務場景, 例如

new Vue({
    // 省略
    created() {
        // 執行第一個時 , pending 為 false, 所以會進入 if (!pending),然後 pending 被設為false
        this.$nextTick(fn1);
        // 
        this.$nextTick(fn2);
        this.$nextTick(fn3);
    }
})
複製程式碼

如果是這樣呼叫, 那麼Vue會怎麼做呢,

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

// 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
複製程式碼

接下來,macroTimerFunc 的定義

// 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)
  }
}
複製程式碼

再是microTimerFunc 的定義

// 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
}
複製程式碼

最後上一段程式碼,出自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技術內幕

深入瀏覽器的事件迴圈 ([email protected])

【Vue原始碼】Vue中DOM的非同步更新策略以及nextTick機制

前端基礎進階(十二):深入核心,詳解事件迴圈機制