调用分页接口是前端和 Node.js 开发中再常见不过的场景:一个列表接口返回第一页数据和 nextCursor 游标,客户端拿着游标继续请求,直到没有更多数据为止。传统的写法通常是一个 while 循环加 await,或者在递归函数里一层层调用,代码逻辑散落各处,可读性和可维护性都不理想。ES2018 正式引入的异步迭代协议(Asynchronous Iteration)和 for await...of 循环,为这类场景提供了语言层面的支持,让“逐页消费数据”这件事变得像遍历数组一样自然。

一、从同步迭代协议到异步迭代协议
要理解异步迭代器,得先回顾 JavaScript 的同步迭代协议。一个对象只要实现了 Symbol.iterator 方法,返回一个带 next() 的迭代器,就能被 for...of 遍历。每次调用 next() 返回 { value, done },整个过程是同步完成的。
问题在于,网络请求是异步的。如果 next() 需要发起一次 HTTP 请求才能知道下一页的内容,同步协议就无能为力了——它没法等待 Promise。于是 ES2018 定义了第二套协议:对象实现 Symbol.asyncIterator 方法,返回的迭代器的 next() 返回一个 Promise,resolve 之后才拿到 { value, done }。for await...of 循环则在每轮迭代前 await 这个 Promise,把异步等待的细节完全封装在语言层面。
一个最小的异步迭代器示例如下:
const counter = {
[Symbol.asyncIterator]() {
let i = 0;
return {
next() {
if (i >= 3) return Promise.resolve({ value: undefined, done: true });
return new Promise(resolve => setTimeout(() => resolve({ value: i++, done: false }), 500));
}
};
}
};
(async () => {
for await (const num of counter) {
console.log(num); // 依次输出 0, 1, 2,每次间隔约 500ms
}
})();可以看到,消费端代码和普通 for...of 几乎没有区别,唯一的差异是多了一个 await 关键字,以及外层函数需要是 async。这正是异步迭代器最大的价值:把“按需异步产出数据”的复杂度从消费方转移到了生产方。
二、用异步生成器封装分页接口
手写 Symbol.asyncIterator 比较繁琐,实践中几乎都用异步生成器函数(async generator)来实现。在 function 前加一个星号、再配合 await,就得到一个天然的异步迭代器。用它封装分页接口的思路是:每请求一页就 yield 一页的数据,同时更新游标,直到接口返回没有更多数据。
async function* paginate(url, pageSize = 20) {
let cursor = null;
do {
const params = new URLSearchParams({ pageSize });
if (cursor) params.set('cursor', cursor);
const res = await fetch(`${url}?${params}`);
if (!res.ok) throw new Error(`请求失败: ${res.status}`);
const data = await res.json();
// 每取到一页就交出一页,消费方可以立即处理
yield data.items;
cursor = data.nextCursor;
} while (cursor);
}
(async () => {
for await (const page of paginate('https://api.ipipp.com/orders')) {
console.log(`本页 ${page.length} 条`);
for (const item of page) renderRow(item);
}
})();这段代码的优点非常明显。第一,业务代码只关心“拿到一页后做什么”,完全不用管游标怎么传递、循环何时终止。第二,数据是流式消费的:第一页返回后立刻处理,处理的同时下一页请求已经在路上,内存中任何时刻只保留一页数据,对上万条记录的大列表特别友好。第三,paginate 是一个可复用的通用函数,任何游标式或页码式接口都能套用,只需调整游标更新逻辑。
如果希望迭代时直接拿到单条记录而不是整页,可以在生成器里再做一层展开,用 yield* 委托给一个普通数组即可:
async function* paginateFlat(url, pageSize = 20) {
let cursor = null;
do {
const res = await fetch(url + '?cursor=' + encodeURIComponent(cursor ?? ''));
const data = await res.json();
yield* data.items; // 逐条产出,消费方拿到的是单条记录
cursor = data.nextCursor;
} while (cursor);
}三、对比传统方案的差异与优势
没有异步迭代器之前,处理分页通常有三种写法。第一种是 while 循环拼变量:外部声明 cursor 和 results,循环体内 fetch、push、更新游标,所有中间状态都暴露在外层作用域,函数一长就容易混乱。第二种是递归 Promise:写一个函数请求一页,在 then 里调用自己,逻辑分散在闭包链上,调试栈也不直观。第三种是回调式:每次请求完成后回调判断是否继续,容易出现回调嵌套。
和这些方案相比,for await...of 的优势可以归纳为四点。其一是声明式消费:循环结构本身就表达了“遍历所有页”的语义,终止条件内聚在迭代器里。其二是惰性求值:只有消费方还在迭代,才会发起下一页请求,天然支持提前 break——比如找到目标记录就停止,后续页面根本不会请求,这一点 while 循环也能做到,但递归方案很难干净地中断。其三是可组合性:异步迭代器可以和普通生成器组合、被其他生成器委托、甚至配合 stream 模块逐块读取数据。其四是错误处理统一:整个循环体套一个 try/catch 就能捕获任意一页的异常,不用在每个请求点重复写。
| 方案 | 可读性 | 提前终止 | 内存占用 | 复用性 |
|---|---|---|---|---|
| while 循环 | 一般 | 支持 | 需手动控制 | 低 |
| 递归 Promise | 较差 | 困难 | 易堆积 | 低 |
| 回调式 | 差 | 困难 | 易堆积 | 低 |
| for await...of | 好 | break 即可 | 流式,仅一页 | 高 |
四、实战细节:错误处理、并发与资源释放
错误重试。分页抓取最常见的故障是某一页请求超时或 5xx。可以在生成器内部封装重试逻辑,让消费方无感知:
async function fetchWithRetry(url, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
const res = await fetch(url);
if (res.ok) return res.json();
if (res.status < 500) throw new Error(`客户端错误: ${res.status}`);
} catch (e) {
if (i === retries - 1) throw e;
await new Promise(r => setTimeout(r, 2 ** i * 500)); // 指数退避
}
}
}受控并发预取。for await...of 默认是串行的:处理完一页才请求下一页。如果接口响应慢而本地处理快,可以在迭代器里维护一个小型预取队列,提前请求接下来的一两页,吞吐量能明显提升,同时把并发数控制在服务端可接受的范围内。要注意游标式接口下一页的参数依赖上一页的返回,预取深度不宜过大。
提前终止与清理。当循环中 break、抛出异常或调用迭代器的 return() 时,生成器会触发一次 finally 块的执行。把连接关闭、abort 控制器取消等清理逻辑写在 finally 里,可以保证资源不泄漏:
async function* fetchPages(url, signal) {
const controller = new AbortController();
const onAbort = () => controller.abort();
signal?.addEventListener('abort', onAbort);
try {
let cursor = null;
do {
const data = await fetch(url + '?cursor=' + cursor, { signal: controller.signal }).then(r => r.json());
yield data.items;
cursor = data.nextCursor;
} while (cursor);
} finally {
signal?.removeEventListener('abort', onAbort); // 提前 break 时也会执行
}
}兼容性提示。for await...of 在现代浏览器和 Node 10 以上的版本均已支持,直接使用没有问题。唯一需要注意的是它不能遍历普通同步可迭代对象以外的异步结构以外的某些 polyfill 不完整的旧环境,必要时可借助 Babel 插件转译。另外,遍历异步迭代器必须用 for await,如果误写成普通 for...of,得到的会是 Promise 对象本身,这是一个新手常踩的坑。
总结一下,异步迭代器把分页接口抽象成“可以逐页产出的数据源”,for await...of 则提供了最自然的消费语法。两者结合之后,翻页逻辑只写一次、封装在生成器里,业务代码回到简洁的循环形态,同时获得流式处理、惰性求值、统一错误处理和干净的中断语义。下次面对游标分页接口时,不妨把旧的 while 循环重构成异步生成器,代码量通常会减少一半以上,可读性更是质的提升。
异步迭代器for-await-of分页API修改时间:2026-09-14 02:04:53