非同步 JavaScript 簡介
在本文中,我們將解釋什麼是非同步程式設計,為什麼我們需要它,以及簡要討論 JavaScript 中非同步函式在歷史上的一些實現方式。
| 先決條件 | 對 JavaScript 基礎知識有合理的理解,包括函式和事件處理程式。 |
|---|---|
| 目標 | 熟悉什麼是非同步 JavaScript,它與同步 JavaScript 的區別,以及我們為什麼需要它。 |
非同步程式設計是一種技術,它使您的程式能夠啟動一個可能需要長時間執行的任務,並且仍然能夠在該任務執行時響應其他事件,而不是必須等到該任務完成。一旦該任務完成,您的程式就會收到結果。
瀏覽器提供的許多函式,特別是最有趣的函式,都可能需要很長時間,因此是非同步的。例如
- 使用
fetch()發出 HTTP 請求 - 使用
getUserMedia()訪問使用者的攝像頭或麥克風 - 使用
showOpenFilePicker()提示使用者選擇檔案
因此,即使您可能不必經常實現自己的非同步函式,您也很可能需要正確地使用它們。
在本文中,我們將首先探討長時間執行的同步函式的問題,這使得非同步程式設計成為必要。
同步程式設計
考慮以下程式碼
const name = "Miriam";
const greeting = `Hello, my name is ${name}!`;
console.log(greeting);
// "Hello, my name is Miriam!"
這段程式碼
- 宣告一個名為
name的字串。 - 宣告另一個名為
greeting的字串,它使用name。 - 將問候語輸出到 JavaScript 控制檯。
我們應該在這裡注意,瀏覽器實際上是按順序逐行執行程式,按照我們編寫的順序。在每個點,瀏覽器都會等待該行完成其工作,然後再繼續執行下一行。它必須這樣做,因為每一行都依賴於前面行完成的工作。
這使得它成為一個同步程式。即使我們呼叫一個單獨的函式,它仍然是同步的,例如
function makeGreeting(name) {
return `Hello, my name is ${name}!`;
}
const name = "Miriam";
const greeting = makeGreeting(name);
console.log(greeting);
// "Hello, my name is Miriam!"
在這裡,makeGreeting()是一個同步函式,因為呼叫方必須等待函式完成其工作並返回值,然後呼叫方才能繼續。
長時間執行的同步函式
如果同步函式需要很長時間怎麼辦?
下面的程式使用一個非常低效的演算法,在使用者點選“生成素數”按鈕時生成多個大素數。使用者指定的素數數量越多,操作時間就越長。
<label for="quota">Number of primes:</label>
<input type="text" id="quota" name="quota" value="1000000" />
<button id="generate">Generate primes</button>
<button id="reload">Reload</button>
<div id="output"></div>
const MAX_PRIME = 1000000;
function isPrime(n) {
for (let i = 2; i <= Math.sqrt(n); i++) {
if (n % i === 0) {
return false;
}
}
return n > 1;
}
const random = (max) => Math.floor(Math.random() * max);
function generatePrimes(quota) {
const primes = [];
while (primes.length < quota) {
const candidate = random(MAX_PRIME);
if (isPrime(candidate)) {
primes.push(candidate);
}
}
return primes;
}
const quota = document.querySelector("#quota");
const output = document.querySelector("#output");
document.querySelector("#generate").addEventListener("click", () => {
const primes = generatePrimes(quota.value);
output.textContent = `Finished generating ${quota.value} primes!`;
});
document.querySelector("#reload").addEventListener("click", () => {
document.location.reload();
});
嘗試點選“生成素數”。根據您計算機的速度,程式可能需要幾秒鐘才能顯示“完成!”訊息。
長時間執行的同步函式的問題
下一個示例與上一個示例相同,只是我們添加了一個文字框供您輸入。這次,點選“生成素數”,然後立即嘗試在文字框中輸入內容。
您會發現,當我們的generatePrimes()函式正在執行時,我們的程式完全無響應:您無法輸入任何內容,無法點選任何內容,也無法執行任何其他操作。
出現這種情況的原因是這個 JavaScript 程式是單執行緒的。執行緒是程式遵循的一系列指令。因為程式由單個執行緒組成,所以它一次只能做一件事:因此,如果它正在等待我們長時間執行的同步呼叫返回,它就無法執行任何其他操作。
我們需要一種方法讓我們的程式
- 透過呼叫函式來啟動一個長時間執行的操作。
- 讓該函式啟動操作並立即返回,以便我們的程式仍然能夠響應其他事件。
- 讓該函式以不阻塞主執行緒的方式執行操作,例如啟動一個新執行緒。
- 在操作最終完成後,用操作結果通知我們。
這正是非同步函式使我們能夠做到的。本模組的其餘部分解釋了它們如何在 JavaScript 中實現。
事件處理程式
我們剛剛看到的非同步函式的描述可能會讓您想起事件處理程式,如果確實如此,那您是對的。事件處理程式實際上是非同步程式設計的一種形式:您提供一個函式(事件處理程式),該函式不會立即被呼叫,而是在事件發生時被呼叫。如果“事件”是“非同步操作已完成”,那麼該事件可以用來通知呼叫方非同步函式呼叫的結果。
一些早期的非同步 API 恰好以這種方式使用事件。XMLHttpRequest API 使您能夠使用 JavaScript 向遠端伺服器發出 HTTP 請求。由於這可能需要很長時間,因此它是一個非同步 API,您可以透過向XMLHttpRequest物件附加事件監聽器來獲取有關請求的進度和最終完成的通知。
以下示例演示了這一點。按“點選啟動請求”傳送請求。我們建立一個新的XMLHttpRequest並監聽其loadend事件。處理程式記錄“完成!”訊息以及狀態程式碼。
新增事件監聽器後,我們傳送請求。請注意,在此之後,我們可以記錄“已啟動 XHR 請求”:也就是說,我們的程式可以在請求進行的同時繼續執行,並且當請求完成時,我們的事件處理程式將被呼叫。
<button id="xhr">Click to start request</button>
<button id="reload">Reload</button>
<pre readonly class="event-log"></pre>
const log = document.querySelector(".event-log");
document.querySelector("#xhr").addEventListener("click", () => {
log.textContent = "";
const xhr = new XMLHttpRequest();
xhr.addEventListener("loadend", () => {
log.textContent = `${log.textContent}Finished with status: ${xhr.status}`;
});
xhr.open(
"GET",
"https://raw.githubusercontent.com/mdn/content/main/files/en-us/_wikihistory.json",
);
xhr.send();
log.textContent = `${log.textContent}Started XHR request\n`;
});
document.querySelector("#reload").addEventListener("click", () => {
log.textContent = "";
document.location.reload();
});
這就像我們在前面模組中遇到的事件處理程式一樣,只是事件不是使用者操作(例如使用者點選按鈕),而是某個物件狀態的更改。
回撥函式
事件處理程式是一種特殊的回撥型別。回撥只是一個傳遞給另一個函式的函式,並且預期會在適當的時間呼叫該回調。正如我們剛剛看到的,回撥曾經是 JavaScript 中實現非同步函式的主要方式。
但是,當回撥本身必須呼叫接受回撥的函式時,基於回撥的程式碼可能難以理解。如果您需要執行分解為一系列非同步函式的操作,這種情況很常見。例如,考慮以下情況
function doStep1(init) {
return init + 1;
}
function doStep2(init) {
return init + 2;
}
function doStep3(init) {
return init + 3;
}
function doOperation() {
let result = 0;
result = doStep1(result);
result = doStep2(result);
result = doStep3(result);
console.log(`result: ${result}`);
}
doOperation();
這裡我們有一個操作,它被分成三個步驟,其中每個步驟都依賴於上一步。在我們的示例中,第一步將輸入加 1,第二步加 2,第三步加 3。從輸入 0 開始,最終結果為 6(0 + 1 + 2 + 3)。作為同步程式,這非常簡單。但是,如果我們使用回撥實現這些步驟會怎樣呢?
function doStep1(init, callback) {
const result = init + 1;
callback(result);
}
function doStep2(init, callback) {
const result = init + 2;
callback(result);
}
function doStep3(init, callback) {
const result = init + 3;
callback(result);
}
function doOperation() {
doStep1(0, (result1) => {
doStep2(result1, (result2) => {
doStep3(result2, (result3) => {
console.log(`result: ${result3}`);
});
});
});
}
doOperation();
因為我們必須在回撥中呼叫回撥,所以我們得到了一個巢狀很深的doOperation()函式,這使得閱讀和除錯變得更加困難。這有時被稱為“回撥地獄”或“厄運金字塔”(因為縮排看起來像一個側面的金字塔)。
當我們像這樣巢狀回撥時,處理錯誤也可能變得非常困難:通常您必須在“金字塔”的每個級別處理錯誤,而不是隻在頂層處理一次錯誤。
由於這些原因,大多數現代非同步 API 都不使用回撥。相反,JavaScript 中非同步程式設計的基礎是Promise,這是下一篇文章的主題。