非同步 JavaScript 簡介

在本文中,我們將解釋什麼是非同步程式設計,為什麼我們需要它,並簡要討論 JavaScript 中非同步函式歷史上的一些實現方式。

預備知識 JavaScript 基礎知識有紮實的理解。
學習成果
  • 熟悉非同步 JavaScript 是什麼,它與同步 JavaScript 有何不同,以及為什麼我們需要它。
  • 什麼是同步程式設計,以及為什麼它有時會成為問題。
  • 非同步程式設計如何旨在解決這些問題。
  • 事件處理程式和回撥函式,以及它們與非同步程式設計的關係。

非同步程式設計是一種技術,它使你的程式能夠啟動一個可能長時間執行的任務,並且在任務執行期間仍然能夠響應其他事件,而無需等待該任務完成。一旦任務完成,程式就會得到結果。

瀏覽器提供的許多函式,尤其是最有趣的那些,可能需要很長時間,因此是非同步的。例如:

因此,即使你可能不經常實現自己的非同步函式,你也很可能需要正確地使用它們。

在本文中,我們將首先探討長時間執行的同步函式帶來的問題,這些問題使得非同步程式設計成為必然。

同步程式設計

考慮以下程式碼:

js
const name = "Miriam";
const greeting = `Hello, my name is ${name}!`;
console.log(greeting);
// "Hello, my name is Miriam!"

這段程式碼:

  1. 宣告一個名為name的字串。
  2. 宣告另一個名為greeting的字串,它使用name
  3. 將問候語輸出到 JavaScript 控制檯。

這裡我們應該注意,瀏覽器實際上是按照我們編寫的順序,一行一行地執行程式的。在每個點上,瀏覽器都會等待該行完成其工作,然後才繼續執行下一行。它必須這樣做,因為每一行都依賴於前一行完成的工作。

這使得它成為一個同步程式。即使我們呼叫一個單獨的函式,它仍然是同步的,例如:

js
function makeGreeting(name) {
  return `Hello, my name is ${name}!`;
}

const name = "Miriam";
const greeting = makeGreeting(name);
console.log(greeting);
// "Hello, my name is Miriam!"

這裡,makeGreeting()是一個同步函式,因為呼叫者必須等待函式完成其工作並返回一個值,然後呼叫者才能繼續。

一個長時間執行的同步函式

如果同步函式需要很長時間怎麼辦?

下面的程式使用一種非常低效的演算法,在使用者點選“生成素數”按鈕時生成多個大素數。使用者指定的素數數量越多,操作所需的時間就越長。

html
<label for="quota">Number of primes:</label>
<input type="text" id="quota" name="quota" value="1000000" />

<button id="generate">Generate primes</button>
<button id="reload">Reload</button>

<div id="output"></div>
js
const MAX_PRIME = 1000000;

function isPrime(n) {
  for (let i = 2; i <= Math.sqrt(n); i++) {
    if (n % i === 0) {
      return false;
    }
  }
  return n > 1;
}

const random = (max) => Math.floor(Math.random() * max);

function generatePrimes(quota) {
  const primes = [];
  while (primes.length < quota) {
    const candidate = random(MAX_PRIME);
    if (isPrime(candidate)) {
      primes.push(candidate);
    }
  }
  return primes;
}

const quota = document.querySelector("#quota");
const output = document.querySelector("#output");

document.querySelector("#generate").addEventListener("click", () => {
  const primes = generatePrimes(quota.value);
  output.textContent = `Finished generating ${quota.value} primes!`;
});

document.querySelector("#reload").addEventListener("click", () => {
  document.location.reload();
});

嘗試點選“生成素數”。根據你的計算機速度,程式可能需要幾秒鐘才能顯示“完成!”訊息。

長時間執行的同步函式的問題

下一個示例與上一個示例類似,只是我們添加了一個文字框供你輸入。這次,點選“生成素數”,然後立即嘗試在文字框中輸入。

你會發現,當我們的generatePrimes()函式執行時,我們的程式完全無響應:你無法輸入任何內容、點選任何內容或執行任何其他操作。

原因是這個 JavaScript 程式是單執行緒的。執行緒是程式遵循的指令序列。由於程式由單個執行緒組成,它一次只能做一件事:所以如果它正在等待我們長時間執行的同步呼叫返回,它就不能做其他任何事情。

我們需要一種方法讓我們的程式能夠:

  1. 透過呼叫函式啟動一個長時間執行的操作。
  2. 讓該函式立即啟動操作並返回,以便我們的程式仍然能夠響應其他事件。
  3. 讓函式以不阻塞主執行緒的方式執行操作,例如透過啟動新執行緒。
  4. 當操作最終完成時,通知我們結果。

