K 的一隅

JavaScript JavaScript 核心机制

当数据不是一次到齐:异步迭代器与 for await...of

从分批到货的包裹出发,理解异步迭代协议、for await...of、异步生成器与顺序等待。

10 分钟阅读 更新于 2026-07-23
本文目录

网购三件商品,不一定三件同时送到。第一件到了,你可以先拆;第二件还在路上,就等下一次通知。你并不需要等全部到齐才开始,也不需要每隔一秒把所有订单重新查一遍。

异步迭代器表达的正是“请给我下一项,它准备好以后告诉我”。

快递不会因为你开始循环就同时到齐

同步 iterator 的 next() 立刻返回 { value, done }。如果下一项依赖网络,这个返回值暂时无法确定。最直接的改变是让 next() 返回 Promise:

js
{
  next() {
    return Promise.resolve({
      value: '第一件包裹',
      done: false,
    })
  },
}

Promise 完成后,消费者才知道这次有没有值。生产者仍然是拉取式:只有消费者请求下一项,才推进一次;只是一次请求可以跨过异步等待。

先猜耗时:三次等待会不会并发

js
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))

async function* packages() {
  await wait(300)
  yield '包裹 A'

  await wait(300)
  yield '包裹 B'

  await wait(300)
  yield '包裹 C'
}

const start = performance.now()

for await (const item of packages()) {
  console.log(item)
}

console.log(Math.round(performance.now() - start))

总时间接近 900 毫秒,而不是 300 毫秒。每次恢复生产者后才开始下一段等待;循环也要等当前 next() 完成,才请求下一项。这是顺序等待。

设备调度会让测量略有偏差,不能把 900 当精确保证。重要的是三段等待的依赖关系。

异步迭代器把 next 的结果放进 Promise

异步 iterable 通过 Symbol.asyncIterator 提供 async iterator:

js
const deliveries = {
  [Symbol.asyncIterator]() {
    let index = 0
    const items = ['A', 'B', 'C']

    return {
      async next() {
        await wait(100)

        if (index >= items.length) {
          return { value: undefined, done: true }
        }

        return {
          value: items[index++],
          done: false,
        }
      },
    }
  },
}

这里 async next() 自动返回 Promise。它 fulfilled 后的值必须是迭代结果对象。若 Promise rejected,消费循环也会因异常中断。

同步协议入口是 Symbol.iterator,异步协议入口是 Symbol.asyncIterator。分开两个 symbol,可以让一个对象选择提供不同的同步和异步遍历语义。

for await...of 每一步等待了什么

手写消费过程大致是:

js
const iterator = deliveries[Symbol.asyncIterator]()

while (true) {
  const result = await iterator.next()
  if (result.done) break

  console.log(result.value)
}

for await...of 帮我们完成这套循环和关闭处理。它只能出现在允许 await 的上下文中,例如 async 函数或支持顶层 await 的模块:

js
async function receive() {
  for await (const item of deliveries) {
    console.log('收到', item)
  }
}

每轮先取得迭代器结果,再等待结果 Promise,检查 done,最后把 value 绑定给循环变量。循环体自身若包含 await,下一次 next() 还会等循环体完成后才调用。

因此,异步迭代自然提供了背压:消费者处理得慢,默认就不会无限制向生产者索取。它不是完整的流量控制方案,但比生产者不管消费速度持续推送更容易保持边界。

异步生成器省掉的是哪一层手工状态

前面的 deliveries 要手写 index{ value, done }。改成异步生成器后,结构更接近日常代码:

js
async function* deliveries() {
  for (const item of ['A', 'B', 'C']) {
    await wait(100)
    yield item
  }
}

async function* 调用后返回 async generator object。它符合异步迭代协议,next() 返回 Promise;函数体可以使用 await 等待,也可以用 yield 交出一项。

异步生成器不会在调用时运行:

js
const iterator = deliveries()
const firstPromise = iterator.next()

调用 next() 才推进函数。firstPromise 最终 fulfilled 为类似 { value: 'A', done: false } 的对象。

yield 一个 Promise 时,异步生成器的迭代结果会采用等待后的值,而不是简单把一个未完成 Promise 当普通值扔给循环。理解这种语义时,以“next() 最终给消费者一个异步迭代结果”为主线更稳。

分页接口很适合变成异步 iterable

很多接口一次只返回一页数据,同时给出下一页游标。业务代码若到处手写 while (cursor),网络细节会泄漏给消费者。可以用异步生成器封装:

