一段时间前,我在 Medium 上发了一篇博文,在其中我讨论了 Iterator 协议及其用户界面。然而,除了 Promise.finally
等 API 之外,ECMAScript 2018 还为我们带来了另一种处理迭代器的方法。异步迭代器。
我们假设自己处于一种相当常见的情形中。我们正在使用 Node.js,并且必须逐行读取一个文件。Node 有一个用于此类函数的 API,称为 readLine
(在此处查看完整文档)。此 API 是一个包装器,因此您可以逐行读取输入流中的数据,而无需解析输入缓冲区并将文本分解成小块。
它公开了一个事件 API,您可以像这样进行监听:
const fs = require('fs')
const readline = require('readline')
const reader = readline.createInterface({
input: fs.createReadStream('./file.txt'),
crlfDelay: Infinity
})
reader.on('line', (line) => console.log(line))
想象一下我们有一个简单文件:
line 1
line 2
line 3
如果我们对已创建的文件运行此代码,我们将在控制台上获得逐行输出。但是,使用事件并不是编写可维护代码的最佳方式之一,因为事件是完全异步的,并且它们可能会破坏代码的流程,因为它们会无序触发,并且您只能通过侦听器分配一个操作。
除了事件 API, readline
还公开了一个 async iterator
。这意味着,我们不会通过 line
事件中的侦听器读取行,而是会通过使用 for
关键字的新方式读取行。
今天,我们有几种使用 for
循环的选项。第一个是最常见的模型,使用计数器和条件:
for (let x = 0; x < array.length; x++) {
// Code here
}
我们还可以使用符号 for ... in
符号来读取数组索引:
const a = [1,2,3,4,5,6]
for (let index in a) {
console.log(a[index])
}
在前面的情况下,我们将在 console.log
中获得输出,从 1 到 6 的数字,但是如果我们使用 console.log(index)
,我们将记录数组的索引,即从 0 到 5 的数字。
对于下一个情况,我们可以使用 for ... of
符号直接获取数组的可枚举属性,即其直接值:
const a = [1,2,3,4,5,6]
for (let item of a) {
console.log(item)
}
请注意,我描述的所有方式都是同步的。那么,我们如何按顺序读取一系列承诺?
想象一下,我们有另一个总是返回 Promise 的接口,该 Promise 会针对我们所讨论的文件行进行解析。为了按顺序解析这些 Promise,我们需要执行类似以下操作:
async function readLine (files) {
for (const file of files) {
const line = await readFile(file) // Imagine readFile is our cursor
console.log(line)
}
}
但是,由于异步可迭代对象(如 readline
)的神奇之处,我们可以执行以下操作:
const fs = require('fs')
const readline = require('readline')
const reader = readline.createInterface({
input: fs.createReadStream('./xpto.txt'),
crlfDelay: Infinity
})
async function read () {
for await (const line of reader) {
console.log(line)
}
}
read()
注意我们现在使用 for
的新定义, for await (const x of y)
Node.js 运行时从版本 10.x 开始原生支持 for await
表示法。如果您使用的是版本 8.x 或 9.x,则需要使用 --harmony_async_iteration
标志启动 Javascript 文件。遗憾的是,Node.js 版本 6 或 7 不支持异步迭代器。
为了理解异步迭代器的概念,我们需要了解迭代器本身是什么。我之前的文章是一份很好的信息来源,但简而言之,迭代器是一个公开 next()
函数的对象,该函数返回另一个带有表示法 {value: any, done: boolean}
的对象,其中 value
是当前迭代的值,而 done
标识序列中是否还有更多值。
一个简单的示例是一个迭代器,它遍历数组中的所有项:
const array = [1,2,3]
let index = 0
const iterator = {
next: () => {
if (index >= array.length) return { done: true }
return {
value: array[index++],
done: false
}
}
}
单独来看,迭代器没有任何实际用途,因此为了从中获取一些用途,我们需要一个 iterable
。 iterable
是一个具有 Symbol.iterator
键的对象,该键返回一个函数,该函数返回我们的迭代器:
// ... Iterator code here ...
const iterable = {
[Symbol.iterator]: () => iterator
}
现在我们可以正常使用它,使用 for (const x of iterable)
,我们将使 array
中的所有值逐个迭代。
如果您想进一步了解符号,请查看我专门为此撰写的另一篇文章。
在底层,所有数组和对象都有一个 Symbol.iterator
,以便我们可以执行 for (let x of [1,2,3])
并返回我们想要的值。
正如您可能期望的那样,异步迭代器与迭代器完全相同,只是我们可以在可迭代对象中使用 Symbol.asyncIterator
而不是 Symbol.iterator
,并且我们有一个返回 {value, done}
的对象而不是解析为具有相同签名的对象的 Promise。
让我们将上面的迭代器转换为异步迭代器:
const array = [1,2,3]
let index = 0
const asyncIterator = {
next: () => {
if (index >= array.length) return Promise.resolve({done: true})
return Promise.resolve({value: array[index++], done: false})
}
}
const asyncIterable = {
[Symbol.asyncIterator]: () => asyncIterator
}
我们可以通过调用 next()
函数手动迭代任何迭代器:
// ... Async iterator Code here ...
async function manual () {
const promise = asyncIterator.next() // Promise
await p // Object { value: 1, done: false }
await asyncIterator.next() // Object { value: 2, done: false }
await asyncIterator.next() // Object { value: 3, done: false }
await asyncIterator.next() // Object { done: true }
}
为了迭代我们的异步迭代器,我们必须使用 for await
,但请记住关键字 await
只能在 async function
内使用,这意味着我们必须拥有类似这样的东西:
// ... Code above ...
async function iterate () {
for await (const num of asyncIterable) console.log(num)
}
iterate() // 1, 2, 3
但是,由于异步迭代器在 Node 8.x 或 9.x 中不受支持(我知道非常老了),因此为了在这些版本中使用异步迭代器,我们可以简单地从对象中提取 next
并手动对其进行迭代:
// ... Async Iterator Code here ...
async function iterate () {
const {next} = asyncIterable[Symbol.asyncIterator]() // we take the next iterator function
for (let {value, done} = await next(); !done; {value, done} = await next()) {
console.log(value)
}
}
请注意, for await
更加简洁,也更加干净,因为它表现得像一个常规循环,但除此之外,它还比理解起来简单得多,它通过 done
键自行检查迭代器的结尾。
如果我们的承诺在迭代器中被拒绝,会发生什么?好吧,就像任何被拒绝的承诺一样,我们可以通过一个简单的 try/catch
来捕获它的错误(因为我们正在使用 await
):
const asyncIterator = { next: () => Promise.reject('Error') }
const asyncIterable = { [Symbol.asyncIterator]: () => asyncIterator }
async function iterate () {
try {
for await (const num of asyncIterable) {}
} catch (e) {
console.log(e.message)
}
}
iterate()
关于异步迭代器非常有趣的一点是,它们有一个 Symbol.iterator
的后备,这意味着您也可以将其与常规迭代器一起使用,例如一个 Promise 数组:
const promiseArray = [
fetch('https://lsantos.dev'),
fetch('https://lsantos.me')
]
async function iterate () {
for await (const response of promiseArray) console.log(response.status)
}
iterate() // 200, 200
在大多数情况下,可以从生成器创建迭代器和异步迭代器。
生成器是允许暂停和恢复其执行的函数,因此可以执行执行,然后通过 next()
函数获取下一个值。
这是一个非常简单的生成器描述,有必要阅读仅讨论它们的相关文章,以便您可以快速深入地理解生成器。
异步生成器表现得像一个异步迭代器,但您必须手动实现停止机制,例如,让我们构建一个用于 git 提交的随机消息生成器,让您的同事对他们的贡献超级满意:
async function* gitCommitMessageGenerator () {
const url = 'https://whatthecommit.com/index.txt'
while (true) {
const response = await fetch(url)
yield await response.text() // We return the value
}
}
请注意,我们从未返回过 {value, done}
对象,因此循环无法知道何时执行已完成。我们可以实现这样的函数:
// Previous Code
async function getCommitMessages (times) {
let execution = 1
for await (const message of gitCommitMessageGenerator()) {
console.log(message)
if (execution++ >= times) break
}
}
getCommitMessages(5)
// I'll explain this when I'm sober .. or revert it
// Never before had a small typo like this one caused so much damage.
// For real, this time.
// Too lazy to write descriptive message
// Ugh. Bad rebase.
对于一个更有趣的示例,让我们为一个实际用例构建一个异步迭代器。目前,Oracle Database 驱动程序支持 Node.js resultSet
API,该 API 在数据库上执行查询并返回一个记录流,可以使用 getRow()method
一个一个地读取这些记录。
要创建此 resultSet
,我们需要在数据库中执行一个查询,如下所示:
const oracle = require('oracledb')
const options = {
user: 'example',
password: 'example123',
connectString: 'string'
}
async function start () {
const connection = await oracle.getConnection(options)
const { resultSet } = await connection.execute('query', [], { outFormat: oracle.OBJECT, resultSet: true })
return resultSet
}
start().then(console.log)
我们的 resultSet
有一个名为 getRow()
的方法,它返回一个 Promise,其中包含要从数据库中获取的下一行。这是一个异步迭代器的不错用例,不是吗?我们可以创建一个逐行返回此 resultSet
的游标。让我们通过创建一个 Cursor 类使其变得更复杂一些:
class Cursor {
constructor(resultSet) {
this.resultSet = resultSet
}
getIterable() {
return {
[Symbol.asyncIterator]: () => this._buildIterator()
}
}
_buildIterator() {
return {
next: () => this.resultSet.getRow().then((row) => ({ value: row, done: row === undefined }))
}
}
}
module.exports = Cursor
查看光标接收它应该处理的 resultSet
,并将其存储在其当前状态中。因此,让我们更改以前的方法,以便一次返回光标而不是 resultSet
:
const oracle = require('oracledb')
const options = {
user: 'example',
password: 'example123',
connectString: 'string'
}
async function getResultSet() {
const connection = await oracle.getConnection(options)
const { resultSet } = await connection.execute('query', [], { outFormat: oracle.OBJECT, resultSet: true })
return resultSet
}
async function start() {
const resultSet = await getResultSet()
const cursor = new Cursor(resultSet)
for await (const row of cursor.getIterable()) {
console.log(row)
}
}
start()
这样我们就可以循环遍历所有返回的行,而无需单独的 Promises 解析。
异步迭代器非常强大,尤其是在像 JavaScript 这样的动态和异步语言中。使用它们,您可以将复杂的执行变成简单的代码,从而对用户隐藏大部分复杂性。