這正是非同步函式使我們能夠做到的。本模組的其餘部分將解釋它們如何在 JavaScript 中實現。

事件處理器

我們剛剛看到的非同步函式描述可能會讓你想起事件處理程式,如果是這樣,你就對了。事件處理程式實際上是一種非同步程式設計形式:你提供一個函式(事件處理程式),它不會立即被呼叫,而是在事件發生時被呼叫。如果“事件”是“非同步操作已完成”,那麼該事件可以用來通知呼叫者非同步函式呼叫的結果。

一些早期的非同步 API 正是這樣使用事件的。XMLHttpRequest API 允許你使用 JavaScript 向遠端伺服器發出 HTTP 請求。由於這可能需要很長時間,因此它是一個非同步 API,你可以透過將事件監聽器附加到XMLHttpRequest物件來獲取請求的進度和最終完成的通知。

以下示例展示了它的實際應用。按下“點選開始請求”以傳送請求。我們建立一個新的XMLHttpRequest並監聽其loadend事件。處理程式會記錄一條“完成!”訊息以及狀態碼。

新增事件監聽器後,我們傳送請求。請注意,在此之後,我們可以記錄“已啟動 XHR 請求”:也就是說,我們的程式可以在請求進行時繼續執行,並且當請求完成時,我們的事件處理程式將被呼叫。

html
<button id="xhr">Click to start request</button>
<button id="reload">Reload</button>

<pre readonly class="event-log"></pre>
js
const log = document.querySelector(".event-log");

document.querySelector("#xhr").addEventListener("click", () => {
  log.textContent = "";

  const xhr = new XMLHttpRequest();

  xhr.addEventListener("loadend", () => {
    log.textContent = `${log.textContent}Finished with status: ${xhr.status}`;
  });

  xhr.open(
    "GET",
    "https://raw.githubusercontent.com/mdn/content/main/files/en-us/_wikihistory.json",
  );
  xhr.send();
  log.textContent = `${log.textContent}Started XHR request\n`;
});

document.querySelector("#reload").addEventListener("click", () => {
  log.textContent = "";
  document.location.reload();
});

這與使用者操作(例如使用者點選按鈕)的處理程式一樣,是一個事件處理程式。然而,這次的事件是物件狀態的變化。

回撥

事件處理程式是一種特定型別的回撥。回撥只是一個函式,它被傳遞給另一個函式,並期望在適當的時候被呼叫。正如我們剛剛看到的,回撥曾經是 JavaScript 中實現非同步函式的主要方式。

然而,當回撥本身必須呼叫接受回撥的函式時,基於回撥的程式碼會變得難以理解。如果你需要執行一些分解為一系列非同步函式的操作,這是一個常見的情況。例如,考慮以下內容:

js
function doStep1(init) {
  return init + 1;
}

function doStep2(init) {
  return init + 2;
}

function doStep3(init) {
  return init + 3;
}

function doOperation() {
  let result = 0;
  result = doStep1(result);
  result = doStep2(result);
  result = doStep3(result);
  console.log(`result: ${result}`);
}

doOperation();

這裡我們有一個單一的操作,它被分成三個步驟,每個步驟都依賴於上一步。在我們的示例中,第一步將輸入加 1,第二步加 2,第三步加 3。從輸入 0 開始,最終結果是 6 (0 + 1 + 2 + 3)。作為一個同步程式,這非常簡單。但是如果我們將這些步驟用回撥實現呢?

js
function doStep1(init, callback) {
  const result = init + 1;
  callback(result);
}

function doStep2(init, callback) {
  const result = init + 2;
  callback(result);
}

function doStep3(init, callback) {
  const result = init + 3;
  callback(result);
}

function doOperation() {
  doStep1(0, (result1) => {
    doStep2(result1, (result2) => {
      doStep3(result2, (result3) => {
        console.log(`result: ${result3}`);
      });
    });
  });
}

doOperation();

因為我們必須在回撥內部呼叫回撥,所以我們會得到一個深度巢狀的doOperation()函式,這使得它更難以閱讀和除錯。這有時被稱為“回撥地獄”或“末日金字塔”(因為縮排看起來像一個側向的金字塔)。

當我們這樣巢狀回撥時,處理錯誤也會變得非常困難:通常你必須在“金字塔”的每個級別處理錯誤,而不是隻在頂層處理一次錯誤。

由於這些原因,大多數現代非同步 API 不使用回撥。相反,JavaScript 中非同步程式設計的基礎是Promise,這也是下一篇文章的主題。