一文看懂 JavaScript 異步相關(guān)知識

異步 是我們在閱讀技術(shù)文章時(shí)經(jīng)常看到的字眼,那 異步 是什么意思?他重要嗎?要怎么實(shí)現(xiàn)異步呢?本文將試著說明清楚這些事情。

異步 JavaScript 簡介

異步編程技術(shù)使你的程序可以在執(zhí)行一個可能長期運(yùn)行的任務(wù)的同時(shí)繼續(xù)對其他事件做出反應(yīng)而不必等待任務(wù)完成。與此同時(shí),你的程序也將在任務(wù)完成后顯示結(jié)果。

瀏覽器提供的許多功能(尤其是最有趣的那一部分)可能需要很長的時(shí)間來完成,因此需要異步完成,例如:

因此,即使你可能不需要經(jīng)常實(shí)現(xiàn)自己的異步函數(shù),你也很可能需要正確使用它們。

在這部分中,我們將從同步函數(shù)長時(shí)間運(yùn)行時(shí)存在的問題開始,并以此進(jìn)一步認(rèn)識異步編程的必要性。

同步編程

觀察下面的代碼:

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

這段代碼:

  1. 聲明了一個叫做 name 的字符串常量
  2. 聲明了另一個叫做 greeting 的字符串常量(并使用了 name 常量的值)
  3. greeting 常量輸出到 JavaScript 控制臺中。

我們應(yīng)該注意的是,實(shí)際上瀏覽器是按照我們書寫代碼的順序一行一行地執(zhí)行程序的。瀏覽器會等待代碼的解析和工作,在上一行完成后才會執(zhí)行下一行。這樣做是很有必要的,因?yàn)槊恳恍行碌拇a都是建立在前面代碼的基礎(chǔ)之上的。

這也使得它成為一個同步程序。

事實(shí)上,調(diào)用函數(shù)的時(shí)候也是同步的,就像這樣:

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() 就是一個同步函數(shù),因?yàn)樵诤瘮?shù)返回之前,調(diào)用者必須等待函數(shù)完成其工作。

一個耗時(shí)的同步函數(shù)

如果同步函數(shù)需要很長的時(shí)間怎么辦?

當(dāng)用戶點(diǎn)擊“生成素?cái)?shù)”按鈕時(shí),這個程序?qū)⑹褂靡环N非常低效的算法生成一些大素?cái)?shù)。你可以控制要生成的素?cái)?shù)數(shù)量,這也會影響操作需要的時(shí)間。

<label for="quota">素?cái)?shù)個數(shù):</label>
<input type="text" id="quota" name="quota" value="1000000">

<button id="generate">生成素?cái)?shù)</button>
<button id="reload">重載</button>

<div id="output"></div>

function generatePrimes(quota) {
  function isPrime(n) {
    for (let c = 2; c <= Math.sqrt(n); ++c) {
      if (n % c === 0) {
          return false;
       }
    }
    return true;
  }
  const primes = [];
  const maximum = 1000000;
  while (primes.length < quota) {
    const candidate = Math.floor(Math.random() * (maximum + 1));
    if (isPrime(candidate)) {
      primes.push(candidate);
    }
  }
  return primes;
}
document.querySelector('#generate').addEventListener('click', () => {
  const quota = document.querySelector('#quota').value;
  const primes = generatePrimes(quota);
  document.querySelector('#output').textContent = `完成!已生成素?cái)?shù)${quota}個。`;
});
document.querySelector('#reload').addEventListener('click', () => {
  document.location.reload()
});

耗時(shí)同步函數(shù)的問題

接下來的示例和上一個一樣,不過我們增加了一個文本框供你輸入。這一次,試著點(diǎn)擊“生成素?cái)?shù)”,然后在文本框中輸入。

你會發(fā)現(xiàn),當(dāng)我們的 generatePrimes() 函數(shù)運(yùn)行時(shí),我們的程序完全沒有反應(yīng):用戶不能輸入任何東西,也不能點(diǎn)擊任何東西,或做任何其他事情。

這就是耗時(shí)的同步函數(shù)的基本問題。在這里我們想要的是一種方法,以讓我們的程序可以:

  • 通過調(diào)用一個函數(shù)來啟動一個長期運(yùn)行的操作
  • 讓函數(shù)開始操作并立即返回,這樣我們的程序就可以保持對其他事件做出反應(yīng)的能力
  • 當(dāng)操作最終完成時(shí),通知我們操作的結(jié)果。

