2022 年 2 月 4 日

非同步迭代器和生成器

非同步迭代讓我們得以依需求非同步地反覆處理資料。例如,當我們透過網路分塊下載某項內容時。非同步產生器讓這件事變得更方便。

讓我們先看一個簡單的範例來了解語法,然後再檢視實際的用例。

回顧可迭代物件

讓我們回顧一下可迭代物件的主題。

概念是我們有一個物件,例如這裡的 range

let range = {
  from: 1,
  to: 5
};

…我們希望對它使用 for..of 迴圈,例如 for(value of range),來取得 15 的值。

換句話說,我們希望為物件新增迭代能力

這可以使用一個名稱為 Symbol.iterator 的特殊方法來實作

  • 當迴圈開始時,for..of 建構會呼叫此方法,它應該傳回一個具有 next 方法的物件。
  • 對於每個迭代,都會呼叫 next() 方法以取得下一個值。
  • next() 應該傳回 {done: true/false, value:<loop value>} 形式的值,其中 done:true 表示迴圈結束。

以下是可迭代 range 的實作

let range = {
  from: 1,
  to: 5,

  [Symbol.iterator]() { // called once, in the beginning of for..of
    return {
      current: this.from,
      last: this.to,

      next() { // called every iteration, to get the next value
        if (this.current <= this.last) {
          return { done: false, value: this.current++ };
        } else {
          return { done: true };
        }
      }
    };
  }
};

for(let value of range) {
  alert(value); // 1 then 2, then 3, then 4, then 5
}

如果還有不清楚的地方,請參閱章節 可迭代,它提供了有關一般可迭代的所有詳細資料。

非同步可迭代

當值以非同步方式出現時,需要非同步迭代:在 setTimeout 或其他類型的延遲之後。

最常見的情況是,物件需要發出網路要求才能傳遞下一個值,我們稍後會看到一個實際範例。

要讓物件以非同步方式可迭代

  1. 使用 Symbol.asyncIterator 取代 Symbol.iterator
  2. next() 方法應傳回一個承諾(以下一個值來完成)。
    • async 關鍵字會處理它,我們可以簡單地建立 async next()
  3. 要迭代這樣的物件,我們應使用 for await (let item of iterable) 迴圈。
    • 請注意 await 字詞。

作為一個開始的範例,讓我們建立一個可迭代 range 物件,類似於前一個,但現在它會以非同步方式傳回值,每秒一個。

我們只需要在上述程式碼中執行一些替換

let range = {
  from: 1,
  to: 5,

  [Symbol.asyncIterator]() { // (1)
    return {
      current: this.from,
      last: this.to,

      async next() { // (2)

        // note: we can use "await" inside the async next:
        await new Promise(resolve => setTimeout(resolve, 1000)); // (3)

        if (this.current <= this.last) {
          return { done: false, value: this.current++ };
        } else {
          return { done: true };
        }
      }
    };
  }
};

(async () => {

  for await (let value of range) { // (4)
    alert(value); // 1,2,3,4,5
  }

})()

正如我們所見,結構類似於一般迭代器

  1. 要讓一個物件以非同步方式可迭代,它必須有一個方法 Symbol.asyncIterator (1)
  2. 這個方法必須傳回一個物件,其 next() 方法傳回一個承諾 (2)
  3. next() 方法不必是 async,它可以是一個傳回承諾的一般方法,但 async 允許我們使用 await,因此很方便。這裡我們只延遲一秒 (3)
  4. 要迭代,我們使用 for await(let value of range) (4),即在「for」之後加上「await」。它會呼叫 range[Symbol.asyncIterator]() 一次,然後呼叫其 next() 來取得值。

以下是一個顯示差異的小表格

迭代器 非同步迭代器
提供迭代器的物件方法 Symbol.iterator Symbol.asyncIterator
next() 傳回值是 任何值 承諾
要迴圈,請使用 for..of for await..of
散佈語法 ... 非同步運作

需要一般同步迭代器的功能,無法與非同步迭代器搭配使用。

例如,散佈語法無法運作

alert( [...range] ); // Error, no Symbol.iterator

這是正常的,因為它預期找到 Symbol.iterator,而不是 Symbol.asyncIterator

for..of 也是如此:沒有 await 的語法需要 Symbol.iterator

回顧產生器