js
async function* listOrders(initialUrl, signal) {
  let url = initialUrl

  while (url) {
    const response = await fetch(url, { signal })
    if (!response.ok) {
      throw new Error(`请求失败:${response.status}`)
    }

    const page = await response.json()

    for (const order of page.items) {
      yield order
    }

    url = page.nextUrl
  }
}

for await (const order of listOrders('/api/orders', controller.signal)) {
  renderOrder(order)
}

消费者只看见订单序列,不需要知道第几页。生产者只有在当前页的项目被消费完、循环再次索取时,才请求下一页。用户只看前十项时,不必仍然下载全部页面。

封装不应吞掉错误。HTTP 非成功状态不会让 fetch 自动 rejected,所以示例显式检查 response.ok;网络错误或取消则通过 rejected Promise 传播到循环外部。调用方可以用 try/catch 决定提示、重试或结束。

如果 break 后需要取消正在进行或未来的请求,应把 AbortSignal 与迭代器清理策略连接起来。仅停止循环,不会自动知道你希望怎样处理所有底层 API。

同步 iterable 也能被消费,但不是免费转换

for await...of 会先寻找 Symbol.asyncIterator。若没有,它可以回退到同步 Symbol.iterator,把同步迭代器适配为异步迭代器,并等待产生的值:

js
async function print() {
  for await (const value of [1, 2, 3]) {
    console.log(value)
  }
}

这段合法,但不代表遍历数组时应该一律使用 for await...of。适配过程带来 Promise 解析和异步恢复开销,错误处理时机也与普通 for...of 不同。已知数据完全同步时,普通循环更直接。

一个容易意外的边界是:同步 generator 在交出 rejected Promise 时,for await...of 的适配与清理行为可能不符合你对生成器内部 finally 的直觉。若资源清理依赖同步生成器的控制流,应该明确捕获 yielded Promise 的失败,或使用真正的 async generator 组织异步资源。

break 之后为什么还要等待 return()

异步资源的关闭也可能需要时间。for await...of 提前退出时,会取得 iterator 的 return(),调用后等待其结果,再离开循环:

js
const source = {
  [Symbol.asyncIterator]() {
    let current = 0

    return {
      async next() {
        await wait(50)
        return { value: ++current, done: false }
      },
      async return() {
        await wait(50)
        console.log('连接已关闭')
        return { done: true }
      },
    }
  },
}

for await (const value of source) {
  console.log(value)
  break
}

console.log('循环之后')

“连接已关闭”出现在“循环之后”之前。循环不仅发出关闭请求,还等待清理完成。

对 async generator,可以把清理放在 try/finally

js
async function* stream() {
  try {
    while (true) {
      yield await readNext()
    }
  } finally {
    await closeConnection()
  }
}

这不意味着所有底层 API 都会自动取消。生成器的 finally 需要实际调用对应资源的取消或释放方法。

清理还应尽量幂等。消费者可能正常读完、提前退出、抛错或主动取消,底层连接也可能先自行关闭。closeConnection() 若被调用两次,不应把原始业务错误覆盖成“已经关闭”的新错误。

异步生成器的 return() 返回 Promise,所以手工使用时也要等待:

js
const iterator = stream()

try {
  console.log(await iterator.next())
} finally {
  await iterator.return()
}

漏掉 await 可能让外层函数先结束,清理仍悬在后台;测试也可能偶尔通过、偶尔留下未释放资源。

顺序消费不等于并发下载

如果三件包裹彼此独立,而且目标是同时查询,可以先启动工作,再统一等待:

js
const requests = [
  fetchPackage('A'),
  fetchPackage('B'),
  fetchPackage('C'),
]

const results = await Promise.all(requests)

三个函数调用在构造数组时已经发生,之后 Promise.all 等全部完成。若写成:

js
for (const id of ['A', 'B', 'C']) {
  const result = await fetchPackage(id)
  console.log(result)
}

下一次请求要等上一次完成才开始。

但并发不是越多越好。一次发出一万次请求可能撞上连接、服务端限流和内存边界。常见做法是设置并发上限,保持有限数量的工作在途。异步迭代器也可以设计预取缓冲,不过那是生产者主动提前准备若干项,不是 for await...of 自动带来的能力。

还要分清“并发调用 next()”和“并发处理值”。不同 async iterator 对同时出现的多个 next() 请求可能有自己的排队语义;async generator 会按请求顺序处理,但手写 iterator 若没有保护共享状态,两个请求都可能读到同一个索引。