這就是異步函數(shù)為我們提供的能力,本模塊的其余部分將解釋它們是如何在 JavaScript 中實(shí)現(xiàn)的。

事件處理程序

我們剛才看到的對異步函數(shù)的描述可能會讓你想起事件處理程序,這么想是對的。事件處理程序?qū)嶋H上就是異步編程的一種形式:你提供的函數(shù)(事件處理程序)將在事件發(fā)生時(shí)被調(diào)用(而不是立即被調(diào)用)。如果“事件”是“異步操作已經(jīng)完成”,那么你就可以看到事件如何被用來通知調(diào)用者異步函數(shù)調(diào)用的結(jié)果的。

一些早期的異步 API 正是以這種方式來使用事件的。XMLHttpRequest API 可以讓你用 JavaScript 向遠(yuǎn)程服務(wù)器發(fā)起 HTTP 請求。由于這樣的操作可能需要很長的時(shí)間,所以它被設(shè)計(jì)成異步 API,你可以通過給 XMLHttpRequest 對象附加事件監(jiān)聽器來讓程序在請求進(jìn)展和最終完成時(shí)獲得通知。

下面的例子展示了這樣的操作。點(diǎn)擊“點(diǎn)擊發(fā)起請求”按鈕來發(fā)送一個請求。我們將創(chuàng)建一個新的 XMLHttpRequest 并監(jiān)聽它的 loadend 事件。而我們的事件處理程序則會在控制臺中輸出一個“完成!”的消息和請求的狀態(tài)代碼。

我們在添加了事件監(jiān)聽器后發(fā)送請求。注意,在這之后,我們?nèi)匀豢梢栽诳刂婆_中輸出“請求已發(fā)起”,也就是說,我們的程序可以在請求進(jìn)行的同時(shí)繼續(xù)運(yùn)行,而我們的事件處理程序?qū)⒃谡埱笸瓿蓵r(shí)被調(diào)用。

<button id="xhr">點(diǎn)擊發(fā)起請求</button>
<button id="reload">重載</button>

<pre readonly class="event-log"></pre>

const log = document.querySelector('.event-log');
document.querySelector('#xhr').addEventListener('click', () => {
  log.textContent = '';
  const xhr = new XMLHttpRequest();
  xhr.addEventListener('loadend', () => {
    log.textContent = `${log.textContent}完成!狀態(tài)碼:${xhr.status}`;
  });
  xhr.open('GET', 'https://raw.githubusercontent.com/mdn/content/main/files/en-us/_wikihistory.json');
  xhr.send();
  log.textContent = `${log.textContent}請求已發(fā)起\n`;});
document.querySelector('#reload').addEventListener('click', () => {
  log.textContent = '';
  document.location.reload();
});

這次的事件不是像點(diǎn)擊按鈕那樣的用戶行為,而是某個對象的狀態(tài)變化。

回調(diào)

事件處理程序是一種特殊類型的回調(diào)函數(shù)。而回調(diào)函數(shù)則是一個被傳遞到另一個函數(shù)中的會在適當(dāng)?shù)臅r(shí)候被調(diào)用的函數(shù)。正如我們剛剛所看到的:回調(diào)函數(shù)曾經(jīng)是 JavaScript 中實(shí)現(xiàn)異步函數(shù)的主要方式。

然而,當(dāng)回調(diào)函數(shù)本身需要調(diào)用其他同樣接受回調(diào)函數(shù)的函數(shù)時(shí),基于回調(diào)的代碼會變得難以理解。當(dāng)你需要執(zhí)行一些分解成一系列異步函數(shù)的操作時(shí),這將變得十分常見。例如下面這種情況:

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(`結(jié)果:${result}`);
}
doOperation();

現(xiàn)在我們有一個被分成三步的操作,每一步都依賴于上一步。在這個例子中,第一步給輸入的數(shù)據(jù)加 1,第二步加 2,第三步加 3。從輸入 0 開始,最終結(jié)果是 6(0+1+2+3)。作為同步代碼,這很容易理解。但是如果我們用回調(diào)來實(shí)現(xiàn)這些步驟呢?

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(`結(jié)果:${result3}`);
      });
    });
  });
}
doOperation();

