24. 非同步程式設計(背景)
目錄
請支持這本書:購買它(PDF、EPUB、MOBI)捐款
(廣告,請不要封鎖。)

24. 非同步程式設計(背景)

本章說明 JavaScript 中非同步程式設計的基礎。它提供 下一章 ES6 Promises 的背景知識。



24.1 JavaScript 呼叫堆疊

當函式 f 呼叫函式 g 時,g 需要知道在完成後要返回到哪裡(在 f 內)。此資訊通常使用堆疊(呼叫堆疊)來管理。我們來看一個範例。

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)

最初,當上述程式開始執行時,呼叫堆疊是空的。在 D 行的函式呼叫 f(3) 之後,堆疊有一個項目

在 C 行的函式呼叫 g(x + 1) 之後,堆疊有兩個項目

在 B 行的函式呼叫 h(y + 1) 之後,堆疊有三個項目

在 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 瀏覽器事件迴圈

簡而言之,每個瀏覽器分頁在單一程序中執行:事件迴圈。此迴圈執行瀏覽器相關事項(稱為工作),這些事項是透過工作佇列提供給迴圈的。工作的範例包括

  1. 剖析 HTML
  2. 執行指令碼元素中的 JavaScript 程式碼
  3. 回應使用者輸入(滑鼠點擊、按鍵等)
  4. 處理非同步網路要求的結果

項目 2-4 是透過瀏覽器內建的引擎執行 JavaScript 程式碼的任務。當程式碼終止時,它們也會終止。然後,可以執行佇列中的下一個任務。以下的圖表(靈感來自 Philip Roberts 的投影片 [1])概述了所有這些機制的連接方式。

事件迴圈周圍有其他平行執行的程序(計時器、輸入處理等)。這些程序透過將任務新增到其佇列與其溝通。

24.2.1 計時器

瀏覽器有 計時器setTimeout() 會建立一個計時器,等到它觸發後,再將一個任務新增到佇列。它的簽章為

setTimeout(callback, ms)

在經過 ms 毫秒後,callback 會被新增到任務佇列。請務必注意,ms 僅指定何時新增回呼,而不是實際執行時間。這可能會晚很多,特別是在事件迴圈被封鎖時(如本章稍後所述)。

ms 設定為零的 setTimeout() 是立即將某個項目新增到任務佇列的常見解決方法。但是,有些瀏覽器不允許 ms 低於最小值(Firefox 中為 4 毫秒);如果是,它們會將其設定為該最小值。

24.2.2 顯示 DOM 變更

對於大多數 DOM 變更(特別是涉及重新配置的變更),顯示不會立即更新。「配置每 16 毫秒發生一次更新勾選」(@bz_moz),並且必須透過事件迴圈獲得執行機會。

有一些方法可以協調頻繁的 DOM 更新與瀏覽器,以避免與其配置節奏發生衝突。請參閱 文件,以取得 requestAnimationFrame() 的詳細資訊。

24.2.3 執行至完成語意

JavaScript 具有所謂的執行至完成語意:目前任務總是在執行下一個任務前完成。這表示每個任務都能完全控制所有目前的狀態,而且不必擔心並發修改。

我們來看一個範例

setTimeout(function () { // (A)
    console.log('Second');
}, 0);
console.log('First'); // (B)

從 A 行開始的函式會立即新增到任務佇列,但只會在目前的程式碼執行完畢後才執行(特別是 B 行!)。這表示此程式碼的輸出將永遠是

First
Second

24.2.4 封鎖事件迴圈

正如我們所見,每個分頁(在某些瀏覽器中,是整個瀏覽器)都由單一程序管理,包括使用者介面和所有其他運算。這表示你可以透過在該程序中執行長時間運算來凍結使用者介面。以下程式碼示範了這一點。

<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>

每當按一下開頭的連結時,就會觸發函式 onClick()。它使用同步的 sleep() 函式來封鎖事件迴圈五秒鐘。在這幾秒鐘內,使用者介面無法運作。例如,你無法按一下「簡單按鈕」。

24.2.5 避免封鎖

