先说个让我印象深刻的场景。之前做一个小工具,要循环请求某个分页接口,每页20条,总共有几十页。当时没想太多,写了个递归函数,每拿到一页就往外面抛一个回调。后来页面多了,代码里面又嵌套了排序、过滤、去重逻辑,根本分不清数据在往哪条线上流动。差不多花了两小时才把逻辑捋清。
后来我发现用异步迭代器来处理“分页拉取”这种需求,简直顺手得不像话。数据什么时候流过来、什么时候停,都可以在一条同步风格的for循环里读明白。
所以今天就拿一个具体的例子来拆解它:用自己的异步迭代器读取分页接口。不需要任何库,一分钟就能读懂。
为什么等接口数据时要关心迭代器
普通数组是同步的可迭代对象。你想从头到尾读一遍,用for of就行。但接口返回是异步的,你不能在普通迭代器里“等一下下”再给下一个值。异步迭代器,就是专门给“下一个值不会立刻准备好”的情况准备的。
最简单理解方式:同步迭代器的next()返回{value, done},而异步迭代器的next()返回的是一个Promise,resolve后才是{value, done}。
为了不用老写.then(),JavaScript 提供了for await...of语法,像同步for of一样去循环异步数据源。
先看一个极其简单的自定义异步迭代对象:
const asyncRange = {
start: 0,
end: 3,
[Symbol.asyncIterator]() {
let current = this.start;
const end = this.end;
return {
next: () => {
if (current <= end) {
return Promise.resolve({
value: current++,
done: false
});
}
return Promise.resolve({ done: true });
}
};
}
};
(async () => {
for await (let num of asyncRange) {
console.log(num); // 依次输出0,1,2,3
}
})();
你可能觉得,为了打印几个数字,搞这么复杂没必要。那我们来点真的:分页接口。
分页读取器的真实需求
假设有一个后端接口/api/users,传page和pageSize返回一段数据,返回结构如下:
{
"code": 0,
"data": {
"list": ["用户A", "用户B", "..."],
"total": 42,
"page": 1,
"pageSize": 20
}
}
现在我们不想一次性把total条数据全塞到内存里。我们要一页一页拿,每拿到一页就“流式”地处理一条或者一批。这时,异步迭代器就派上用场了。
先把这个分页接口模拟出来,注意我故意用了setTimeout模拟网络延迟:
function fetchPage(page, pageSize = 10) {
const total = 35; // 共35条,分4页
const start = (page - 1) * pageSize;
const end = Math.min(start + pageSize, total);
const list = [];
for (let i = start; i < end; i++) {
list.push(`用户${i + 1}`);
}
return new Promise((resolve) => {
setTimeout(() => {
resolve({
code: 0,
data: {
list,
total,
page,
pageSize
}
});
}, Math.random() * 200 + 50);
});
}
通常我们封装分页,可能会写一个递归函数,边拿边处理。那样比较容易犯错。改成异步迭代器以后,我们可以把“翻页”这个动作封装进一个对象里:
function createPaginatedReader({ pageSize = 10 } = {}) {
let currentPage = 0;
let hasMore = true;
let buffer = [];
let total = 0;
let loadedCount = 0;
return {
// 关键点:给这个普通对象加上异步迭代器方法
[Symbol.asyncIterator]() {
return {
next: async () => {
// 如果当前缓冲里没数据了,去请求下一页
if (buffer.length === 0 && hasMore) {
const page = currentPage + 1;
const res = await fetchPage(page, pageSize);
total = res.data.total;
buffer = res.data.list;
hasMore = page * pageSize < total;
currentPage = page;
}
if (buffer.length > 0) {
loadedCount++;
return {
value: buffer.shift(),
done: false
};
}
// 没有更多数据,结束迭代
return { done: true };
}
};
}
};
}
这代码乍一看有点长,其实很好懂。它维护了一个内部缓冲区。当buffer没有数据时,就异步去拉下一页。只要mysql有值,就从头部弹出一条返回。弹完这一页,下次next()发现buffer空了,又自动拉下一页。
使用起来非常舒服:
(async () => {
const reader = createPaginatedReader({ pageSize: 10 });
for await (const user of reader) {
console.log('处理:', user);
// 这里可以做一些同步/异步的处理
// 比如 await saveToDatabase(user)
}
console.log('所有数据都读完了');
})();
运行结果大概是按顺序“处理: 用户1”、“处理: 用户2” …… 一直到“用户35”。也就是说,分页细节完全从主流程里消失了。
用“异步生成器”能更简洁
如果你的翻页逻辑比较直接,没必要再单独维护buffer,可以用异步生成器函数让代码缩短一半。异步生成器就是async function加个星号,内部可以yield值,Promise可以自动等。
async function* paginateUsers(pageSize = 10) {
let page = 1;
let total = Infinity;
while (page * pageSize < total + pageSize) {
const res = await fetchPage(page, pageSize);
const { list, total: totalCount } = res.data;
total = totalCount;
// 逐个吐出当前页的用户
for (const user of list) {
yield user;
}
page++;
// 如果这一页不满pageSize,说明到末尾了,直接停
if (list.length < pageSize) break;
}
}
这段逻辑比刚才手工实现迭代器要直观得多。使用它也一样:
(async () => {
for await (const user of paginateUsers(10)) {
console.log(user);
}
})();
异步生成器是一块语法糖。它的运行方式类似状态机,每次执行到yield就会暂停,等外面索取下一个值时再往后跑。而且它天然支持异常处理,你在异步生成器里写try/catch,可以捕获到整个迭代过程中的异常。
如果我想中途停掉不读了,怎么办
用for await...of时,只要遇到break、return 或throw,JavaScript会去调用迭代器的return()方法。异步生成器里,这相当于执行到finally块。
比如只取前5个用户:
(async () => {
for await (const user of paginateUsers(10)) {
console.log(user);
if (user === '用户5') {
break; // 不再往后翻页了
}
}
})();
注意,加了break以后,后面几页的数据其实不会被请求。具体原因说一下:for await调用了break以后,会调用异步生成器的return()方法,异步生成器内部在yield点抛出一个“return”的控制信号,让生成器执行完finally块并结束。所以循环外层的while也不会继续跑了。这在拉取大量数据时能节约不少接口请求。
如果是手动调reader.next(),那也只需不去调下一次next即可,想中断拉取也处于停止状态。
实战中的应用:Node.js 流式读取文件
分页接口只是其中一个小例子。实际上Node.js内置了很多异步迭代器。比如文件流:
import { createReadStream } from 'fs';
async function processFile(path) {
const stream = createReadStream(path, { encoding: 'utf8' });
// createReadStream返回的也是异步可迭代对象
for await (const chunk of stream) {
// 每个chunk是一段Buffer/字符串,可以在这里做分块处理
console.log('收到块', chunk.length);
}
}
这样不用把整个文件装进内存,也能逐块处理。
还有Readable.from,把数组转换成异步迭代流,中间再经过Transform处理都很方便。不过话题扯远了,重点是:for await已经把“异步数据流”变成了标准的循环抽象。
原生对象到底能不能直接 for await?
很多初学者会踩这个坑:
const obj = {};
for await (const x of obj) {
// 这里会报错 obj is not async iterable
}
对象必须实现了[Symbol.asyncIterator]方法才可以。普通数组虽然是可以同步迭代的,但你不能直接在普通数组上用for await吗?其实是可以的!因为for await会先检查是不是异步可迭代,如果不是,退而求其次会把它当成同步可迭代来处理,然后自动把value包装成Promise。for await...of是允许循环同步可迭代对象的。
但反过来,你绝对不能在一个异步可迭代对象上用普通for of,因为for of会去取[Symbol.iterator]方法,异步对象没有。
这一点在实际项目中搞混了会报怪错:
- 如果是异步可迭代,等它.next()返回的是Promise,for of根本不会等。
- 如果同步可迭代,用for await会多一层没用包装,但不会报错。
一个让人主动封装的小模式
有的项目里接口翻页有个特殊逻辑:如果当前页的数据量刚好等于pageSize,并不能说明还有更多,因为可能最后一页刚好满员。需要额外传一个“是否还有下一页”的标记。
所以有时候会看到接口返回类似hasMore: true这种字段。那就可以把异步生成器里那个“list.length < pageSize”判断改为接口的hasMore字段。
async function* customPager() {
let page = 1;
let hasMore = true;
while (hasMore) {
const res = await fetchSomething({ page });
yield* res.data.list; // yield* 可以展开一个数组
hasMore = res.data.hasMore;
page++;
}
}
记住少做“把所有页的数据装到一个数组再循环”的傻事。用异步生成器,数据到了就处理,内存占用非常稳定。上面那个例子中,即使total有几百万条,你依然只用处理当前的一条/块,不会卡死进程。
异常处理有一点点不同
在异步生成器里面,如果fetchPage抛错了,错误会顺着for await的循环抛出来。可以用try/catch捕获:
try {
for await (const user of paginateUsers(10)) {
console.log(user);
}
} catch (err) {
console.error('抓取过程出错:', err);
}
但有个特别容易忽略的点:如果在普通异步可迭代对象的next()里抛错,上一次迭代可能已经消耗掉一页数据。而异步生成器在yield抛出异常时,仍然可以恢复,但如果你不捕获,它就会自动中断。所以建议在生成器内部做好局部重试,避免整条流停掉。
和我之前用递归翻页的对比
以前写分页拉取,大概率是这种模式:
function loadPage(page, callback) {
fetchPage(page).then(res => {
callback(res.data.list);
if (page * pageSize < total) {
loadPage(page + 1, callback);
} else {
console.log('全部完成');
}
});
}
回调版本的问题在于:你很难在中间“停一下”处理其他事情。比如你需要在取到第5条时把之前的数据去重,那必须把去重逻辑放进回调里,一层层嵌套。而异步生成器让你用直线式的思维去写。
还有就是我以前尝试过用数组push所有页的数据,但遇到大接口就内存爆炸。这个做法也不可取。
最后的思考
异步迭代器在JavaScript里不算是什么新东西,但很多前端同学并不熟悉它。可能是因为平时业务多以“一次性请求”为主,一次拿完整数组,前端顶多用个map、filter。
但是一旦遇到流式数据、长分页、大文件、SSE推送这类场景,异步迭代器就是最顺手的那把刀。以后你想写一个“边拉取边显示进度”的滚动列表,不需要再维护一堆外部状态了,试试把请求源做成一个异步迭代器,你会回来感谢我的。
建议你现在打开编辑器,把上面的paginateUsers跑一遍,再改一改,改成你自己的接口。实际跑通一次就会明白,原来处理异步数据流也能这么舒服。