現在讓我們回顧一下產生器,因為它們可以讓迭代程式碼更簡短。大部分時間,當我們想要建立一個可迭代物件時,我們會使用產生器。

為了簡潔起見,省略了一些重要的東西,它們是「產生(讓出)值的函式」。它們在章節 產生器 中有詳細說明。

產生器標記為 function*(注意星號),並使用 yield 產生一個值,然後我們可以使用 for..of 迴圈它們。

此範例產生一個從 startend 的值序列

function* generateSequence(start, end) {
  for (let i = start; i <= end; i++) {
    yield i;
  }
}

for(let value of generateSequence(1, 5)) {
  alert(value); // 1, then 2, then 3, then 4, then 5
}

正如我們所知,要建立一個可迭代物件,我們應該新增 Symbol.iterator 到其中。

let range = {
  from: 1,
  to: 5,
  [Symbol.iterator]() {
    return <object with next to make range iterable>
  }
}

Symbol.iterator 的常見做法是回傳一個產生器,它可以縮短程式碼,如您所見

let range = {
  from: 1,
  to: 5,

  *[Symbol.iterator]() { // a shorthand for [Symbol.iterator]: function*()
    for(let value = this.from; value <= this.to; value++) {
      yield value;
    }
  }
};

for(let value of range) {
  alert(value); // 1, then 2, then 3, then 4, then 5
}

如果您想要更多詳細資訊,請參閱章節 產生器

在一般產生器中,我們無法使用 await。所有值都必須同步傳遞,這是 for..of 結構所要求的。

如果我們想要非同步產生值怎麼辦?例如,從網路要求中。

讓我們切換到非同步產生器以實現這個功能。

非同步產生器(最後)

對於大多數實際應用,當我們想要建立一個非同步產生一系列值的物件時,我們可以使用非同步產生器。

語法很簡單:在 function* 前加上 async。這會讓產生器變成非同步。

然後使用 for await (...) 迭代它,如下所示

async function* generateSequence(start, end) {

  for (let i = start; i <= end; i++) {

    // Wow, can use await!
    await new Promise(resolve => setTimeout(resolve, 1000));

    yield i;
  }

}

(async () => {

  let generator = generateSequence(1, 5);
  for await (let value of generator) {
    alert(value); // 1, then 2, then 3, then 4, then 5 (with delay between)
  }

})();

由於產生器是異步的,我們可以在其中使用await,依賴承諾,執行網路請求等等。

內部差異

技術上來說,如果您是一位進階讀者,記得有關產生器的詳細資訊,那麼內部會有些差異。

對於非同步產生器,generator.next()方法是非同步的,它會傳回承諾。

在一般產生器中,我們會使用result = generator.next()來取得值。在非同步產生器中,我們應該加入await,如下所示

result = await generator.next(); // result = {value: ..., done: true/false}

這就是為什麼非同步產生器會使用for await...of

非同步可迭代範圍

一般產生器可以用作Symbol.iterator,以縮短迭代程式碼。

類似地,非同步產生器可以用作Symbol.asyncIterator來實作非同步迭代。

例如,我們可以讓range物件非同步產生值,每秒一次,方法是將同步的Symbol.iterator替換為非同步的Symbol.asyncIterator

let range = {
  from: 1,
  to: 5,

  // this line is same as [Symbol.asyncIterator]: async function*() {
  async *[Symbol.asyncIterator]() {
    for(let value = this.from; value <= this.to; value++) {

      // make a pause between values, wait for something
      await new Promise(resolve => setTimeout(resolve, 1000));

      yield value;
    }
  }
};

(async () => {

  for await (let value of range) {
    alert(value); // 1, then 2, then 3, then 4, then 5
  }

})();

現在值之間會延遲 1 秒。

請注意

技術上來說,我們可以將Symbol.iteratorSymbol.asyncIterator都新增到物件,因此它既可以同步(for..of)又可以非同步(for await..of)迭代。

不過,在實務上,那會是一件很奇怪的事。

實際範例:分頁資料

到目前為止,我們已經看過一些基本範例,以加深了解。現在讓我們檢視一個實際的用例。

有許多線上服務會提供分頁資料。例如,當我們需要使用者清單時,要求會傳回預先定義的數量(例如 100 位使用者)–「一頁」,並提供下一個頁面的網址。

這種模式非常常見。它不只適用於使用者,而是適用於任何事物。

