## 36. JavaScript中的異步編程
> 原文: http://exploringjs.com/impatient-js/ch_async-js.html
>
> 貢獻者:[Kavelaa](https://github.com/Kavelaa)
本章介紹了 JavaScript 中異步編程的基礎。
### 36.1 JavaScript異步編程的路線圖
本節提供了關于JavaScript異步編程內容的路線圖。
> **不必擔心細節!**
>
> 如果你不是每樣東西都理解了,不要擔心。這只是對即將發生的事情的快速一瞥。
#### 36.1.1 同步函數
正常的函數都是 *同步* 的:調用者等待被調用者完成其計算。行A中的`divideSync()`就是一個同步函數的調用:
```js
function main() {
try {
const result = divideSync(12, 3); // (A)
assert.equal(result, 4);
} catch (err) {
assert.fail(err);
}
}
```
#### 36.1.2 JavaScript在單個進程中順序執行任務
默認情況下,JavaScript任務是在單個進程中順序執行的函數。看起來是這樣的:
```js
while (true) {
const task = taskQueue.dequeue();
task(); // run task
}
```
這個循環也稱為 *事件循環* (event loop),因為事件(比如單擊鼠標)會將任務添加到隊列中。
由于這種協作多任務的風格,我們不希望一個任務在等待來自服務器的結果時阻止其他任務的執行。下一小節將探討如何處理這種情況。
#### 36.1.3 基于回調的異步函數
如果`divide()`需要一個服務器來計算它的結果呢?那么結果應該以不同的方式被交付:調用者不應該(同步地)等到結果出來,而應該在結果出來時再(異步地)通知調用者。異步交付結果的一種方法是給`divide()`傳入一個回調函數,用來通知調用者。
```js
function main() {
divideCallback(12, 3,
(err, result) => {
if (err) {
assert.fail(err);
} else {
assert.equal(result, 4);
}
});
}
```
發生異步函數的調用時:
```js
divideCallback(x, y, callback)
```
會進行如下的步驟:
* `divideCallback()`向服務器發送請求
* 然后當前任務`main()`執行結束,其他任務可以執行
* 當來自服務器的響應到達時,它會是:
* 一個錯誤`err`:會把以下任務添加到任務隊列
```js
taskQueue.enqueue(() => callback(err));
```
* 一個結果`r`:會把以下任務添加到任務隊列
```js
taskQueue.enqueue(() => callback(null, r));
```
#### 36.1.4 基于Promise的異步函數
Promises是兩樣東西:
* 一個使回調工作更容易的標準模式
* 異步函數(下一小節的主題)所基于的機制。
調用一個基于Promise的異步函數如下。
```js
function main() {
dividePromise(12, 3)
.then(result => assert.equal(result, 4))
.catch(err => assert.fail(err));
}
```
#### 36.1.5 Async函數
一種看待Async函數的方法是對基于Promise的代碼使用更好的語法:
```js
async function main() {
try {
const result = await dividePromise(12, 3); // (A)
assert.equal(result, 4);
} catch (err) {
assert.fail(err);
}
}
```
我們在第A行調用的`dividePromise()`是與上一節相同的基于Promise的函數。但是現在我們有了處理調用的同步語法。`await`只能在一種特殊的函數中使用,即 *async函數* (注意關鍵字`async`在關鍵字`function`前面)。`await`暫停了當前的async函數并且從中返回值。一旦等待的結果準備好了,函數的執行將從它停止的地方繼續執行。
#### 36.1.6 接下來的步驟
* 在本章中,我們將看到同步函數調用是如何工作的。我們還將探討JavaScript通過 *事件循環* 在單個進程中執行代碼的方法。
* 本章還描述了<a href='#3652'>通過回調實現的異步性</a>。
* 下面的章節將涵蓋[Promises](https://github.com/apachecn/impatient-js-zh/tree/master/docs/45.md)和[async函數](https://github.com/apachecn/impatient-js-zh/tree/master/docs/46.md)。
* 本系列關于異步編程的章節以[關于異步迭代](https://exploringjs.com/impatient-js/ch_async-iteration.html)的章節結束,異步迭代與[同步迭代](https://github.com/apachecn/impatient-js-zh/tree/master/docs/34.md)相似,但是迭代后的值是異步交付的。
### 36.2 調用堆棧
當一個函數調用另一個函數時,我們需要記住后一個函數完成后返回到哪里。這通常是通過一個堆棧完成的,叫做*調用堆棧* :調用者將要返回的位置推給它,被調用者在它完成后跳轉到該位置。
下面是幾個調用發生的例子:
```js
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)`后,堆棧有一個條目:
* Line 12 (location in top-level scope)
在第9行函數調用`g(x + 1)`之后,堆棧有兩個條目:
- Line 10 (location in `f()`)
- Line 12 (location in top-level scope)
在第6行函數調用`h(y + 1)`之后,堆棧有三個條目:
- Line 7 (location in `g()`)
- Line 10 (location in `f()`)
- Line 12 (location in top-level scope)
日志記錄錯誤在第3行,產生以下輸出:
```js
Error:
at h (demos/async-js/stack_trace.mjs:2:17)
at g (demos/async-js/stack_trace.mjs:6:3)
at f (demos/async-js/stack_trace.mjs:9:3)
at demos/async-js/stack_trace.mjs:11:1
```
這就是所謂的`Error`對象創建位置的 *堆棧跟蹤*。注意,它記錄調用的位置,而不是返回的位置。在第2行中創建異常是另一個調用。這就是堆棧跟蹤在`h()`中包含一個位置的原因。
在第3行之后,每個函數都會終止,每次都會從調用堆棧中刪除頂部條目。函數`f`完成后,我們回到頂層作用域,堆棧為空。當代碼片段結束時,就像隱式`return`。如果我們將代碼片段視為正在執行的任務,那么返回空調用堆棧將結束該任務。
### 36.3 事件循環
默認情況下,JavaScript在一個進程中運行——在web瀏覽器和Node.js中都是如此。所謂的 *事件循環* 按順序執行該流程中的 *任務* (代碼段)。事件循環如圖21所示。

? 圖21:任務*源* 向*任務隊列* 添加要運行的代碼,任務隊列將被*事件循環* 清空。
訪問任務隊列的雙方:
* 任務源將任務添加到隊列中。其中一些源代碼同時運行到JavaScript進程。例如,一個任務源負責處理用戶界面事件:如果用戶單擊某個地方,并且注冊了一個單擊偵聽器,則將該偵聽器的調用添加到任務隊列中。
* 事件循環在JavaScript進程中持續運行。在每個循環迭代期間,它從隊列中取出一個任務(如果隊列是空的,它將等待,直到它不是空的)并執行它。當調用堆棧為空且有一個`return`時,則當前任務完成。控件返回到事件循環,然后事件循環從隊列中檢索下一個任務并執行它等等。
下面的JavaScript代碼是一個近似的事件循環:
```js
while (true) {
const task = taskQueue.dequeue();
task(); // run task
}
```
### 36.4 怎么避免阻塞JavaScript進程
#### <a name='3641'>36.4.1 瀏覽器的用戶界面可以被阻塞</a>
瀏覽器的許多用戶界面機制也在JavaScript進程中運行(作為任務)。因此,長時間運行的JavaScript代碼可能會阻塞用戶界面。讓我們看一個演示這一點的web頁面。你可以用兩種方法來嘗試這個頁面:
* 你可以[在線運行](http://rauschma.github.io/async-examples/blocking.html)
* 你可以使用練習在存儲庫中打開以下文件:`demos/async-js/block.html`
下列HTML是頁面的用戶界面:
```html
<a id="block" href="">Block</a>
<div id="statusMessage"></div>
<button>Click me!</button>
```
其思想是單擊“Block”,然后通過JavaScript執行一個長時間運行的循環。
在該循環期間,你不能單擊按鈕,因為瀏覽器/JavaScript進程被阻塞。
JavaScript代碼的簡化版本如下:
```js
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;
}
```
這是幾個代碼的關鍵點:
* 行A:當單擊ID為`block`的HTML元素時,我們告訴瀏覽器調用`doBlock()`。
* `doBlock()`顯示狀態信息,然后調用`sleep()`將JavaScript進程阻塞5000毫秒(行B)。
* `sleep()`通過循環阻塞JavaScript進程,直到經過了足夠的時間才停止。
* `displayStatus()`在ID為statusMessage的`<div>`中顯示狀態信息。
#### 36.4.2 怎么避免阻塞瀏覽器?
有幾種方法可以防止長時間運行的操作阻塞瀏覽器:
* 該操作可以*異步*地交付其結果:有些操作(如下載)可以并發地執行到JavaScript進程。觸發此類操作的JavaScript代碼注冊了一個回調,該回調在操作完成后使用結果調用。調用是通過任務隊列處理的。這種交付結果的方式稱為異步,因為調用者不會等到結果準備好了才進行調用。而普通函數調用會同步地交付它們的結果。
* 在單獨的進程中執行長時間的計算:這可以通過所謂的Web workers來完成。Web workers是與主進程并行運行的重量級進程。它們中的每一個都有自己的運行時環境(全局變量等)。它們是完全孤立的,必須通過消息傳遞進行通信。更多信息請參考[MDN web文檔](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API)。
* 在長時間的計算中休息。下一小節將解釋如何進行。
#### 36.4.3 休息
下面的全局函數在延遲`ms`毫秒后執行參數`callback`(類型簽名被簡化了——`setTimeout()`具有更多特性):
```js
function setTimeout(callback: () => void, ms: number): any
```
函數通過以下全局函數返回一個*句柄*(ID),該句柄可用于*清除* 定時器(取消回調的執行):
```js
function clearTimeout(handle?: any): void
```
`setTimeout()`在瀏覽器和Node.js上都可用。下一小節將展示它的實際應用。
> `setTimeout()`**讓任務能夠暫時停止**
>
> 查看`setTimeout()`的另一種方法是,當前任務暫停,然后通過回調繼續執行。
#### 36.4.4 run-to-completion語義
JavaScript與任務之間約定:每個任務總是在執行下一個任務之前完成(“run to completion”)。
因此,任務在處理數據時不必擔心數據被更改(并發修改)。這簡化了JavaScript的編程。
下面的例子演示了這種保證:
```js
console.log('start');
setTimeout(() => {
console.log('callback');
}, 0);
console.log('end');
// Output:
// 'start'
// 'end'
// 'callback'
```
`setTimeout()`將其參數放入任務隊列。因此,該參數在當前代碼段(任務)完全完成后的某個時候執行。
參數`ms`只指定任務何時放入隊列,而不是確切地何時運行。它甚至可能永遠不會運行;例如,如果隊列中有一個永遠不會終止的任務在它之前。這就解釋了為什么前面的代碼日志`'end'`在`'callback'`之前,即使參數`ms`是`0`。
### 36.5 用于交付異步結果的模式
為了避免在等待長時間運行的操作完成時阻塞主進程,通常使用JavaScript異步地交付結果。以下是三種常用的模式:
* Events(事件)
* Callbacks(回調)
* Promises
前兩個模式將在接下來的兩個小節中進行解釋。Promises將在下一章解釋。
#### 36.5.1 通過事件交付異步結果
事件作為模式工作如下:
* 它們用于異步地交付值。
* 執行零次或多次。
* 在這個模式中有三個角色:
* 事件(對象)攜帶要傳遞的數據。
* 事件監聽器是一個通過參數接收事件的函數。
* 事件源發送事件并允許您注冊事件偵聽器。
在JavaScript世界中存在著這種模式的多種變體。接下來我們將看三個例子。
##### 36.5.1.1 事件:IndexedDB
IndexedDB是一個內置在web瀏覽器中的數據庫。這是一個使用它的例子:
```js
const openRequest = indexedDB.open('MyDatabase', 1); // (A)
openRequest.onsuccess = (event) => {
const db = event.target.result;
// ···
};
openRequest.onerror = (error) => {
console.error(error);
};
```
`indexedDB`有一種調用操作的特殊方式:
* 每個操作都有一個用于創建請求對象的關聯方法。例如,在第A行中,操作是“open”,方法是`.open()`,請求對象是`openRequest`。
* 操作的參數是通過請求對象提供的,而不是通過方法的參數提供的。例如,事件監聽器(函數)存儲在`.onsuccess`和`.onerror`屬性中。
* 操作的調用通過方法(在第A行)添加到任務隊列。也就是說,我們在將操作的調用添加到隊列之后配置該操作。只有run-to-completion語義將我們從這里的競爭條件中拯救出來,并確保在當前代碼片段完成后運行操作。
##### 36.5.1.2 事件:XMLHttpRequest
`XMLHttpRequest`API允許我們在web瀏覽器中進行下載。下面是下載文件`http://example.com/textfile.txt`的方法:
```js
const xhr = new XMLHttpRequest(); // (A)
xhr.open('GET', 'http://example.com/textfile.txt'); // (B)
xhr.onload = () => { // (C)
if (xhr.status == 200) {
processData(xhr.responseText);
} else {
assert.fail(new Error(xhr.statusText));
}
};
xhr.onerror = () => { // (D)
assert.fail(new Error('Network error'));
};
xhr.send(); // (E)
function processData(str) {
assert.equal(str, 'Content of textfile.txt\n');
}
```
使用這個API,我們首先創建一個請求對象(第A行),然后配置它,然后激活它(第E行)。配置包括:
* 指定要使用哪個HTTP請求方法(第B行):`GET`、`POST`、`PUT`等。
* 注冊一個偵聽器(第C行),如果可以下載某些內容,它將收到通知。在偵聽器內部,我們仍然需要確定下載是否包含我們請求的內容,或者是否通知我們有錯誤。注意,有些結果數據是通過請求對象`xhr`交付的。我不支持這種輸入和輸出數據混合在一起的方式。
* 注冊偵聽器(第D行),如果出現網絡錯誤,偵聽器將收到通知。
##### 36.5.1.3 事件:DOM
我們已經在<a href='#3641'>關于阻止瀏覽器 UI </a>的部分中看到DOM事件在起作用。以下代碼也處理`click`事件:
```js
const element = document.getElementById('my-link'); // (A)
element.addEventListener('click', clickListener); // (B)
function clickListener(event) {
event.preventDefault(); // (C)
console.log(event.shiftKey); // (D)
}
```
我們首先要求瀏覽器檢索ID為`'my-link'`的HTML元素(行A)。然后,我們為所有單擊事件添加一個偵聽器(行B)。在監聽器中,我們首先告訴瀏覽器不要執行它的默認操作(第C行)——指向鏈接的目標。然后,如果當前按下shift鍵,我們將登錄到控制臺(第D行)。
#### <a name='3652'>36.5.2 通過回調傳遞異步結果</a>
回調是處理異步結果的另一種模式。它們只用于一次性結果,并且具有比事件更簡潔的優點。
例如,考慮一個函數`readFile()`,它讀取文本文件并異步返回其內容。如果`readFile()`使用Node.js風格的回調函數,這是你如何去調用它:
```js
readFile('some-file.txt', {encoding: 'utf8'},
(error, data) => {
if (error) {
assert.fail(error);
return;
}
assert.equal(data, 'The content of some-file.txt\n');
});
```
有一個回調函數同時處理成功和失敗。如果第一個參數不為`null`,則會發生錯誤。否則,結果可以在第二個參數中找到。
> **練習:基于回調的代碼**
>
> 下面的練習使用異步代碼的測試,這與同步代碼的測試不同。更多信息請參考[有關 mocha](https://exploringjs.com/impatient-js/ch_quizzes-exercises.html#async-tests-ava)中異步測試的部分(在測試章節中)。
>
> * 從同步代碼到基于回調的代碼:`exercises/async-js/read_file_cb_exrc.mjs`
> * 給`.map`實現一個基于回調的版本:`exercises/async-js/map_cb_test.mjs`
### 36.6 異步代碼:缺點
在許多情況下,無論是在瀏覽器上還是在Node.js上,你沒有選擇:你必須使用異步代碼。在本章中,我們已經看到了這類代碼可以使用的幾種模式。它們都有兩個缺點:
* 異步代碼比同步代碼更冗長。
* 如果您調用異步代碼,那么您的代碼也必須成為異步的。這是因為您不能同步地等待異步結果。異步代碼具有傳染性。
第一個缺點隨著Promise(下一章將介紹)變得不那么嚴重,而隨著async函數(再下一章將介紹)基本上消失了。
唉,異步代碼的傳染性并沒有消失。但是,使用async函數可以很容易地在同步和異步之間切換,這一事實緩解了這個問題。
### 36.7 資源
- [“Help, I’m stuck in an event-loop”](https://vimeo.com/96425312) by Philip Roberts (video).
- [“Event loops”](https://www.w3.org/TR/html5/webappapis.html#event-loops), section in HTML5 spec.
- I.背景
- 1.關于本書(ES2019 版)
- 2.常見問題:本書
- 3. JavaScript 的歷史和演變
- 4.常見問題:JavaScript
- II.第一步
- 5.概覽
- 6.語法
- 7.在控制臺上打印信息(console.*)
- 8.斷言 API
- 9.測驗和練習入門
- III.變量和值
- 10.變量和賦值
- 11.值
- 12.運算符
- IV.原始值
- 13.非值undefined和null
- 14.布爾值
- 15.數字
- 16. Math
- 17. Unicode - 簡要介紹(高級)
- 18.字符串
- 19.使用模板字面值和標記模板
- 20.符號
- V.控制流和數據流
- 21.控制流語句
- 22.異常處理
- 23.可調用值
- VI.模塊化
- 24.模塊
- 25.單個對象
- 26.原型鏈和類
- 七.集合
- 27.同步迭代
- 28.數組(Array)
- 29.類型化數組:處理二進制數據(高級)
- 30.映射(Map)
- 31. WeakMaps(WeakMap)
- 32.集(Set)
- 33. WeakSets(WeakSet)
- 34.解構
- 35.同步生成器(高級)
- 八.異步
- 36. JavaScript 中的異步編程
- 37.異步編程的 Promise
- 38.異步函數
- IX.更多標準庫
- 39.正則表達式(RegExp)
- 40.日期(Date)
- 41.創建和解析 JSON(JSON)
- 42.其余章節在哪里?