[譯] 理解非同步 JavaScript-學習JavaScript是怎麼工作的

H246802發表於2019-01-05

封面照片來自 Unsplash 的作者 Sean Lim

JavaScript 是一種單執行緒程式語言,這意味著同一時間只能完成一件事情。也就是說,JavaScript 引擎只能在單一執行緒中處理一次語句。

單執行緒語言簡化了程式碼編寫,因為你不必擔心併發問題,但這也意味著你無法在不阻塞主執行緒的情況下執行網路請求等長時間操作。

想象一下從 API 中請求一些資料。根據情況,伺服器可能需要一些時間來處理請求,同時阻塞主執行緒,讓網頁無法響應。

這也就是非同步 JavaScript 的美妙之處了。使用非同步 JavaScript(例如回撥,Promise 或者 async/await),你可以執行長時間網路請求同時不會阻塞主執行緒。

雖然您沒有必要將所有這些概念都學會成為一名出色的 JavaScript 開發人員,但瞭解這些對你會很有幫助 :)

所以不用多說了,讓我們開始吧!

同步 JavaScript 如何工作?

在深入研究非同步 JavaScript 之前,讓我們首先了解同步 JavaScript 程式碼在 JavaScript 引擎中的執行情況。例如:

const second = () => {
  console.log('Hello there!');
}
const first = () => {
  console.log('Hi there!');
  second();
  console.log('The End');
}
first();
複製程式碼

要理解上述程式碼在 JavaScript 引擎中的執行方式,我們必須理解執行上下文和呼叫棧(也稱為執行棧)的概念。

執行上下文

執行上下文是評估和執行 JavaScript 程式碼的環境的抽象概念。每當在 JavaScript 中執行任何程式碼時,它都在執行上下文中執行。

函式程式碼在函式執行上下文中執行,全域性程式碼在全域性執行上下文中執行。每個函式都有自己的執行上下文。

呼叫棧

顧名思義,呼叫棧是一個具有 LIFO(後進先出)結構的棧,用於儲存程式碼執行期間建立的所有執行上下文。

JavaScript 有一個單獨的呼叫棧,因為它是一種單執行緒程式語言。呼叫棧具有 LIFO 結構,這意味著只能從呼叫棧頂部新增或刪除元素。

讓我們回到上面的程式碼片段以便嘗試理解程式碼在 JavaScript 引擎中的執行方式。

const second = () => {
  console.log('Hello there!');
}
const first = () => {
  console.log('Hi there!');
  second();
  console.log('The End');
}
first();
複製程式碼

image

上述程式碼的呼叫棧工作情況

這過程發生了什麼呢?

當程式碼執行的時候,會建立一個全域性執行上下文(由 main() 表示)並將其推到執行棧的頂部。當對 first() 函式呼叫時,它會被推送的棧的頂部。

接下來,console.log('Hi there!') 被推到呼叫棧的頂部,當它執行完成後,它會從呼叫棧中彈出。在它之後,我們呼叫 second(),因此 second() 函式被推送到呼叫棧的頂部。

console.log('Hello there!') 被推到呼叫棧頂部並在完成後從呼叫棧中彈出。second() 函式執行完成,接著它從呼叫棧中彈出。

console.log('The End') 被推到呼叫棧頂部並在完成後被刪除。之後,first() 函式執行完成,因此它從呼叫棧中刪除。

程式此時完成其執行,因此從呼叫棧中彈出全域性執行上下文(main())。

非同步 JavaScript 如何工作?

現在我們已經瞭解了相關呼叫棧的基本概念,以及同步 JavaScript 的工作原理,現在讓我們回到非同步 JavaScript。

什麼是阻塞?

假設我們正在以同步方式進行影象處理或網路請求。例如:

const processImage = (image) => {
  /**
  * 對影象進行一些操作
  **/
  console.log('Image processed');
}
const networkRequest = (url) => {
  /**
  * 請求網路資源
  **/
  return someData;
}
const greeting = () => {
  console.log('Hello World');
}
processImage(logo.jpg);
networkRequest('www.somerandomurl.com');
greeting();
複製程式碼