例如,GitHub 允許我們以相同的、分頁的方式擷取提交記錄

  • 我們應該以https://api.github.com/repos/<repo>/commits格式向fetch提出要求。
  • 它會以 30 筆提交記錄的 JSON 檔回應,並在Link標頭中提供下一個頁面的連結。
  • 然後,我們可以使用該連結進行下一個要求,以取得更多提交記錄,依此類推。

對於我們的程式碼,我們希望有一個更簡單的方法來取得提交記錄。

讓我們建立一個函式fetchCommits(repo),它會為我們取得提交記錄,並在需要時提出要求。讓它處理所有分頁相關的事項。對我們來說,這只會是一個簡單的非同步迭代for await..of

因此,用法如下

for await (let commit of fetchCommits("username/repository")) {
  // process commit
}

以下是一個以非同步產生器實作的函式

async function* fetchCommits(repo) {
  let url = `https://api.github.com/repos/${repo}/commits`;

  while (url) {
    const response = await fetch(url, { // (1)
      headers: {'User-Agent': 'Our script'}, // github needs any user-agent header
    });

    const body = await response.json(); // (2) response is JSON (array of commits)

    // (3) the URL of the next page is in the headers, extract it
    let nextPage = response.headers.get('Link').match(/<(.*?)>; rel="next"/);
    nextPage = nextPage?.[1];

    url = nextPage;

    for(let commit of body) { // (4) yield commits one by one, until the page ends
      yield commit;
    }
  }
}

更多關於其運作方式的說明

  1. 我們使用瀏覽器的 fetch 方法來下載提交。

    • 初始 URL 為 https://api.github.com/repos/<repo>/commits,而下一頁將會在回應的 Link 標頭中。
    • fetch 方法允許我們提供授權和其他標頭(如果需要的話)——在此處 GitHub 需要 User-Agent
  2. 提交以 JSON 格式傳回。

  3. 我們應該從回應的 Link 標頭取得下一頁的 URL。它有特殊格式,因此我們使用正規表示法(我們將在 正規表示法 中學習此功能)。

    • 下一頁的 URL 可能看起來像 https://api.github.com/repositories/93253246/commits?page=2。它是由 GitHub 本身產生的。
  4. 然後我們逐一產生接收到的提交,而當它們完成時,下一個 while(url) 迭代將觸發,執行更多請求。

使用範例(在主控台中顯示提交作者)

(async () => {

  let count = 0;

  for await (const commit of fetchCommits('javascript-tutorial/en.javascript.info')) {

    console.log(commit.author.login);

    if (++count == 100) { // let's stop at 100 commits
      break;
    }
  }

})();

// Note: If you are running this in an external sandbox, you'll need to paste here the function fetchCommits described above

這正是我們想要的。

分頁請求的內部機制對外部來說是不可見的。對我們來說,它只是一個傳回提交的非同步產生器。

摘要

常規迭代器和產生器適用於不會花費時間產生的資料。

當我們預期資料會非同步地延遲到來時,可以使用它們的非同步對應項目,並使用 for await..of 取代 for..of

非同步迭代器和常規迭代器之間的語法差異

可迭代 非同步可迭代
提供迭代器的函數 Symbol.iterator Symbol.asyncIterator
next() 傳回值是 {value:…, done: true/false} Promise 解析為 {value:…, done: true/false}

非同步產生器和常規產生器之間的語法差異

產生器 非同步產生器
宣告 function* async function*
next() 傳回值是 {value:…, done: true/false} Promise 解析為 {value:…, done: true/false}

在網頁開發中,我們經常會遇到資料串流,當它一塊一塊地流動時。例如,下載或上傳一個大檔案。

我們可以使用非同步產生器來處理此類資料。值得注意的是,在某些環境中,例如在瀏覽器中,還有一個稱為串流的 API,它提供了特殊介面來處理此類串流,以轉換資料並將其從一個串流傳遞到另一個串流(例如從一個地方下載並立即傳送到其他地方)。

教學課程地圖

留言

留言前請先閱讀…
  • 如果你有建議要如何改進 - 請 提交 GitHub 問題 或發起拉取請求,而不是留言。
  • 如果你無法理解文章中的某個部分 - 請詳細說明。
  • 若要插入少數幾個字的程式碼,請使用 <code> 標籤,對於多行程式碼 - 請用 <pre> 標籤將其包起來,對於超過 10 行的程式碼 - 請使用沙盒 (plnkrjsbincodepen…)