因?yàn)楸仨氃诨卣{(diào)函數(shù)中調(diào)用回調(diào)函數(shù),我們就得到了這個深度嵌套的 doOperation() 函數(shù),這就更難閱讀和調(diào)試了。在一些地方這被稱為“回調(diào)地獄”或“厄運(yùn)金字塔”(因?yàn)榭s進(jìn)看起來像一個金字塔的側(cè)面)。

面對這樣的嵌套回調(diào),處理錯誤也會變得非常困難:你必須在“金字塔”的每一級處理錯誤,而不是在最高一級一次完成錯誤處理。

由于以上這些原因,大多數(shù)現(xiàn)代異步 API 都不使用回調(diào)。事實(shí)上,JavaScript 中異步編程的基礎(chǔ)是 Promise,這也是我們下一章節(jié)要講述的主題。

如何使用 Promise

Promise 是現(xiàn)代 JavaScript 中異步編程的基礎(chǔ),是一個由異步函數(shù)返回的可以向我們指示當(dāng)前操作所處的狀態(tài)的對象。在 Promise 返回給調(diào)用者的時(shí)候,操作往往還沒有完成,但 Promise 對象可以讓我們操作最終完成時(shí)對其進(jìn)行處理(無論成功還是失?。?/p>

在上一章文章中,我們談到使用回調(diào)實(shí)現(xiàn)異步函數(shù)的方法。在這種設(shè)計(jì)中,我們需要在調(diào)用異步函數(shù)的同時(shí)傳入回調(diào)函數(shù)。這個異步函數(shù)會立即返回,并在操作完成后調(diào)用傳入的回調(diào)。

在基于 Promise 的 API 中,異步函數(shù)會啟動操作并返回 Promise 對象。然后,你可以將處理函數(shù)附加到 Promise 對象上,當(dāng)操作完成時(shí)(成功或失?。@些處理函數(shù)將被執(zhí)行。

使用 fetch() API

在這個例子中,我們將從[https://mdn.github.io/learning-area/javascript/apis/can-store/products.json下載 JSON 文件,并記錄一些相關(guān)信息。

在這篇文章中,我們將通過復(fù)制頁面上的代碼示例到瀏覽器的 JavaScript 控制臺中運(yùn)行的方式來學(xué)習(xí) Promise。因此在正式開始學(xué)習(xí)之前你需要進(jìn)行以下設(shè)置:

  1. 在瀏覽器的新標(biāo)簽頁中訪問 https://example.org
  2. 在該標(biāo)簽頁中,打開 瀏覽器開發(fā)者工具 中的 JavaScript 控制臺
  3. 把我們展示的代碼示例復(fù)制到控制臺中運(yùn)行。值得注意的是,你必須在每次輸入新的示例之前重新加載頁面,否則控制臺會報(bào)錯“重新定義了 fetchPromise”。

要做到這一點(diǎn),我們將向服務(wù)器發(fā)出一個 HTTP 請求。在 HTTP 請求中,我們向遠(yuǎn)程服務(wù)器發(fā)送一個請求信息,然后它向我們發(fā)送一個響應(yīng)。這次,我們將發(fā)送一個請求,從服務(wù)器上獲得一個 JSON 文件。還記得在上一篇文章中,我們使用 XMLHttpRequest API 進(jìn)行 HTTP 請求嗎?那么,在這篇文章中,我們將使用 fetch() API,一個現(xiàn)代的、基于 Promise 的、用于替代 XMLHttpRequest 的方法。

把下列代碼復(fù)制到你的瀏覽器 JavaScript 控制臺中:

const fetchPromise = fetch('https://mdn.github.io/learning-area/javascript/apis/can-store/products.json');

console.log(fetchPromise);

fetchPromise.then( response => {
  console.log(`已收到響應(yīng):${response.status}`);
});

console.log("已發(fā)送請求……");

我們在這里:

  1. 調(diào)用 fetch() API,并將返回值賦給 fetchPromise 變量。
  2. 緊接著,輸出 fetchPromise 變量,輸出結(jié)果應(yīng)該像這樣:Promise { <state>: "pending" }。這告訴我們有一個 Promise 對象,它有一個 state屬性,值是 "pending"。"pending" 狀態(tài)意味著操作仍在進(jìn)行中。
  3. 將一個處理函數(shù)傳遞給 Promise 的 then() 方法。當(dāng)(如果)獲取操作成功時(shí),Promise 將調(diào)用我們的處理函數(shù),傳入一個包含服務(wù)器的響應(yīng)的 Response 對象。
  4. 輸出一條信息,說明我們已經(jīng)發(fā)送了這個請求。

完整的輸出結(jié)果應(yīng)該是這樣的:

已發(fā)送請求……
已收到響應(yīng):200

請注意,已發(fā)送請求…… 的消息在我們收到響應(yīng)之前就被輸出了。與同步函數(shù)不同,fetch() 在請求仍在進(jìn)行時(shí)返回,這使我們的程序能夠保持響應(yīng)性。響應(yīng)顯示了 200(OK)的 狀態(tài)碼 。

可能這看起來很像上一篇文章中的例子中我們把事件處理程序添加到 XMLHttpRequest 對象中。但不同的是,我們這一次將處理程序傳遞到返回的 Promise 對象的 then() 方法中。

鏈?zhǔn)绞褂?Promise

在你通過 fetch() API 得到一個 Response 對象的時(shí)候,你需要調(diào)用另一個函數(shù)來獲取響應(yīng)數(shù)據(jù)。這次,我們想獲得JSON格式的響應(yīng)數(shù)據(jù),所以我們會調(diào)用 Response 對象的 json() 方法。事實(shí)上,json() 也是異步的,因此我們必須連續(xù)調(diào)用兩個異步函數(shù)。

試試這個:

const fetchPromise = fetch('https://mdn.github.io/learning-area/javascript/apis/can-store/products.json');

fetchPromise.then( response => {
  const jsonPromise = response.json();
  jsonPromise.then( json => {
    console.log(json[0].name);
  });
});

在這個示例中,就像我們之前做的那樣,我們給 fetch() 返回的 Promise 對象添加了一個 then() 處理程序。但這次我們的處理程序調(diào)用 response.json() 方法,然后將一個新的 then() 處理程序傳遞到 response.json() 返回的 Promise 中。

執(zhí)行代碼后應(yīng)該會輸出 “baked beans”(“products.json”中第一個產(chǎn)品的名稱)。

等等! 還記得上一篇文章嗎?我們好像說過,在回調(diào)中調(diào)用另一個回調(diào)會出現(xiàn)多層嵌套的情況?我們是不是還說過,這種“回調(diào)地獄”使我們的代碼難以理解?這不是也一樣嗎,只不過變成了用 then() 調(diào)用而已?

當(dāng)然如此。但 Promise 的優(yōu)雅之處在于 then() 本身也會返回一個 Promise,這個 Promise 將指示 then() 中調(diào)用的異步函數(shù)的完成狀態(tài)。這意味著我們可以(當(dāng)然也應(yīng)該)把上面的代碼改寫成這樣:

const fetchPromise = fetch('https://mdn.github.io/learning-area/javascript/apis/can-store/products.json');

fetchPromise
  .then( response => {
    return response.json();
  })
  .then( json => {
    console.log(json[0].name);
  });

不必在第一個 then() 的處理程序中調(diào)用第二個 then(),我們可以直接返回 json() 返回的 Promise,并在該返回值上調(diào)用第二個 "then()"。這被稱為 Promise 鏈,意味著當(dāng)我們需要連續(xù)進(jìn)行異步函數(shù)調(diào)用時(shí),我們就可以避免不斷嵌套帶來的縮進(jìn)增加。

在進(jìn)入下一步之前,還有一件事要補(bǔ)充:我們需要在嘗試讀取請求之前檢查服務(wù)器是否接受并處理了該請求。我們將通過檢查響應(yīng)中的狀態(tài)碼來做到這一點(diǎn),如果狀態(tài)碼不是“OK”,就拋出一個錯誤:

const fetchPromise = fetch('https://mdn.github.io/learning-area/javascript/apis/can-store/products.json');

fetchPromise
  .then( response => {
    if (!response.ok) {
      throw new Error(`HTTP error: ${response.status}`);
    }
    return response.json();
  })
  .then( json => {
    console.log(json[0].name);
  });

錯誤捕獲

這給我們帶來了最后一個問題:我們?nèi)绾翁幚礤e誤?fetch() API 可能因?yàn)楹芏嘣驋伋鲥e誤(例如,沒有網(wǎng)絡(luò)連接或 URL 本身存在問題),我們也會在服務(wù)器返回錯誤消息時(shí)拋出一個錯誤。

