[TOC]
# 24. 異步編程 (背景知識)
本章介紹了JavaScript中異步編程的基礎。它為下一章ES6 Promise提供背景知識。
## 24.1 JavaScript調用堆棧
當函數`f`調用了函數`g`時,函數`g`需要知道在它完成之后返回到哪里(在函數`f`內部)。這些信息通常是用堆棧來管理的,就是**調用堆棧 call stack**。讓我們來看一個例子:
```js
function h(z) {
// Print stack trace
console.log(new Error().stack); // (A)
}
function g(y) {
h(y + 1); // (B)
}
function f(x) {
g(x + 1); // (C)
}
f(3); // (D)
return; // (E)
```
最初,當上述程序啟動時,這個調用堆棧為空。在`f(3)`D行中的函數調用之后,堆棧有一個條目:
- 全局作用域中的位置
在C行的函數`g(x + 1)`調用之后,堆棧有兩個條目:
- 函數`f`的位置
- 全局作用域中的位置
在B行的函數`h(y + 1)`調用之后,堆棧有三個條目:
- 函數`g`的位置
- 函數`f`的位置
- 全局作用域中的位置
在行A中打印的堆棧跟蹤顯示了調用堆棧的情況:
~~~
Error
at h (stack_trace.js:2:17)
at g (stack_trace.js:6:5)
at f (stack_trace.js:9:5)
at <global> (stack_trace.js:11:1)
~~~
接下來,每個函數都終止并且每次從堆棧中刪除頂部條目。函數f執行結束后,回到全局作用域并且此時的調用堆棧是空的。在行E時,返回并且堆棧為空,此時整個程序結束。
## 24.2 瀏覽器事件循環
簡單來說,每個瀏覽器選項卡運行(在)一個進程中:[事件循環](https://html.spec.whatwg.org/multipage/webappapis.html#event-loop).。這個循環執行與瀏覽器相關的東西(即所謂的任務),它通過一個任務隊列來提供。任務示例如下
1. 解析HTML
2. 在腳本元素中執行JavaScript代碼
3. 響應用戶輸入(鼠標點擊,按鍵等)
4. 處理異步網絡請求的結果
2-4條是通過瀏覽器內置的引擎運行JavaScript代碼的任務。當代碼終止時,它們終止。以下圖(由[Philip Roberts的幻燈片](http://vimeo.com/96425312)啟發))概述了所有這些機制是如何連接的。

事件循環被其他并行運行的進程所包圍(計時器、輸入處理等)。這些進程通過向其隊列添加任務來與之通信。
### 24.2.1 計時器
瀏覽器可以使用`setTimeout()`創建了一個計時器,等到它被觸發時,會被添加到隊列。示例:
```js
setTimeout(callback, ms)
```
等待`ms`毫秒后,函數`callback`被添加到任務隊列中。 重要的是要注意,ms只指定何時**添加**回調,而不是實際執行回調。這可能會發生得比指定的`ms`更遲,特別是如果事件循環被阻塞(如本章后面所述)。
通常的變通方案是把`setTimeout()`的`ms`設置為0,來立刻向任務隊列添加任務。但是,某些瀏覽器不允許`ms`低于瀏覽器的默認最小值(Firefox中為4 ms);`ms`過低,瀏覽器就會自動把它設置為默認的值。
### 24.2.2 DOM的更改
對于大多數DOM更改(特別是那些涉及重新布局的),顯示不會立即更新。“頁面布局每隔16毫秒就會刷新一次”(@[bz_moz](https://twitter.com/bz_moz/status/513777809287028736)),并且必須通過事件循環來運行。
有一些方法可以與瀏覽器協調**頻繁的DOM更新**,以避免與其布局節奏發生沖突。查閱[該文檔](https://developer.mozilla.org/en/docs/Web/API/window.requestAnimationFrame)的`requestAnimationFrame()`詳細信息。
### 24.2.3 運行至完成 的語義
JavaScript有所謂的**運行至完成 **的語義: **當前任務總是在執行下一個任務之前完成**。 這意味著每個任務都對所有當前狀態有完全的控制并且不需要擔心**并發修改**。
該示例:
```js
setTimeout(function () { // (A)
console.log('Second');
}, 0);
console.log('First'); // (B)
```
從行A開始的函數立即被添加到任務隊列中,但是只在當前代碼完成之后才執行(特別是行B!)。這意味著這段代碼的輸出將永遠是:
~~~
First
Second
~~~
### 24.2.4 阻塞事件循環
如我們已經看到的,每個標簽(在一些瀏覽器中,完整的瀏覽器)由單個進程(用戶界面和所有其他計算)進行管理。這意味著您可以通過在該過程中執行長時間運行的計算來凍結用戶界面,下面的代碼演示了這一點:
```js
<a id="block" href="">Block for 5 seconds</a>
<p>
<button>This is a button</button>
<div id="statusMessage"></div>
<script>
document.getElementById('block')
.addEventListener('click', onClick);
function onClick(event) {
event.preventDefault();
setStatusMessage('Blocking...');
// Call setTimeout(), so that browser has time to display
// status message
setTimeout(function () {
sleep(5000);
setStatusMessage('Done');
}, 0);
}
function setStatusMessage(msg) {
document.getElementById('statusMessage').textContent = msg;
}
function sleep(milliseconds) {
var start = Date.now();
while ((Date.now() - start) < milliseconds);
}
</script>
```
[在線運行](http://rauschma.github.io/async-examples/blocking.html)。
每當點擊`Block for 5 seconds`的鏈接時,`onClick()`被觸發調用。它使用 - 同步sleep()功能來阻止事件循環5秒鐘。在這段時間內,用戶界面不起作用。例如,您不能單擊“This is a button”按鈕。
### 24.2.5 避免阻塞
避免以兩種方式阻塞事件循環:
* 首先,在主進程中不執行長時間運行的計算,將它們移動到不同的進程。這可以通過[Worker API](https://developer.mozilla.org/en/docs/Web/API/Worker)實現。
* 其次,您不要(同步)等待長時間運行的計算(在工作進程中的自己的算法,網絡請求等)的結果,您繼續執行事件循環,當計算完成時,讓計算通知您結果。實際上,在瀏覽器中,我們必須要這樣做。例如,沒有內置的同步睡眠方式(如在之前實現的`sleep()`)。相反,`setTimeout()`讓您以異步方式睡眠。
下一節將介紹為異步等待結果的技術。
## 24.3 異步接收結果
異步接收結果的兩種常見模式是:事件和回調。
### 24.3.1 異步結果方式:事件
在這種異步接收結果的模式中,您可以為每個請求創建一個對象,并使用它注冊事件處理程序:一個處理成功,另一個用于處理錯誤。下面的代碼為`XMLHttpRequest`API的示例:
```js
var req = new XMLHttpRequest();
req.open('GET', url);
req.onload = function () {
if (req.status == 200) {
processData(req.response);
} else {
console.log('ERROR', req.statusText);
}
};
req.onerror = function () {
console.log('Network Error');
};
req.send(); // 向任務隊列添加請求
```
請注意,最后一行實際沒有執行請求,它將其添加到任務隊列中。因此,你也可以在`open()`之后、在設置`onload`和`onerror`函數之前調用該方法。由于JavaScript**執行至完成**的語義,代碼還是會如期執行。
#### 24.3.1.1 隱式的請求
瀏覽器API IndexedDB有一種稍微特殊的事件處理風格
```js
var openRequest = indexedDB.open('test', 1);
openRequest.onsuccess = function (event) {
console.log('Success!');
var db = event.target.result;
};
openRequest.onerror = function (error) {
console.log(error);
};
```
首先創建一個請求對象,向其中添加通知結果的事件偵聽器。但是,您不需要顯式排隊請求,這是由`open()`完成的。它是在當前任務完成后執行的。這就是為什么你可以(并且事實上必須)在調用`open()`后注冊事件處理程序。
如果你習慣于多線程編程語言,這種處理請求的風格可能看起來很奇怪,好像它可能容易出現競態條件。但是,由于**執行至完成**的語義,運行總是安全的。
#### 24.3.1.2 單個結果的事件不能正常工作
如果您多次收到結果,那么這種處理異步計算結果的方式是可以的。然而,如果只有一個結果,那么冗長度就成了一個問題。對于這種用例,很流行做法就是使用回調。
### 24.3.2 異步結果方式:回調
通過回調方式處理異步結果,就是將**回調函數作為尾隨參數傳遞給異步函數或方法調用**。
以下是Node.js中的一個示例。我們通過異步`fs.readFile()`調用讀取文本文件的內容:
```js
// Node.js
fs.readFile('myfile.txt', { encoding: 'utf8' },
function (error, text) { // (A)
if (error) {
// ...
}
console.log(text);
});
```
如果`readFile()`成功,則A行中的回調通過參數`text`接收結果。如果失敗了,則回調`Error`的第一個參數會獲得一個錯誤(通常是構造函數 `Error`的實例或子構造函數)
經典的函數式編程風格的代碼是這樣的:
```js
// 函數式
readFileFunctional('myfile.txt', { encoding: 'utf8' },
function (text) { // success
console.log(text);
},
function (error) { // failure
// ...
});
```
### 24.3.3 繼續傳遞風格
使用回調的編程風格(特別是前面所示的函數式方式)也被稱為**繼續傳遞風格**(CPS),因為下一步(續)被顯式地作為參數傳遞。這就給了一個被調用的函數更多地控制接下來發生的事情。
下面的代碼演示了CPS:
```js
console.log('A');
identity('B', function step2(result2) {
console.log(result2);
identity('C', function step3(result3) {
console.log(result3);
});
console.log('D');
});
console.log('E');
// 輸出: A E B D C
function identity(input, callback) {
setTimeout(function () {
callback(input);
}, 0);
}
```
對于每個步驟,程序的控制流程在回調內繼續。這導致嵌套函數,這有時被稱為**回調地獄**。但是,您可以經常避免嵌套,因為JavaScript的函數聲明被提升(它們的定義在其范圍的開始處被編譯器執行解析)。這意味著您可以提前調用并調用程序后面定義的函數。以下代碼使用提升來平坦化前一個示例。
```js
console.log('A');
identity('B', step2);
function step2(result2) {
// The program continues here
console.log(result2);
identity('C', step3);
console.log('D');
}
function step3(result3) {
console.log(result3);
}
console.log('E');
```
有關CPS的[更多信息](http://www.2ality.com/2012/06/continuation-passing-style.html)中給出。
### 24.3.4 用CPS風格編寫代碼
在正常的JavaScript風格中,您可以通過以下方式撰寫代碼片段:
1. 把它們一個接一個地。這很明顯,但提醒自己,以正常風格連接的代碼是順序組合是很好的。
2. 數組方法,例如`map()`,`filter()`和`forEach()`。
3. 循環如`for`和`while`
[Async.js](https://github.com/caolan/async)庫提供了組合器,讓您在 **連續傳遞風格(CPS)** 中使用Node.js風格樣式的回調進行類似的操作。在下面的例子中,它被用于加載三個文件的內容,這些文件的名稱存儲在一個數組中。
```js
var async = require('async');
var fileNames = [ 'foo.txt', 'bar.txt', 'baz.txt' ];
async.map(fileNames,
function (fileName, callback) {
fs.readFile(fileName, { encoding: 'utf8' }, callback);
},
// Process the result
function (error, textArray) {
if (error) {
console.log(error);
return;
}
console.log('TEXTS:\n' + textArray.join('\n----\n'));
});
```
### 24.3.5 回調的優點和缺點
使用回調會導致完全不同的編程風格,CPS。CPS的主要優點是它的基本機制很容易理解。但也有缺點:
* 錯誤處理變得更加復雜:現在有兩種方式通過回調和異常來報告錯誤。你必須小心地把兩者結合起來。
* 不太優雅的簽名:在同步函數中,輸入(參數)和輸出(函數結果)之間存在明顯的關注點分離。在使用回調的異步函數中,這些關注點是混合的:函數結果并不重要,一些參數用于輸入,另一些參數用于輸出。
* 組合更復雜:由于關注的“output”出現在參數中,通過組合器編寫代碼更加復雜。
在Node.js風格樣式中的回調函數有三個缺點(與函數式風格的比較)
- 錯誤處理的if語句增加了冗度.
- 重用錯誤處理程序更加困難.
- 提供一個默認的錯誤處理程序也會更加困難。如果您進行函數調用并且不想編寫自己的處理程序,那么默認的錯誤處理是非常有用的。如果調用者沒有指定處理程序,它也可以被函數使用。
## 24.4 展望未來
下一章涵蓋了Promises和ES6 Promise API。在底層中,Promises 比回調更復雜。作為交換,它們帶來了幾個顯著的優點,并且消除了前面提到的回調的大部分缺點。
## 24.5 延伸閱讀
[1] “[Help, I’m stuck in an event-loop](http://vimeo.com/96425312)” by Philip Roberts (video).
[2] 在HTML規范中的“[Event loops](https://html.spec.whatwg.org/multipage/webappapis.html#event-loops)” 。
[3] “[Asynchronous programming and continuation-passing style in JavaScript](http://www.2ality.com/2012/06/continuation-passing-style.html)” by Axel Rauschmayer.
- 關于本書
- 目錄簡介
- 關于這本書你需要知道的
- 序
- 前言
- I 背景
- 1. About ECMAScript 6 (ES6)
- 2. 常見問題:ECMAScript 6
- 3. 一個JavaScript:在 ECMAScript 6 中避免版本化
- 4. 核心ES6特性
- II 數據
- 5. New number and Math features
- 6. 新的字符串特性
- 7. Symbol
- 8. Template literals
- 第9章 變量與作用域
- 第10章 解構
- 第11章 參數處理
- III 模塊化
- 12. ECMAScript 6中的可調用實體
- 13. 箭頭函數
- 14. 除了類之外的新OOP特性
- 15. 類
- 16. 模塊
- IV 集合
- 17. The for-of loop
- 18. New Array features
- 19. Maps and Sets
- 20. 類型化數組
- 21. 可迭代對象和迭代器
- 22. 生成器( Generator )
- V 標準庫
- 23. 新的正則表達式特性
- 24. 異步編程 (基礎知識)
- 25. 異步編程的Promise
- VI 雜項
- 26. Unicode in ES6
- 27. 尾部調用優化
- 28 用 Proxy 實現元編程
- 29. Coding style tips for ECMAScript 6
- 30. 概述ES6中的新內容
- 注釋
- ES5過時了嗎?
- ==個人筆記==