給急躁的程式設計師的 JavaScript(ES2022 版本)
請支持這本書:購買捐款
(廣告,請勿封鎖。)

39 JavaScript 中的非同步程式設計



本章說明 JavaScript 中非同步程式設計的基礎。

39.1 JavaScript 中非同步程式設計的路線圖

本節提供 JavaScript 中非同步程式設計內容的路線圖。

  別擔心細節!

如果您還不了解所有內容,請別擔心。這只是對接下來內容的快速瀏覽。

39.1.1 同步函式

一般函式是同步的:呼叫者會等到被呼叫者完成計算。A 行中的 divideSync() 是同步函式呼叫

function main() {
  try {
    const result = divideSync(12, 3); // (A)
    assert.equal(result, 4);
  } catch (err) {
    assert.fail(err);
  }
}

39.1.2 JavaScript 在單一程序中依序執行任務

預設情況下,JavaScript 任務是在單一程序中依序執行的函式。如下所示

while (true) {
  const task = taskQueue.dequeue();
  task(); // run task
}

此迴圈也稱為事件迴圈,因為事件(例如按一下滑鼠)會將任務加入佇列。

由於這種協作式多工處理的風格,我們不希望任務在執行時封鎖其他任務,例如等待伺服器傳回的結果。下一個小節探討如何處理此情況。

39.1.3 基於回呼的非同步函式

如果 divide() 需要伺服器來計算其結果,該怎麼辦?那麼結果應以不同的方式傳送:呼叫者不應(同步地)等待結果準備好;它應在結果準備好時(非同步地)收到通知。非同步傳送結果的一種方式是提供一個回呼函式給 divide(),它用來通知呼叫者。

function main() {
  divideCallback(12, 3,
    (err, result) => {
      if (err) {
        assert.fail(err);
      } else {
        assert.equal(result, 4);
      }
    });
}

當有非同步函式呼叫時

divideCallback(x, y, callback)

接著會發生下列步驟

39.1.4 基於承諾的非同步函式

承諾有兩件事

呼叫基於承諾的函式如下所示。

function main() {
  dividePromise(12, 3)
    .then(result => assert.equal(result, 4))
    .catch(err => assert.fail(err));
}

39.1.5 非同步函式

一種看待非同步函式的方式是將其視為基於承諾的程式碼的更好語法

async function main() {
  try {
    const result = await dividePromise(12, 3); // (A)
    assert.equal(result, 4);
  } catch (err) {
    assert.fail(err);
  }
}

我們在 A 行呼叫的 dividePromise() 是與前一節中相同的基於承諾的函式。但我們現在有同步式的語法來處理呼叫。await 只能用在特殊類型的函式內,也就是非同步函式(注意 function 關鍵字前面的 async 關鍵字)。await 會暫停目前的非同步函式並從中傳回。一旦等待的結果準備好,函式的執行就會從中斷處繼續。

39.1.6 下一步

39.2 呼叫堆疊

每當函式呼叫另一個函式時,我們需要記住後者函式完成後要返回何處。這通常透過堆疊來完成,也就是呼叫堆疊:呼叫者將要返回的位置推入其中,而被呼叫者在完成後會跳到該位置。

這是發生多個呼叫的範例

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) 之後,堆疊有兩個條目

在第 6 行的函數呼叫 h(y + 1) 之後,堆疊有三個條目

在第 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。如果我們將程式碼片段視為一個要執行的任務,那麼以空的呼叫堆疊回傳會結束任務。

39.3 事件迴圈

預設情況下,JavaScript 在單一程序中執行,無論是在網頁瀏覽器或 Node.js 中。所謂的 事件迴圈 會在該程序內依序執行 任務(程式碼片段)。事件迴圈在圖 21 中描述。

Figure 21: Task sources add code to run to the task queue, which is emptied by the event loop.

有兩個部分會存取任務佇列

以下 JavaScript 程式碼是事件迴圈的近似值

while (true) {
  const task = taskQueue.dequeue();
  task(); // run task
}

39.4 如何避免阻擋 JavaScript 程序

39.4.1 瀏覽器的使用者介面可能會被阻擋

瀏覽器的許多使用者介面機制也在 JavaScript 程序中執行(作為任務)。因此,執行時間長的 JavaScript 程式碼可能會阻擋使用者介面。我們來看一個示範該情況的網頁。有兩種方法可以試用該網頁

以下 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;
}

以下是程式碼的主要部分