在上一篇文章中,我們看到在嵌套回調(diào)中進(jìn)行錯誤處理非常困難,我們需要在每一個嵌套層中單獨(dú)捕獲錯誤。

Promise 對象提供了一個 catch() 方法來支持錯誤處理。這很像 then():你調(diào)用它并傳入一個處理函數(shù)。然后,當(dāng)異步操作成功時(shí),傳遞給 then() 的處理函數(shù)被調(diào)用,而當(dāng)異步操作失敗時(shí),傳遞給 catch() 的處理函數(shù)被調(diào)用。

如果將 catch() 添加到 Promise 鏈的末尾,它就可以在任何異步函數(shù)失敗時(shí)被調(diào)用。于是,我們就可以將一個操作實(shí)現(xiàn)為幾個連續(xù)的異步函數(shù)調(diào)用,并在一個地方處理所有錯誤。

試試這個版本的 fetch() 代碼。我們使用 catch() 添加了一個錯誤處理函數(shù),并修改了 URL(這樣請求就會失?。?/p>

const fetchPromise = fetch('bad-scheme://mdn.github.io/learning-area/javascript/apis//can-store/products.json');

fetchPromise
  .then( response => {
    if (!response.ok) {
      throw new Error(`HTTP 請求錯誤:${response.status}`);
    }
    return response.json();
  })
  .then( json => {
    console.log(json[0].name);
  })
  .catch( error => {
    console.error(`無法獲取產(chǎn)品列表:${error}`);
  });

嘗試運(yùn)行這個版本:你應(yīng)該會看到 catch() 處理函數(shù)輸出的錯誤。

Promise 術(shù)語

Promise 中有一些具體的術(shù)語值得我們弄清楚。

首先,Promise 有三種狀態(tài):

  • 待定(pending):初始狀態(tài),既沒有被兌現(xiàn),也沒有被拒絕。這是調(diào)用 fetch() 返回 Promise 時(shí)的狀態(tài),此時(shí)請求還在進(jìn)行中。
  • 已兌現(xiàn)(fulfilled):意味著操作成功完成。當(dāng) Promise 完成時(shí),它的 then() 處理函數(shù)被調(diào)用。
  • 已拒絕(rejected):意味著操作失敗。當(dāng)一個 Promise 失敗時(shí),它的 catch() 處理函數(shù)被調(diào)用。

注意,這里的“成功”或“失敗”的含義取決于所使用的 API:例如,fetch() 認(rèn)為服務(wù)器返回一個錯誤(如 404 Not Found )時(shí)請求成功,但如果網(wǎng)絡(luò)錯誤阻止請求被發(fā)送,則認(rèn)為請求失敗。

有時(shí)我們用 已敲定(settled) 這個詞來同時(shí)表示 已兌現(xiàn)(fulfilled)已拒絕(rejected) 兩種情況。

如果一個 Promise 處于已決議(resolved)狀態(tài),或者它被“鎖定”以跟隨另一個 Promise 的狀態(tài),那么它就是 已兌現(xiàn)(fulfilled)

合并使用多個 Promise

當(dāng)你的操作由幾個異步函數(shù)組成,而且你需要在開始下一個函數(shù)之前完成之前每一個函數(shù)時(shí),你需要的就是 Promise 鏈。但是在其他的一些情況下,你可能需要合并多個異步函數(shù)的調(diào)用,Promise API 為解決這一問題提供了幫助。

有時(shí)你需要所有的 Promise 都得到實(shí)現(xiàn),但它們并不相互依賴。在這種情況下,將它們一起啟動然后在它們?nèi)勘粌冬F(xiàn)后得到通知會更有效率。這里需要 Promise.all() 方法。它接收一個 Promise 數(shù)組,并返回一個單一的 Promise。

還有 57% 的精彩內(nèi)容
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。
支付 ¥14.99 繼續(xù)閱讀

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容