Javascript 異步迭代和 generator

2023-02-17 10:53 更新

異步迭代允許我們對按需通過異步請求而得到的數據進行迭代。例如,我們通過網絡分段(chunk-by-chunk)下載數據時。異步生成器(generator)使這一步驟更加方便。

首先,讓我們來看一個簡單的示例以掌握語法,然后再看一個實際用例。

回顧可迭代對象

讓我們回顧一下可迭代對象的相關內容。

假設我們有一個對象,例如下面的 range

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

我們想對它使用 for..of 循環(huán),例如 for(value of range),來獲取從 1 到 5 的值。

換句話說,我們想向對象 range 添加 迭代能力。

這可以通過使用一個名為 Symbol.iterator 的特殊方法來實現:

  • 當循環(huán)開始時,該方法被 ?for..of? 結構調用,并且它應該返回一個帶有 ?next? 方法的對象。
  • 對于每次迭代,都會為下一個值調用 ?next()? 方法。
  • ?next()? 方法應該以 ?{done: true/false, value:<loop value>}? 的格式返回一個值,其中 ?done:true? 表示循環(huán)結束。

這是可迭代的 range 的一個實現:

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

  [Symbol.iterator]() { // 在 for..of 循環(huán)開始時被調用一次
    return {
      current: this.from,
      last: this.to,

      next() { // 每次迭代時都會被調用,來獲取下一個值
        if (this.current <= this.last) {
          return { done: false, value: this.current++ };
        } else {
          return { done: true };
        }
      }
    };
  }
};

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

如果有任何不清楚的,你可以閱讀 Iterable object(可迭代對象) 一章,其中詳細講解了關于常規(guī)迭代器(iterator)的所有內容。

異步可迭代對象

當值是以異步的形式出現時,例如在 setTimeout 或者另一種延遲之后,就需要異步迭代。

最常見的場景是,對象需要發(fā)送一個網絡請求以傳遞下一個值,稍后我們將看到一個它的真實示例。

要使對象異步迭代:

  1. 使用 ?Symbol.asyncIterator? 取代 ?Symbol.iterator?。
  2. ?next()? 方法應該返回一個 ?promise?(帶有下一個值,并且狀態(tài)為 ?fulfilled?)。
    • 關鍵字 ?async? 可以實現這一點,我們可以簡單地使用 ?async next()?。
  3. 我們應該使用 ?for await (let item of iterable)? 循環(huán)來迭代這樣的對象。
    • 注意關鍵字 ?await?。

作為開始的示例,讓我們創(chuàng)建一個可迭代的 range 對象,與前面的那個類似,不過現在它將異步地每秒返回一個值。

我們需要做的就是對上面代碼中的部分代碼進行替換:

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

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

      async next() { // (2)

        // 注意:我們可以在 async next 內部使用 "await"
        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
  }

})()

正如我們所看到的,其結構與常規(guī)的 iterator 類似:

  1. 為了使一個對象可以異步迭代,它必須具有方法 ?Symbol.asyncIterator ?(1)??。
  2. 這個方法必須返回一個帶有 ?next()? 方法的對象,?next()? 方法會返回一個 promise ?(2)?。
  3. 這個 ?next()? 方法可以不是 ?async? 的,它可以是一個返回值是一個 ?promise? 的常規(guī)的方法,但是使用 ?async? 關鍵字可以允許我們在方法內部使用 ?await?,所以會更加方便。這里我們只是用于延遲 1 秒的操作 ?(3)?。
  4. 我們使用 ?for await(let value of range)? ?(4)? 來進行迭代,也就是在 ?for? 后面添加 ?await?。它會調用一次 ?range[Symbol.asyncIterator]()? 方法一次,然后調用它的 ?next()? 方法獲取值。

這是一個對比 Iterator 和異步 iterator 之間差異的表格:

Iterator 異步 iterator
提供 iterator 的對象方法 Symbol.iterator Symbol.asyncIterator
next() 返回的值是 任意值 Promise
要進行循環(huán),使用 for..of for await..of

Spread 語法 ?...? 無法異步工作

需要常規(guī)的同步 iterator 的功能,無法與異步 iterator 一起使用。

例如,spread 語法無法工作:

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

這很正常,因為它期望找到 Symbol.iterator,而不是 Symbol.asyncIterator。