進行影象處理和網路請求都需要時間。因此,當 processImage() 函式呼叫時需要一些時間,具體多少時間根據影象的大小決定。

processImage() 函式完成時,它將從呼叫棧中刪除。之後呼叫 networkRequest() 函式並將其推送到執行棧。同樣,它還需要一些時間才能完成執行。

最後,當 networkRequest() 函式完成時,呼叫 greeting() 函式,因為它只包含 console.log 語句,而 console.log 語句通常很快,所以 greeting() 函式會立即執行並返回。

所以你可以看到,我們必須等到函式(例如 processImage()networkRequest())完成。這也就意味著這些函式阻塞了呼叫棧或主執行緒。因此,在執行上述程式碼時,我們無法執行任何其他操作,這是不理想的。

那麼解決方案是什麼?

最簡單的解決辦法是非同步回撥,我們通常使用非同步回撥來讓程式碼無阻塞。例如:

const networkRequest = () => {
  setTimeout(() => {
    console.log('Async Code');
  }, 2000);
};
console.log('Hello World');
networkRequest();
複製程式碼

這裡我使用了 setTimeout 方法來模擬網路請求。請記住,setTimeout 不是 JavaScript 引擎的一部分,它是 Web APIs(在瀏覽器中)和 C/C++ APIs(在 node.js 中)的一部分。

要了解如何執行此程式碼,我們必須瞭解一些其他概念,例如事件迴圈和回撥佇列(也稱為任務佇列或訊息佇列)。

image

JavaScript 執行時環境概述

事件迴圈Web APIs訊息佇列/任務佇列 不是 JavaScript 引擎的一部分,它是瀏覽器的 JavaScript 執行所處環境或 Nodejs JavaScript 執行所處環境中的一部分(在 Nodejs 的環境下)。在 Nodejs 中,Web APIs 被 C/C++ APIs 取代。

現在讓我們回過頭看看上面的程式碼,看看它是如何以非同步方式執行的。

const networkRequest = () => {
  setTimeout(() => {
    console.log('Async Code');
  }, 2000);
};
console.log('Hello World');
networkRequest();
console.log('The End');
複製程式碼

image
)

Event Loop(事件迴圈)

當上面的程式碼在瀏覽器中執行時,console.log('Hello World') 被推送到棧,在執行完成後從棧中彈出。緊接著,遇到 networkRequest() 的執行,因此將其推送到棧頂部。

接下來呼叫 setTimeout() 函式,因此將其推送到棧頂部。setTimeout() 有兩個引數:1) 回撥和 2) 以毫秒(ms)為單位的時間。

setTimeout() 方法在 Web APIs 環境中啟動 2s 的計時器。此時,setTimeout() 已完成,並從呼叫棧中彈出。在它之後,console.log('The End') 被推送到棧,在執行完成後從呼叫棧中刪除。

同時,計時器已到期,現在回撥函式被推送到訊息佇列。但回撥函式並沒有立即執行,而這就是形成了一個事件迴圈(Event Loop)。

事件迴圈

事件迴圈的作用是檢視呼叫棧並確定呼叫棧是否為空。如果呼叫棧為空,它會檢視訊息佇列以檢視是否有任何掛起的回撥等待執行。

在這個例子中,訊息佇列包含一個回撥,此時呼叫棧為空。因此,事件迴圈(Event Loop)將回撥推送到呼叫棧頂部。

再之後,console.log('Async Code') 被推到棧頂部,執行並從呼叫棧中彈出。此時,回撥函式已完成,因此將其從呼叫棧中刪除,程式最終完成。

DOM 事件

訊息佇列還包含來自 DOM 事件的回撥,例如點選事件和鍵盤事件。

例如:

document.querySelector('.btn').addEventListener('click',(event) => {
  console.log('Button Clicked');
});
複製程式碼