js
let index = 0

async function unsafeNext() {
  const current = index
  await wait(10)
  index = current + 1
  return current
}

同时调用两次,两个调用都可能先读到 0。默认 for await...of 一轮结束后才请求下一轮,避免了这种重叠;一旦自己并发调用,就要由实现保证原子推进或显式排队。

消费值也可以有限并发。例如异步迭代读取任务,再把每项交给限制并发数的工作池。这里“读取顺序”“开始处理顺序”“完成顺序”是三件事,API 应说明最终保证哪一种。

错误发生后,序列通常不能从原处重来

某次 next() 返回的 Promise rejected,for await...of 会抛出错误。若生产者是异步生成器,未在内部处理的异常会使它完成:

js
async function* source() {
  yield 1
  throw new Error('连接断开')
}

const iterator = source()

console.log(await iterator.next())

try {
  await iterator.next()
} catch (error) {
  console.log(error.message)
}

console.log(await iterator.next()) // { value: undefined, done: true }

重试不能简单地对已完成对象继续 next()。通常要创建新的数据源,并决定从头开始、从游标恢复还是跳过失败项。恢复策略属于业务协议,不是异步迭代器自动提供的能力。

如果生成器内部能处理某类临时错误,它可以捕获后继续循环;但要避免无上限重试。至少应考虑次数、退避、取消信号,以及重试期间消费者是否仍能退出。

容易踩的坑:加上 async 不会自动并行

async function* 允许 await,不会让多轮自动重叠。for await...of 默认按次调用并等待 next(),也不是并发循环。

另一个误解是“异步迭代器就是一串 Promise”。一串已创建的 Promise 是一个同步数组,所有工作可能早已开始;异步迭代器则定义逐次请求下一结果的协议,可以按需启动、施加背压并清理。

还要区分 done 与值为空。value 可以是 undefineddone: false,这仍是一项有效结果;是否结束只看 done

“有了背压就不会占内存”也不准确。生产者底层若连接的是持续推送的数据源,适配层仍可能需要缓冲;消费者长期跟不上时,必须选择限制、暂停上游、丢弃或报错。async iterator 让消费侧一次一项地拉取,却不能凭空改变底层系统的生产速度。

手写异步迭代器时,把推进请求串起来

若 API 允许外部直接拿到 iterator,就应考虑调用者不按 for await...of 的顺序使用。一个简单保护方法是让每次推进接在前一次 Promise 后:

js
function createQueuedIterator(load) {
  let index = 0
  let queue = Promise.resolve()

  return {
    next() {
      const result = queue.then(async () => {
        const value = await load(index)
        index += 1
        return value === undefined
          ? { value: undefined, done: true }
          : { value, done: false }
      })

      queue = result.then(() => undefined, () => undefined)
      return result
    },
  }
}

即使调用者连续请求两次,第二次加载也会等第一次推进结束。示例把 undefined 当结束哨兵,只适用于业务值不可能为 undefined 的场景;通用实现应让 load 明确返回 { value, done },避免把值域和控制信号混在一起。

排队也带来选择:第一次失败后,后续请求继续、全部失败还是关闭序列?上例为了让队列能继续,把内部队列链的 rejection 转为完成,但调用者拿到的那次 result 仍会 rejected。真正 API 必须把错误策略写进契约,不能靠 Promise 链偶然决定。

async generator 已经替我们处理推进请求队列、状态和迭代结果,所以能用顺序函数体表达时,通常比手写版本可靠。手写 iterator 更适合包装已有异步数据源,或需要特殊缓存和预取策略的场景。

测试这类接口时,不要只验证产出数组。还应覆盖第一次 next() 被延迟、提前 return()、生产中抛错、取消期间清理,以及消费者处理较慢时是否出现无界缓冲。协议正确和资源生命周期正确缺一不可。

最好再记录每次请求与清理的时间,确认所谓顺序不是测试数据恰好同时完成造成的假象。

下一步:普通业务为什么更常见 await

异步迭代适合多项、按需、可能未知长度的数据。多数业务函数却只等一个结果:等用户资料、等保存完成、等一次权限确认。

这时直接写 await 更自然。它同样会暂停一段执行并在稍后恢复,但暂停的是当前 async 函数的后半段,返回接口是一个 Promise。下一章把这种“看起来同步”的写法拆开。