for..of 的情況和這個一樣:沒有 await 關鍵字時,則期望找到的是 Symbol.iterator。

回顧 generator

現在,讓我們回顧一下 generator,它使我們能夠寫出更短的迭代代碼。在大多數時候,當我們想要創(chuàng)建一個可迭代對象時,我們會使用 generator。

簡單起見,這里省略了一些解釋,即 generator 是“生成(yield)值的函數”。關于此的詳細說明請見 generator 一章。

Generator 是標有 function*(注意星號)的函數,它使用 yield 來生成值,并且我們可以使用 for..of 循環(huán)來遍歷它們。

下面這例子生成了從 start 到 end 的一系列值:

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

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

正如我們所知道的,要使一個對象可迭代,我們需要給它添加 Symbol.iterator。

let range = {
  from: 1,
  to: 5,
  [Symbol.iterator]() {
    return <帶有 next 方法的對象,以使對象 range 可迭代>
  }
}

對于 Symbol.iterator 來說,一個通常的做法是返回一個 generator,這樣可以使代碼更短,如下所示:

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

  *[Symbol.iterator]() { // [Symbol.iterator]: function*() 的一種簡寫
    for(let value = this.from; value <= this.to; value++) {
      yield value;
    }
  }
};

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

如果你想了解更多詳細內容,請閱讀 generator 一章。

在常規(guī)的 generator 中,我們無法使用 await。所有的值都必須按照 for..of 構造的要求同步地出現。

如果我們想要異步地生成值該怎么辦?例如,對于來自網絡請求的值。

讓我們再回到異步 generator,來使這個需求成為可能。

異步 generator (finally)

對于大多數的實際應用程序,當我們想創(chuàng)建一個異步生成一系列值的對象時,我們都可以使用異步 generator。

語法很簡單:在 function* 前面加上 async。這即可使 generator 變?yōu)楫惒降摹?

然后使用 for await (...) 來遍歷它,像這樣:

async function* generateSequence(start, end) {

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

    // 哇,可以使用 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,然后 2,然后 3,然后 4,然后 5(在每個 alert 之間有延遲)
  }

})();

因為此 generator 是異步的,所以我們可以在其內部使用 await,依賴于 promise,執(zhí)行網絡請求等任務。

引擎蓋下的差異

如果你還記得我們在前面章節(jié)中所講的關于 generator 的細節(jié)知識,那你應該知道,從技術上講,異步 generator 和常規(guī)的 generator 在內部是有區(qū)別的。

對于異步 generator,generator.next() 方法是異步的,它返回 promise。

在一個常規(guī)的 generator 中,我們使用 result = generator.next() 來獲得值。但在一個異步 generator 中,我們應該添加 await 關鍵字,像這樣:

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

這就是為什么異步 generator 可以與 for await...of 一起工作。

異步的可迭代對象 range

常規(guī)的 generator 可用作 Symbol.iterator 以使迭代代碼更短。

與之類似,異步 generator 可用作 Symbol.asyncIterator 來實現異步迭代。

例如,我們可以通過將同步的 Symbol.iterator 替換為異步的 Symbol.asyncIterator,來使對象 range 異步地生成值,每秒生成一個:

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

  // 這一行等價于 [Symbol.asyncIterator]: async function*() {
  async *[Symbol.asyncIterator]() {
    for(let value = this.from; value <= this.to; value++) {

      // 在 value 之間暫停一會兒,等待一些東西
      await new Promise(resolve => setTimeout(resolve, 1000));

      yield value;
    }
  }
};

(async () => {

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

})();

現在,value 之間的延遲為 1 秒。

請注意:

從技術上講,我們可以把 Symbol.iterator 和 Symbol.asyncIterator 都添加到對象中,因此它既可以是同步的(for..of)也可以是異步的(for await..of)可迭代對象。

但是實際上,這將是一件很奇怪的事情。

實際的例子:分頁的數據

到目前為止,我們已經了解了一些基本示例,以加深理解?,F在,我們來看一個實際的用例。

目前,有很多在線服務都是發(fā)送的分頁的數據(paginated data)。例如,當我們需要一個用戶列表時,一個請求只返回一個預設數量的用戶(例如 100 個用戶)—— “一頁”,并提供了指向下一頁的 URL。

這種模式非常常見。不僅可用于獲取用戶列表,這種模式還可以用于任意東西。