在DOM事件的情況下,事件監聽器位於 Web APIs 環境中等待某個事件(在這種情況下是點選事件)發生,並且當該事件發生時,則回撥函式被放置在等待執行的訊息佇列中。

事件迴圈再次檢查呼叫棧是否為空,如果它為空並且執行了回撥,則將事件回撥推送到呼叫棧。

我們已經知道了如何執行非同步回撥和 DOM 事件,它們使用訊息佇列來儲存等待執行的所有回撥。

ES6 工作佇列/微任務佇列(Job Queue/ Micro-Task queue)

ES6 引入了 Promises 在 JavaScript 中使用的工作佇列/微任務佇列的概念。訊息佇列和微任務佇列之間的區別在於微任務佇列的優先順序高於訊息佇列,這意味著 工作佇列/微任務佇列中的 promise 工作將在訊息佇列內的回撥之前執行。

例如:

console.log('Script start');
setTimeout(() => {
  console.log('setTimeout');
}, 0);
new Promise((resolve, reject) => {
    resolve('Promise resolved');
  }).then(res => console.log(res))
    .catch(err => console.log(err));
console.log('Script End');
複製程式碼

輸出:

Script start
Script End
Promise resolved
setTimeout
複製程式碼

我們可以看到 promise 在 setTimeout 之前執行,因為 promise 響應儲存在微任務佇列中,其優先順序高於訊息佇列。

讓我們再看一個例子,這次有兩個 promise 和兩個 setTimeout。例如:

console.log('Script start');
setTimeout(() => {
  console.log('setTimeout 1');
}, 0);
setTimeout(() => {
  console.log('setTimeout 2');
}, 0);
new Promise((resolve, reject) => {
    resolve('Promise 1 resolved');
  }).then(res => console.log(res))
    .catch(err => console.log(err));
new Promise((resolve, reject) => {
    resolve('Promise 2 resolved');
  }).then(res => console.log(res))
    .catch(err => console.log(err));
console.log('Script End');
複製程式碼

輸出:

Script start
Script End
Promise 1 resolved
Promise 2 resolved
setTimeout 1
setTimeout 2
複製程式碼

我們可以看到兩個 promise 都在 setTimeout 中的回撥之前執行,因為事件迴圈將微任務佇列中的任務優先於訊息佇列中的任務。

當事件迴圈正在執行微任務佇列中的任務時,如果另一個 promise 執行 resolve 方法,那麼它將被新增到同一個微任務佇列的末尾,並且它將在訊息佇列的所有回撥之前執行,無論訊息佇列回撥等待執行花費了多少時間。

例如:

console.log('Script start');
setTimeout(() => {
  console.log('setTimeout');
}, 0);
new Promise((resolve, reject) => {
    resolve('Promise 1 resolved');
  }).then(res => console.log(res));
new Promise((resolve, reject) => {
  resolve('Promise 2 resolved');
  }).then(res => {
       console.log(res);
       return new Promise((resolve, reject) => {
         resolve('Promise 3 resolved');
       })
     }).then(res => console.log(res));
console.log('Script End');
複製程式碼

輸出:

Script start
Script End
Promise 1 resolved
Promise 2 resolved
Promise 3 resolved
setTimeout
複製程式碼

因此,微任務佇列中的所有任務都將在訊息佇列中的任務之前執行。也就是說,事件迴圈將首先在執行訊息佇列中的任何回撥之前清空微任務佇列。

總結

因此,我們已經瞭解了非同步 JavaScript 如何工作以及其他概念,例如呼叫棧,事件迴圈,訊息佇列/任務佇列和工作佇列/微任務佇列,它們共同構成了 JavaScript 執行時環境。雖然您沒有必要將所有這些概念都學習成為一名出色的 JavaScript 開發人員,但瞭解這些概念會很有幫助 :)

譯者注:

文中工作佇列(Job Queue)也就是微任務佇列,而訊息佇列則是指我們通常聊得巨集任務佇列。

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章