39.4.2 我們如何避免封鎖瀏覽器?

有幾種方法可以防止長時間運作封鎖瀏覽器

39.4.3 休息

下列全域函數在延遲 ms 毫秒後執行其參數 callback (類型簽章已簡化 – setTimeout() 有更多功能)

function setTimeout(callback: () => void, ms: number): any

函數傳回一個控制代碼 (一個 ID),可透過下列全域函數用於清除逾時 (取消回呼執行)

function clearTimeout(handle?: any): void

setTimeout() 在瀏覽器和 Node.js 上都可用。 下一個小節 顯示其作用。

  setTimeout() 讓任務暫停

觀察 setTimeout() 的另一種方式是,目前的任務暫停,並透過 callback 在稍後繼續執行。

39.4.4 執行至完成語意

JavaScript 對任務提供保證

每個任務在執行下一個任務之前,總是會完成(「執行至完成」)。

因此,任務在處理資料時,不用擔心資料會被變更(同時修改)。這簡化了 JavaScript 的程式設計。

以下範例說明了此保證

console.log('start');
setTimeout(() => {
  console.log('callback');
}, 0);
console.log('end');

// Output:
// 'start'
// 'end'
// 'callback'

setTimeout() 將其參數放入任務佇列。因此,參數會在目前程式碼(任務)完全完成後執行。

參數 ms 只會指定任務放入佇列的時間,而不是確切的執行時間。甚至可能永遠不會執行,例如,如果佇列中有永遠不會終止的任務。這說明了為什麼先前的程式碼會在 'callback' 之前記錄 'end',即使參數 ms0

39.5 傳遞非同步結果的模式

為了避免在等待長時間執行的作業完成時,阻擋主程序,結果通常會在 JavaScript 中非同步傳遞。以下有 3 種常用的模式

前兩種模式會在 接下來的兩個小節 中說明。承諾會在 下一章 中說明。

39.5.1 透過事件傳遞非同步結果

事件作為模式的運作方式如下

在 JavaScript 的世界中,有許多此模式的不同變化。我們接下來會看三個範例。

39.5.1.1 事件:IndexedDB

IndexedDB 是內建在網頁瀏覽器中的資料庫。以下是使用它的範例

const openRequest = indexedDB.open('MyDatabase', 1); // (A)

openRequest.onsuccess = (event) => {
  const db = event.target.result;
  // ···
};

openRequest.onerror = (error) => {
  console.error(error);
};

indexedDB 有不尋常的作業呼叫方式

39.5.1.2 事件:XMLHttpRequest

XMLHttpRequest API 讓我們可以在網頁瀏覽器中進行下載。這是我們如何下載檔案 http://example.com/textfile.txt

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 行)。設定包括

39.5.1.3 事件:DOM

我們已經在 §39.4.1「瀏覽器的使用者介面可能會被阻擋」 中看過 DOM 事件的實際運作。下列程式碼也會處理 click 事件

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 行)。然後我們新增一個監聽器來監聽所有 click 事件(B 行)。在監聽器中,我們首先告訴瀏覽器不要執行它的預設動作(C 行)—前往連結的目標。然後我們記錄到主控台,如果 Shift 鍵目前有被按下(D 行)。

39.5.2 透過回呼傳遞非同步結果

回呼是處理非同步結果的另一種模式。它們只用於一次性的結果,而且比事件的寫法更簡潔。

舉例來說,考慮一個函式 readFile(),它會讀取一個文字檔並非同步傳回它的內容。如果你使用 Node.js 風格的回呼,以下是呼叫 readFile() 的方式

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,則表示發生錯誤。否則,可以在第二個參數中找到結果。

  練習:基於回呼的程式碼

下列練習使用非同步程式碼的測試,這與同步程式碼的測試不同。請參閱 §10.3.2「Mocha 中的非同步測試」 以取得更多資訊。

39.6 非同步程式碼:缺點

在許多情況下,無論是在瀏覽器還是 Node.js 上,你別無選擇,你必須使用非同步程式碼。在本章中,我們已經看到這種程式碼可以使用多種模式。所有這些模式都有兩個缺點

第一個缺點在 Promises(在 下一章 中介紹)中變得不那麼嚴重,並且在 async 函式(在 下下章 中介紹)中幾乎消失。

唉,非同步程式碼的感染性並未消失。但它被 async 函式在同步和非同步之間輕鬆切換的事實所減輕。

39.7 資源