從 薛定諤的貓 聊到 Event loop

dendoink發表於2019-04-03

前言

上次我們從高階函式聊到了 promise ,這次我們聊聊:

  • promise A+ 規範和 promise 應用來看 promise 的特性
  • promise 和 eventloop 的關係

從薛定諤的貓(Erwin Schrödinger's Cat)來理解 promise

薛定諤的貓是奧地利著名物理學家薛定諤提出的一個思想實驗,那麼這和 promise 有什麼關係呢?在這個著名的實驗中,假設在盒子裡會有一隻貓,然後我們開啟盒子只會出現兩個結果,貓死了或者是活著:

從 薛定諤的貓 聊到 Event loop

那麼 promise 也類似,根據 promise A+ 規範 當一個 promise 被建立出來以後,它就擁有三種可能狀態 Pending (初始時為 pending)/ Fulfilled / Rejected 如果我們把範圍放寬一點,那麼 Fulfilled / Rejected 又可以被稱為 Settled

從 薛定諤的貓 聊到 Event loop

okay,相信你已經理解了 promise 的三種狀態,那細心同學看到上面有 then()catch() 這樣的方法可能不理解,我們再回到上面貓的例子裡面,現在這個科學家比較變態,在第一次實驗之後,貓出現了兩種狀態,但是他並沒結束實驗,而是針對這兩種情況做了處理並繼續了實驗:

從 薛定諤的貓 聊到 Event loop

與之類似,一個完整的 promise ,在 Pending 狀態發生變化時,只可能是兩種情況,FulfilledRejected,並且我們可以看到箭頭是單向的,意味著這個過程是 不可逆 的。

這意味著,當 Pending 狀態發生了變化,無論是變成 Fulfilled 還是 Rejected 都無法再改變了。

從 薛定諤的貓 聊到 Event loop

針對這兩種情況,我們在 then() 裡面可以傳入兩個回撥函式 onFulfillmentonRejection 作為來處理不同的情況。

從圖中我們可以看到,當 onFulfillment 時,我們通常會做一些非同步的操作,而 onRejection 通常是做錯誤處理。然後我們把當前的 promise 重新返回,直到下次他的 then() 再次被執行。

一個promise.then().then().then() 這樣的方式就是我們 上一篇文章 中所說的 鏈式呼叫

通過 promise 的執行來看特性

通過上一節,我們已知 promise 本身的幾個特性:

  • promise 有三種狀態: Pending (初始時為 pending)/ Fulfilled / Rejected
  • promise 狀態的轉變是不可逆的: Pending -> Fulfilled 或者 Pending -> Rejected
  • promise 支援 then() 的鏈式呼叫。

但是還有一些特性,我們需要從程式碼的角度來分析。

1. 建立後,立即執行

因為 promise 原意為承諾,也就是我預先承諾了將來要達成的一件事情。

所以有同學會認為必須等到承諾兌現,也就是 promise 的狀態從 Pending 變為 Fulfilled 或者 Rejected 時,其建構函式接收的函式才會被執行。

但是實際上,一個 promise 被建立時,即使我們沒有定義 then() ,其建構函式接收的函式也會立即執行:

let p = new Promise((resolve, reject) => {
  console.log('A new promise was created1')
  console.log('A new promise was created2')
  console.log('A new promise was created3')
  setTimeout(() => {
    console.log('log setTimeout')
  }, 3000)
  resolve('success')
})

console.log('log outside')
複製程式碼

輸出結果:

A new promise was created1
A new promise was created2
A new promise was created3
log outside
log setTimeout
複製程式碼

2. 異常處理的方式

根據 promise A+ 規範promisethen() 接收2個引數:

promise.then(onFulfilled, onRejected)
複製程式碼

其中 onFulfilled 執行結束後呼叫,onRejected 拒絕執行後呼叫,看看這段程式碼:

let p = new Promise((resolve, reject) => {
  reject('reject')
  //throw 'error'
})

p.then(
  data => {
    console.log('1:', data)
  },
  reason => {
    console.log('reason:', reason)
  }
)
複製程式碼

最後列印的是:

reason: reject
複製程式碼

可以正常執行不是嗎?但是我們發現實際應用中,我們並沒有這樣來定義 then()

p.then(
  data => {
    console.log('1:', data)
  },
  reason => {
    console.log('reason1:', reason)
  }
).then(
  data => {
    console.log('2:', data)
  },
  reason => {
    console.log('reason2:', reason)
  }
).then(
  data => {
    console.log('3:', data)
  },
  reason => {
    console.log('reason3:', reason)
  }
)
複製程式碼

而是使用 catch() 配合 onFulfilled()

p.then(data => {
  console.log('1:', data)
}).then(data => {
    console.log('2:', data)
  }).then(data => {
    console.log('3:', data)
  }).catch(e => {
      console.log('e2:', e)
    })
複製程式碼

表面上看,達到的效果是一樣的,所以這樣有什麼好處呢?

  1. 減少程式碼量。
  2. onFulfilled() 中如果發生錯誤,也會進行捕獲,不會中斷程式碼的執行。

3. then() 是非同步執行的

看一段程式碼:

let p = new Promise((resolve, reject) => {
  console.log('A new promise was created1')
  console.log('A new promise was created2')
  console.log('A new promise was created3')
  resolve('success')
})
console.log('log outside')

p.then(data => {
  console.log('then:', data)
})
複製程式碼

執行結果:

A new promise was created1
A new promise was created2
A new promise was created3
log outside
then: success
複製程式碼

我們可以很清楚的看到,then() 中列印的內容是在最後的,為什麼會這樣呢?因為 p.then() 中傳入的函式會被推入到 microtasks(非同步任務佇列的一種) 中,而任務佇列都是在執行棧中的程式碼(同步任務)之後處理。

下面這些程式碼都在同步任務中處理:

console.log('A new promise was created1')
console.log('A new promise was created2')
console.log('A new promise was created3')
console.log('log outside')
複製程式碼

okay 看到這裡你可能會有一些問題,例如:

  • 什麼是 同步任務 ?
  • 什麼是 執行棧?
  • 什麼是 microtasks
  • 什麼是 非同步任務佇列

要明白這些,就不得不聊聊 Event loop。

Event loop 是什麼?為什麼我們需要 Event loop?

W3C文件 中我們可以找到關於它的描述:

To coordinate events, user interaction, scripts, rendering, networking, and so forth, user agents must use event loops as described in this section. There are two kinds of event loops: those for browsing contexts, and those for workers.

翻譯一下就是:

客戶端必須使用本章節中所描述的事件迴圈,來協調事件,使用者互動,指令碼,呈現,網路等等。 事件迴圈有兩種:用於瀏覽上下文的事件迴圈和用於 worker 的事件迴圈。

我們寫好一段 JavaScript 程式碼,然後瀏覽器開啟這個頁面,或者在 node 環境中執行它,就可以得到我們期望的結果,但是這段程式碼怎麼執行的呢?

很多同學都知道,是 JavaScript 引擎在執行程式碼,而 JavaScript 引擎都是依託於一個宿主環境的,最通用的 JavaScript 宿主環境是瀏覽器。

這和 EventLoop 有什麼關係呢?

因為宿主環境是瀏覽器,所以 JavaScript 引擎被設計為單執行緒。

為什麼不能是多執行緒呢?舉個例子:加入我們同時兩個執行緒都操作同一個 DOM 元素,那應該如何處理呢?對吧。

okay,既然是單執行緒,意味著我們只能順序執行程式碼,但是如果我們執行某一行特別耗費時間,是不是在這行後面的內容就被阻塞了呢?

所以我們需要在單執行緒的引擎中來實現非同步,而 Event loop 就是實現非同步的關鍵。

Event loop 中的任務佇列 & 巨集任務 & 微任務

首先當一段程式碼給到 JavaScript 引擎的時候,會區分這段程式碼是同步還是非同步:

  • 同步的程式碼進入主執行緒執行
  • 非同步的程式碼加入到任務佇列中,等待主執行緒通知執行

從 薛定諤的貓 聊到 Event loop

非同步的程式碼加入到任務佇列中,而任務佇列又分為 巨集任務佇列(macro tasks)微任務佇列(micro tasks)

一個瀏覽器的上下文環境可能對應有多個巨集任務佇列但是隻有一個微任務佇列。你可能覺得會是這樣:

從 薛定諤的貓 聊到 Event loop

但是實際上,每個巨集任務都包含了一個微任務佇列:

從 薛定諤的貓 聊到 Event loop

那麼問題來了,我們怎麼去判斷這段程式碼要加入到巨集任務佇列,還是微任務佇列中呢?

我們參考下文件 中的解讀:

Each task is defined as coming from a specific task source. All the tasks from one particular task source and destined to a particular event loop

每個任務都由特殊任務源來定義。 來自同一個特殊任務源的所有任務都將發往特定事件迴圈

所以我們可以按照不同的來源進行分類,不同來源的任務都對應到不同的任務佇列中

  • (macro-task 巨集任務)來源:I/O, setTimeout + setInterval + setImmediate, UI renderder ···
  • (micro-task 微任務)來源:Promiseprocess.nextTickMutationObserver, Object.observe ···

從 薛定諤的貓 聊到 Event loop

明白了這些概念之後,我們來看看完整的執行過程。

Event loop 完整的執行過程

下圖參考了 Philip Roberts的演講 PPT同時加深和細化:

從 薛定諤的貓 聊到 Event loop

圖的順序從上往下看:

  1. 程式碼開始執行,JavaScript 引擎對所有的程式碼進行區分。
  2. 同步程式碼被壓入棧中,非同步程式碼根據不同來源加入到巨集任務佇列尾部,或者微任務佇列的尾部。
  3. 等待棧中的程式碼被執行完畢,此時通知任務佇列,執行位於佇列首部的巨集任務。
  4. 巨集任務執行完畢,開始執行其關聯的微任務。
  5. 關聯的微任務執行完畢,繼續執行下一個巨集任務,直到任務佇列中所有巨集任務被執行完畢。
  6. 執行下一個任務佇列。

步驟 3 - 4 - 5 就是一個事件迴圈的基本原理。

最後

不知道這篇文章有沒有讓你充分理解呢?有任何想法和建議,都留下你的評論吧~

小冊 你不知道的 Chrome 除錯技巧 已經開始預售啦。

歡迎關注公眾號 「前端惡霸」,掃碼關注,好貨等著你~

從 薛定諤的貓 聊到 Event loop

相關文章