例如,GitHub 允許使用相同的分頁提交(paginated fashion)的方式找回 commit:

  • 我們應該以 ?https://api.github.com/repos/<repo>/commits? 格式創(chuàng)建進行 ?fetch? 的網絡請求。
  • 它返回一個包含 30 條 commit 的 JSON,并在返回的 ?Link? header 中提供了指向下一頁的鏈接。
  • 然后我們可以將該鏈接用于下一個請求,以獲取更多 commit,以此類推。

對于我們的代碼,我們希望有一種更簡單的獲取 commit 的方式。

讓我們創(chuàng)建一個函數 fetchCommits(repo),用來在任何我們有需要的時候發(fā)出請求,來為我們獲取 commit。并且,該函數能夠關注到所有分頁內容。對于我們來說,它將是一個簡單的 for await..of 異步迭代。

因此,其用法將如下所示:

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

通過異步 generator,我們可以輕松實現上面所描述的函數,如下所示:

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 需要任意的 user-agent header
    });

    const body = await response.json(); // (2) 響應的是 JSON(array of commits)

    // (3) 前往下一頁的 URL 在 header 中,提取它
    let nextPage = response.headers.get('Link').match(/<(.*?)>; rel="next"/);
    nextPage = nextPage?.[1];

    url = nextPage;

    for(let commit of body) { // (4) 一個接一個地 yield commit,直到最后一頁
      yield commit;
    }
  }
}

關于其工作原理的進一步解釋:

  1. 我們使用瀏覽器的 fetch 方法來下載 commit。
    • 初始 URL 是 ?https://api.github.com/repos/<repo>/commits?,并且下一頁的 URL 將在響應的 ?Link? header 中。
    • ?fetch? 方法允許我們提供授權和其他 header,如果需要 —— 這里 GitHub 需要的是 ?User-Agent?。
  2. commit 被以 JSON 的格式返回。
  3. 我們應該從響應(response)的 ?Link? header 中獲取前往下一頁的 URL。它有一個特殊的格式,所以我們對它使用正則表達式(我們將在 正則表達式 一章中學習它)。
    • 前往下一頁的 URL 看起來可能就像這樣 ?https://api.github.com/repositories/93253246/commits?page=2?。這是由 GitHub 自己生成的。
  4. 然后,我們將接收到的所有 commit 一個一個地 yield 出來,當所有 commit 都 yield 完成時,將觸發(fā)下一個 ?while(url)? 迭代,并發(fā)出下一個請求。

這是一個使用示例(在控制臺中顯示 commit 的作者)

(async () => {

  let count = 0;

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

    console.log(commit.author.login);

    if (++count == 100) { // 讓我們在獲取了 100 個 commit 時停止
      break;
    }
  }

})();

// 注意:如果你在外部沙箱中運行它,你需要把上面的 fetchCommits 函數粘貼到這兒。

這就是我們想要的。

從外部看不到分頁請求(paginated requests)的內部機制。對我們來說,它只是一個返回 commit 的異步 generator。

總結

常規(guī)的 iterator 和 generator 可以很好地處理那些不需要花費時間來生成的的數據。

當我們期望異步地,有延遲地獲取數據時,可以使用它們的異步版本,并且使用 for await..of 替代 for..of。

異步 iterator 與常規(guī) iterator 在語法上的區(qū)別:

Iterable 異步 Iterable
提供 iterator 的對象方法 Symbol.iterator Symbol.asyncIterator
next() 返回的值是 {value:…, done: true/false} resolve 成 {value:…, done: true/false} 的 Promise

異步 generator 與常規(guī) generator 在語法上的區(qū)別:

Generator 異步 generator
聲明方式 function* async function*
next() 返回的值是 {value:…, done: true/false} resolve 成 {value:…, done: true/false} 的 Promise

在 Web 開發(fā)中,我們經常會遇到數據流,它們分段流動(flows chunk-by-chunk)。例如,下載或上傳大文件。

我們可以使用異步 generator 來處理此類數據。值得注意的是,在一些環(huán)境,例如瀏覽器環(huán)境下,還有另一個被稱為 Streams 的 API,它提供了特殊的接口來處理此類數據流,轉換數據并將數據從一個數據流傳遞到另一個數據流(例如,從一個地方下載并立即發(fā)送到其他地方)。


以上內容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號