本章說明 JavaScript 中非同步程式設計的基礎。
本節提供 JavaScript 中非同步程式設計內容的路線圖。
別擔心細節!
如果您還不了解所有內容,請別擔心。這只是對接下來內容的快速瀏覽。
一般函式是同步的:呼叫者會等到被呼叫者完成計算。A 行中的 divideSync()
是同步函式呼叫
function main() {
try {
const result = divideSync(12, 3); // (A)
.equal(result, 4);
assertcatch (err) {
} .fail(err);
assert
} }
預設情況下,JavaScript 任務是在單一程序中依序執行的函式。如下所示
while (true) {
const task = taskQueue.dequeue();
task(); // run task
}
此迴圈也稱為事件迴圈,因為事件(例如按一下滑鼠)會將任務加入佇列。
由於這種協作式多工處理的風格,我們不希望任務在執行時封鎖其他任務,例如等待伺服器傳回的結果。下一個小節探討如何處理此情況。
如果 divide()
需要伺服器來計算其結果,該怎麼辦?那麼結果應以不同的方式傳送:呼叫者不應(同步地)等待結果準備好;它應在結果準備好時(非同步地)收到通知。非同步傳送結果的一種方式是提供一個回呼函式給 divide()
,它用來通知呼叫者。
function main() {
divideCallback(12, 3,
, result) => {
(errif (err) {
.fail(err);
assertelse {
} .equal(result, 4);
assert
};
}) }
當有非同步函式呼叫時
divideCallback(x, y, callback)
接著會發生下列步驟
divideCallback()
向伺服器傳送請求。main()
完成,其他任務可以執行。錯誤 err
:接著下列任務會加入佇列。
.enqueue(() => callback(err)); taskQueue
result
值:接著下列任務會加入佇列。
.enqueue(() => callback(null, result)); taskQueue
承諾有兩件事
呼叫基於承諾的函式如下所示。
function main() {
dividePromise(12, 3)
.then(result => assert.equal(result, 4))
.catch(err => assert.fail(err));
}
一種看待非同步函式的方式是將其視為基於承諾的程式碼的更好語法
async function main() {
try {
const result = await dividePromise(12, 3); // (A)
.equal(result, 4);
assertcatch (err) {
} .fail(err);
assert
} }
我們在 A 行呼叫的 dividePromise()
是與前一節中相同的基於承諾的函式。但我們現在有同步式的語法來處理呼叫。await
只能用在特殊類型的函式內,也就是非同步函式(注意 function
關鍵字前面的 async
關鍵字)。await
會暫停目前的非同步函式並從中傳回。一旦等待的結果準備好,函式的執行就會從中斷處繼續。
每當函式呼叫另一個函式時,我們需要記住後者函式完成後要返回何處。這通常透過堆疊來完成,也就是呼叫堆疊:呼叫者將要返回的位置推入其中,而被呼叫者在完成後會跳到該位置。
這是發生多個呼叫的範例
function h(z) {
const error = new Error();
console.log(error.stack);
}
function g(y) {
h(y + 1);
}
function f(x) {
g(x + 1);
}
f(3);
// done
最初,在執行這段程式碼之前,呼叫堆疊是空的。在第 11 行的函數呼叫 f(3)
之後,堆疊有一個條目
在第 9 行的函數呼叫 g(x + 1)
之後,堆疊有兩個條目
f()
中的位置)在第 6 行的函數呼叫 h(y + 1)
之後,堆疊有三個條目
g()
中的位置)f()
中的位置)在第 3 行記錄 error
,產生以下輸出
DEBUG
Error:
at h (file://demos/async-js/stack_trace.mjs:2:17)
at g (file://demos/async-js/stack_trace.mjs:6:3)
at f (file://demos/async-js/stack_trace.mjs:9:3)
at file://demos/async-js/stack_trace.mjs:11:1
這是所謂的 堆疊追蹤,記錄 Error
物件建立的位置。請注意,它記錄呼叫發生的位置,而不是回傳位置。在第 2 行建立例外狀況是另一個呼叫。這就是為什麼堆疊追蹤包含 h()
內部的位置。
在第 3 行之後,每個函數都會終止,每次呼叫堆疊中的頂層條目都會被移除。在函數 f
完成後,我們回到頂層範圍,堆疊是空的。當程式碼片段結束時,就像一個隱含的 return
。如果我們將程式碼片段視為一個要執行的任務,那麼以空的呼叫堆疊回傳會結束任務。
預設情況下,JavaScript 在單一程序中執行,無論是在網頁瀏覽器或 Node.js 中。所謂的 事件迴圈 會在該程序內依序執行 任務(程式碼片段)。事件迴圈在圖 21 中描述。
有兩個部分會存取任務佇列
任務來源 會將任務新增到佇列。其中一些來源會與 JavaScript 程序並行執行。例如,有一個任務來源會處理使用者介面事件:如果使用者在某個地方按一下,並且註冊了按一下監聽器,那麼就會將該監聽器的呼叫新增到任務佇列。
事件迴圈 會在 JavaScript 程序內持續執行。在每個迴圈反覆運算期間,它會從佇列中取出一個任務(如果佇列是空的,它會等到佇列不為空),然後執行它。當呼叫堆疊為空且有 return
時,該任務就完成了。控制權會回到事件迴圈,然後從佇列中擷取下一個任務並執行它。以此類推。
以下 JavaScript 程式碼是事件迴圈的近似值
while (true) {
const task = taskQueue.dequeue();
task(); // run task
}
瀏覽器的許多使用者介面機制也在 JavaScript 程序中執行(作為任務)。因此,執行時間長的 JavaScript 程式碼可能會阻擋使用者介面。我們來看一個示範該情況的網頁。有兩種方法可以試用該網頁
demos/async-js/blocking.html
以下 HTML 是網頁的使用者介面
<a id="block" href="">Block</a>
<div id="statusMessage"></div>
<button>Click me!</button>
這個想法是您按一下「封鎖」,並透過 JavaScript 執行一個長時間執行的迴圈。在那個迴圈期間,您無法按一下按鈕,因為瀏覽器/JavaScript 程序遭到封鎖。
JavaScript 程式碼的簡化版本如下所示
document.getElementById('block')
.addEventListener('click', doBlock); // (A)
function doBlock(event) {
// ···
displayStatus('Blocking...');
// ···
sleep(5000); // (B)
displayStatus('Done');
}
function sleep(milliseconds) {
const start = Date.now();
while ((Date.now() - start) < milliseconds);
}function displayStatus(status) {
document.getElementById('statusMessage')
.textContent = status;
}
以下是程式碼的主要部分
doBlock()
。doBlock()
顯示狀態資訊,然後呼叫 sleep()
以封鎖 JavaScript 程序 5000 毫秒 (第 B 行)。sleep()
透過迴圈封鎖 JavaScript 程序,直到經過足夠的時間。displayStatus()
在 ID 為 statusMessage
的 <div>
內顯示狀態訊息。有幾種方法可以防止長時間運作封鎖瀏覽器
運作可以非同步地傳遞其結果:某些運作,例如下載,可以與 JavaScript 程序同時執行。觸發此類運作的 JavaScript 程式碼會註冊一個回呼,一旦運作完成,就會使用結果呼叫回呼。呼叫是透過工作佇列處理的。這種傳遞結果的樣式稱為非同步,因為呼叫者不會等到結果準備好。正常的函數呼叫會同步傳遞其結果。
在個別程序中執行長時間運算:這可以使用所謂的網頁工作者來完成。網頁工作者是與主程序同時執行的重量級程序。它們每一個都有自己的執行時間環境 (全域變數等)。它們是完全孤立的,必須透過訊息傳遞進行通訊。請參閱 MDN 網路文件 以取得更多資訊。
在長時間運算期間休息。 下一個小節 說明如何執行。
下列全域函數在延遲 ms
毫秒後執行其參數 callback
(類型簽章已簡化 – setTimeout()
有更多功能)
function setTimeout(callback: () => void, ms: number): any
函數傳回一個控制代碼 (一個 ID),可透過下列全域函數用於清除逾時 (取消回呼執行)
function clearTimeout(handle?: any): void
setTimeout()
在瀏覽器和 Node.js 上都可用。 下一個小節 顯示其作用。
setTimeout()
讓任務暫停
觀察 setTimeout()
的另一種方式是,目前的任務暫停,並透過 callback 在稍後繼續執行。
JavaScript 對任務提供保證
每個任務在執行下一個任務之前,總是會完成(「執行至完成」)。
因此,任務在處理資料時,不用擔心資料會被變更(同時修改)。這簡化了 JavaScript 的程式設計。
以下範例說明了此保證
console.log('start');
setTimeout(() => {
console.log('callback');
, 0);
}console.log('end');
// Output:
// 'start'
// 'end'
// 'callback'
setTimeout()
將其參數放入任務佇列。因此,參數會在目前程式碼(任務)完全完成後執行。
參數 ms
只會指定任務放入佇列的時間,而不是確切的執行時間。甚至可能永遠不會執行,例如,如果佇列中有永遠不會終止的任務。這說明了為什麼先前的程式碼會在 'callback'
之前記錄 'end'
,即使參數 ms
為 0
。
為了避免在等待長時間執行的作業完成時,阻擋主程序,結果通常會在 JavaScript 中非同步傳遞。以下有 3 種常用的模式
前兩種模式會在 接下來的兩個小節 中說明。承諾會在 下一章 中說明。
事件作為模式的運作方式如下
在 JavaScript 的世界中,有許多此模式的不同變化。我們接下來會看三個範例。
IndexedDB 是內建在網頁瀏覽器中的資料庫。以下是使用它的範例
const openRequest = indexedDB.open('MyDatabase', 1); // (A)
.onsuccess = (event) => {
openRequestconst db = event.target.result;
// ···
;
}
.onerror = (error) => {
openRequestconsole.error(error);
; }
indexedDB
有不尋常的作業呼叫方式
每個作業都有關聯的方法來建立要求物件。例如,在 A 行中,作業是「開啟」,方法是 .open()
,而要求物件是 openRequest
。
操作的參數是透過請求物件提供的,而不是透過方法的參數。例如,事件監聽器(函式)儲存在屬性 .onsuccess
和 .onerror
中。
透過方法將操作的呼叫新增到工作佇列(在 A 行)。亦即,我們在將操作的呼叫新增到佇列之後才設定操作。只有執行到完成的語意才能在此保護我們免於競爭條件,並確保操作在目前的程式碼片段完成後執行。
XMLHttpRequest
XMLHttpRequest
API 讓我們可以在網頁瀏覽器中進行下載。這是我們如何下載檔案 http://example.com/textfile.txt
const xhr = new XMLHttpRequest(); // (A)
.open('GET', 'http://example.com/textfile.txt'); // (B)
xhr.onload = () => { // (C)
xhrif (xhr.status == 200) {
processData(xhr.responseText);
else {
} .fail(new Error(xhr.statusText));
assert
};
}.onerror = () => { // (D)
xhr.fail(new Error('Network error'));
assert;
}.send(); // (E)
xhr
function processData(str) {
.equal(str, 'Content of textfile.txt\n');
assert }
使用這個 API,我們首先建立一個請求物件(A 行),然後設定它,再啟用它(E 行)。設定包括
GET
、POST
、PUT
等。xhr
傳遞的。(我不喜歡這種輸入和輸出資料混在一起的方式。)我們已經在 §39.4.1「瀏覽器的使用者介面可能會被阻擋」 中看過 DOM 事件的實際運作。下列程式碼也會處理 click
事件
const element = document.getElementById('my-link'); // (A)
.addEventListener('click', clickListener); // (B)
element
function clickListener(event) {
event.preventDefault(); // (C)
console.log(event.shiftKey); // (D)
}
我們首先要求瀏覽器擷取 ID 為 'my-link'
的 HTML 元素(A 行)。然後我們新增一個監聽器來監聽所有 click
事件(B 行)。在監聽器中,我們首先告訴瀏覽器不要執行它的預設動作(C 行)—前往連結的目標。然後我們記錄到主控台,如果 Shift 鍵目前有被按下(D 行)。
回呼是處理非同步結果的另一種模式。它們只用於一次性的結果,而且比事件的寫法更簡潔。
舉例來說,考慮一個函式 readFile()
,它會讀取一個文字檔並非同步傳回它的內容。如果你使用 Node.js 風格的回呼,以下是呼叫 readFile()
的方式
readFile('some-file.txt', {encoding: 'utf8'},
, data) => {
(errorif (error) {
.fail(error);
assertreturn;
}.equal(data, 'The content of some-file.txt\n');
assert; })
只有一個回呼處理成功和失敗。如果第一個參數不是 null
,則表示發生錯誤。否則,可以在第二個參數中找到結果。
練習:基於回呼的程式碼
下列練習使用非同步程式碼的測試,這與同步程式碼的測試不同。請參閱 §10.3.2「Mocha 中的非同步測試」 以取得更多資訊。
exercises/async-js/read_file_cb_exrc.mjs
.map()
的基於回呼的版本:exercises/async-js/map_cb_test.mjs
在許多情況下,無論是在瀏覽器還是 Node.js 上,你別無選擇,你必須使用非同步程式碼。在本章中,我們已經看到這種程式碼可以使用多種模式。所有這些模式都有兩個缺點
第一個缺點在 Promises(在 下一章 中介紹)中變得不那麼嚴重,並且在 async 函式(在 下下章 中介紹)中幾乎消失。
唉,非同步程式碼的感染性並未消失。但它被 async 函式在同步和非同步之間輕鬆切換的事實所減輕。