你可以透過兩種方式避免封鎖事件迴圈

首先,不要在主程序中執行長時間運算,請將它們移到其他程序。這可以使用 Worker API 來達成。

其次,不要(同步)等待長時間運算(Worker 程序中的自訂演算法、網路要求等)的結果,請繼續執行事件迴圈,並讓運算在完成時通知你。事實上,在瀏覽器中你通常甚至沒有選擇,而且必須這樣做。例如,沒有內建的方法可以同步休眠(例如先前實作的 sleep())。相反地,setTimeout() 讓你非同步休眠。

下一節說明了非同步等待結果的技術。

24.3 非同步接收結果

非同步接收結果的兩種常見模式是:事件和回呼。

24.3.1 透過事件非同步接收結果

在此非同步接收結果的模式中,你為每個要求建立一個物件,並向其註冊事件處理常式:一個用於運算成功,另一個用於處理錯誤。以下程式碼顯示了如何使用 XMLHttpRequest API 執行此操作

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(); // Add request to task queue

請注意,最後一行並未實際執行要求,而是將其新增到工作佇列。因此,你也可以在 open() 之後、設定 onloadonerror 之前立即呼叫該方法。由於 JavaScript 的執行至完成語意,因此事情會以相同的方式運作。

24.3.1.1 隱含要求

瀏覽器 API IndexedDB 具有稍微特殊的事件處理樣式

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()` 來讀取文字檔的內容

// Node.js
fs.readFile('myfile.txt', { encoding: 'utf8' },
    function (error, text) { // (A)
        if (error) {
            // ...
        }
        console.log(text);
    });

如果 `readFile()` 成功,則 A 行中的回呼會透過參數 `text` 接收結果。如果它不是,則回呼會透過其第一個參數取得錯誤(通常是 `Error` 或子建構函式的執行個體)。

經典函式程式風格中的相同程式碼看起來像這樣

// Functional
readFileFunctional('myfile.txt', { encoding: 'utf8' },
    function (text) { // success
        console.log(text);
    },
    function (error) { // failure
        // ...
    });

24.3.3 延續傳遞風格

使用回呼的程式風格(特別是前面顯示的函式方式)也稱為延續傳遞風格 (CPS),因為下一步(延續)被明確地傳遞為參數。這讓呼叫的函式可以更進一步控制接下來發生的事情以及何時發生。

下列程式碼說明 CPS

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');

// Output: A E B D C

function identity(input, callback) {
    setTimeout(function () {
        callback(input);
    }, 0);
}

對於每一步驟,程式的控制流程會在回呼中繼續。這會導致巢狀函式,有時稱為回呼地獄。但是,您通常可以避免巢狀,因為 JavaScript 的函式宣告是提升的(其定義會在它們的範圍開始時評估)。這表示您可以提前呼叫並呼叫程式中稍後定義的函式。下列程式碼使用提升來扁平化前一個範例。

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 的資訊請見 [3].

24.3.4 以 CPS 編寫程式碼

在一般的 JavaScript 風格中,您可以透過以下方式編寫程式碼片段

  1. 將它們一個接一個地排列。這顯而易見,但提醒自己以一般風格串接程式碼是循序編寫是一個好方法。
  2. 陣列方法,例如 map()filter()forEach()
  3. 迴圈,例如 forwhile

函式庫 Async.js 提供組合器,讓您可以在 CPS 中執行類似的事情,並使用 Node.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 的主要優點是其基本機制容易理解。但它也有缺點

Node.js 風格中的回呼函式有三個缺點(與函式風格相比)

24.4 展望未來

下一章節將介紹 Promise 和 ES6 Promise API。Promise 在底層比回呼函式複雜。作為交換,它們帶來幾個重要的優點,並消除了前面提到的回呼函式的大部分缺點。

24.5 進一步閱讀

[1] Philip Roberts 的「救命,我被困在事件迴圈中」(影片)。

[2] HTML 規範中的「事件迴圈」。

[3] Axel Rauschmayer 的「JavaScript 中的非同步程式設計和延續傳遞樣式」。

下一頁:25. 非同步程式設計的 Promise