雖然編寫完整的生產級代碼可能需要深入了解 C++ 和 C 等語言,但通常只需對 JavaScript 的基本功能有所了解即可開始編碼。
將回調傳遞給函數或編寫異步代碼等概念通常不難掌握,這使得大多數 JavaScript 開發人員不太關注幕後發生了什麼。他們往往不關心理解語言已經為他們抽象化的複雜性。
作為一名 JavaScript 開發人員,了解幕後真正發生了什麼以及這些抽象的複雜性如何運作變得越來越重要。這有助於我們做出更明智的決策,進而可以顯著提高代碼性能。
本文重點介紹 JavaScript 中一個非常重要但常常被誤解的概念或術語:事件循環!
在 JavaScript 中,編寫異步代碼是不可避免的,但為什麼代碼異步運行究竟意味著什麼?答案就在於事件循環。
在我們了解事件循環如何運作之前,我們必須先了解 JavaScript 本身是什麼以及它的工作原理!
什麼是 JavaScript?
在我們繼續之前,我想讓我們先回到最基本的部分。JavaScript 到底是什麼?我們可以將 JavaScript 定義為:
JavaScript 是一種高級、解釋型、單線程、非阻塞、異步、並發的語言。
等等,這又是什麼?教科書定義?🤔
讓我們分解一下!
本文的關鍵字是單線程、非阻塞、並發和異步。
單線程
執行線程是調度程序可以獨立管理的最小編程指令序列。編程語言是單線程的,這意味著它一次只能執行一個任務或操作。這意味著它將從頭到尾執行整個過程,而不會中斷或暫停線程。
這與多線程語言不同,在多線程語言中,多個進程可以同時在多個線程上運行,而不會相互阻塞。
JavaScript 如何做到同時是單線程和非阻塞的呢?
但是,阻塞是什麼意思?
非阻塞
阻塞沒有統一的定義;它只是簡單地表示線程上運行緩慢的事物。因此,非阻塞意味著線程上的操作不會緩慢。
但是,等等,我不是說過 JavaScript 在單線程上運行嗎?而且我也說過它是非阻塞的,這意味著任務在調用堆棧上運行速度很快?但是,這怎麼可能呢???當我們運行計時器時呢?循環呢?
放輕鬆!我們稍後會揭曉答案 😉。
並發
並發意味著代碼由多個線程同時執行。
好的,事情變得有點詭異了,現在,JavaScript 怎麼可能同時是單線程和並發的呢?也就是說,用多個線程執行代碼?
異步
異步編程意味著代碼在事件循環中運行。當出現阻塞操作時,會觸發事件。阻塞代碼保持運行,而不會阻塞主執行線程。當阻塞代碼完成運行時,它將阻塞操作的結果排隊並將它們推回堆疊。
但是,JavaScript 不是單線程嗎?那麼,是什麼在讓線程中的其他代碼得到執行的同時執行這個阻塞代碼呢?
在我們繼續之前,讓我們回顧一下上面的內容。
- JavaScript 是單線程的。
- JavaScript 是非阻塞的,即緩慢的進程不會阻塞其執行。
- JavaScript 是並發的,即它同時在多個線程中執行代碼。
- JavaScript 是異步的,即它在其他地方運行阻塞代碼。
但是,以上這些並不完全吻合,單線程語言怎麼可能是非阻塞的、並發的、異步的呢?
讓我們深入一點,讓我們深入研究 JavaScript 運行時引擎 V8,也許它有一些我們不知道的隱藏線程。
V8 引擎
V8 引擎是一種高性能的開源 Web 程序集運行時引擎,用於 JavaScript,由 Google 用 C++ 編寫。大多數瀏覽器使用 V8 引擎運行 JavaScript,甚至流行的 Node.js 運行環境也使用它。
簡單來說,V8 是一個 C++ 程序,它接收 JavaScript 代碼、編譯並執行它。
V8 主要做兩件事:
- 堆內存分配
- 調用堆棧執行上下文
遺憾的是,我們的猜測是錯誤的。V8 只有一個調用堆疊,可以將調用堆疊想像成一個線程。
一個線程 === 一個調用堆棧 === 一次執行一個。
圖片 – 黑客中午
既然 V8 只有一個調用堆疊,那麼 JavaScript 如何在不阻塞主執行線程的情況下並發異步運行呢?
讓我們通過編寫一個簡單但常見的異步代碼來嘗試找出並一起分析它。
JavaScript 逐行運行每一行代碼,一個接一個(單線程)。正如預期的那樣,第一行在此處打印到控制台中,但為什麼最後一行打印在超時代碼之前?為什麼執行過程在繼續運行最後一行之前不等待超時代碼(阻塞)?
似乎有其他線程在幫助我們執行這個超時,因為我們非常確定一個線程在任何時間點只能執行一個任務。
讓我們先看看 V8 原始碼 一段時間。
等等…什麼??!!!V8 裡面沒有計時器功能,沒有 DOM?沒有事件?沒有 AJAX 嗎?…是的!
事件、DOM、計時器等不是 JavaScript 核心實現的一部分,JavaScript 嚴格遵守 Ecma Scripts 規範,其各種版本通常根據其 Ecma Scripts Specifications (ES X) 進行引用。
執行工作流程
事件、計時器、Ajax 請求均由瀏覽器在客戶端提供,通常稱為 Web API。它們讓單線程 JavaScript 成為非阻塞的、並發的和異步的!但是,如何做到呢?
任何 JavaScript 程序的執行工作流程都包含三個主要部分:調用堆棧、Web API 和任務隊列。
調用堆棧
堆疊是一種數據結構,其中最後添加的元素總是首先從堆疊中移除,您可以將其視為一疊盤子,其中只有最後添加的盤子才能首先移除。調用堆疊只不過是一種堆疊數據結構,其中任務或代碼正在相應地執行。
讓我們考慮以下範例:
來源 – https://youtu.be/8aGhZQkoFbQ
當您調用函數 `printSquare()` 時,它被壓入調用堆疊,`printSquare()` 函數調用 `square()` 函數。 `square()` 函數被壓入堆疊並調用 `multiply()` 函數。`multiply` 函數被壓入堆疊。由於 `multiply` 函數返回並且是最後被壓入堆疊的東西,因此它首先被解析並從堆疊中刪除,然後是 `square()` 函數,然後是 `printSquare()` 函數。
Web API
這是執行 V8 引擎未處理的代碼的地方,不會「阻塞」主執行線程。當調用堆疊遇到 Web API 函數時,會立即將流程交給 Web API 執行,並在執行期間釋放調用堆疊以執行其他操作。
讓我們回到上面的 `setTimeout` 範例:
當我們執行程式碼時,第一個 `console.log` 行被壓入堆疊,我們幾乎立即得到輸出,在到達超時時,計時器由瀏覽器處理,而不是 V8 核心實現的一部分,它被壓入 Web API,釋放堆疊以便它可以執行其他操作。
當超時仍在運行時,堆疊繼續執行下一行操作並執行最後一個 `console.log`,這解釋了為什麼我們在計時器輸出之前輸出它。一旦計時器完成,就會發生一些事情。然後,計時器中的 `console.log` 再次神奇地出現在調用堆疊中!
如何?
事件循環
在我們討論事件循環之前,讓我們先了解一下任務隊列的功能。
回到我們的超時範例,一旦 Web API 完成任務執行,它不會自動將其推回調用堆疊。它進入任務隊列。
隊列是一種遵循先進先出原則的數據結構,因此當任務被推入隊列時,它們會以相同的順序退出。由 Web API 執行的任務會被推送到任務隊列,然後返回到調用堆疊以打印出它們的結果。
可是,等等。事件循環到底是什麼???
來源 – https://youtu.be/8aGhZQkoFbQ
事件循環是在將回調從任務隊列推送到調用堆疊之前等待調用堆疊被清除的過程。一旦堆疊清空,事件循環就會觸發並檢查任務隊列中是否有可用的回調。如果有,則將其壓入調用堆疊,等待調用堆疊再次清空,並重複相同的過程。
資料來源 – https://www.quora.com/How-does-an-event-loop-work/answer/Timothy-Maxwell
上圖演示了事件循環和任務隊列之間的基本工作流程。
結論
雖然這是一個非常基本的介紹,但 JavaScript 中異步編程的概念提供了足夠的洞察力,可以清楚地了解幕後發生了什麼以及 JavaScript 如何僅通過單個線程並發和異步運行。
JavaScript 總是有需求的,如果您有興趣學習,我建議您看看這個 Udemy 課程。