上个月接了个后台管理的需求,前端要拉取一个历史订单接口,每次只能返回 20 条,总共 5000 多条数据。一开始我用 for 循环加 offset 去请求,代码写出来又臭又长,中途还要考虑请求间隔和错误重试。后来我用 AsyncGenerator 重构了一下,整个数据获取逻辑不到 20 行,而且维护起来特别丝滑。
这篇文章就从最基础的概念开始,一步步写一个能直接用的异步生成器,用于分页请求。最后再拓展到无限滚动场景,顺便聊聊取消请求和内存回收。
先从同步生成器回忆一下
生成器函数就是带星号的函数,它执行不会立即跑完,而是在遇到 yield 时暂停。每次调用 next() 继续执行,直到下一个 yield 或 return。比如:
function* naturalNumbers() {
let n = 1;
while (true) {
yield n;
n++;
}
}
const it = naturalNumbers();
console.log(it.next().value); // 1
console.log(it.next().value); // 2
console.log(it.next().value); // 3
同步生成器很好懂,但网络请求是异步的,所以我们需要异步生成器 —— 它允许你 yield 一个 Promise,并且可以用 await 等待内部异步操作完成。
异步生成器长什么样
写法上只需要在 function 后面加一个 async 星号:
async function* asyncGenerator() {
const data = await fetch('/some/api');
const json = await data.json();
yield json;
}
for await (const item of asyncGenerator()) {
console.log(item);
}
for await...of 是异步迭代器的消费方式。它会自动等待每次 yield 的 Promise resolve,然后继续执行。这就是我们今天的主角。
实战:用 AsyncGenerator 拉取分页 API
假设有个订单接口 /api/orders?page=1&pageSize=20,返回结构:
{
"list": [...],
"total": 5678,
"page": 1,
"pageSize": 20,
"hasMore": true
}
传统的异步循环写法通常是递归或者 while 循环加 offset,但代码特别容易失控。我写了一个 AsyncGenerator 来封装分页逻辑:
async function* paginateOrders(pageSize = 20) {
let page = 1;
let hasMore = true;
while (hasMore) {
const url = `/api/orders?page=${page}&pageSize=${pageSize}`;
const res = await fetch(url);
const data = await res.json();
// 连续把当前页的数据 yield 出去
for (const order of data.list) {
yield order;
}
hasMore = data.hasMore;
page++;
}
}
然后业务代码只需要一段 for await 就全部搞定:
let totalAmount = 0;
for await (const order of paginateOrders(50)) {
totalAmount += order.amount;
if (order.riskLevel === 'high') {
console.warn('发现高风险订单:', order.id);
}
}
console.log('所有订单总金额:', totalAmount);
你看,我不需要关心页码怎么递增,也不用处理边界条件。数据像流水一样从生成器里涌出来,每一条都能被及时消费。
打破惯性:在生成器内部做数据转换
有时候不只是取数据,还希望把从接口拿到的原始数据处理成更友好的结构。以前我会在 for await 里做 mapper,但更优雅的方式是放在生成器内部,让生成器直接产出最终可用的对象。
async function* getProcessedOrders(pageSize = 20) {
let page = 1;
let hasMore = true;
while (hasMore) {
const res = await fetch(`/api/orders?page=${page}&pageSize=${pageSize}`);
const data = await res.json();
for (const raw of data.list) {
// 在这里完成数据清洗
yield {
id: raw.id,
buyerName: raw.user?.nickname || '未知',
amount: Number(raw.total_amount),
commodityTags: raw.tags?.split(',') || [],
createdAt: new Date(raw.create_time).toISOString()
};
}
hasMore = data.hasMore;
page++;
}
}
这样做的好处是,消费者拿到的数据结构完全由上层决定,不会把接口的字段命名风格泄漏到页面组件里。
加一些实用功能:请求间隔和错误重试
实际项目中拉取大量数据时,如果瞬间发几百个请求,后端很可能 429。所以我在生成器里加了一个固定的延迟,并且对单页请求最多重试 3 次。
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
async function* paginateWithRetry(pageSize = 20, delayMs = 300) {
let page = 1;
let hasMore = true;
while (hasMore) {
let retryCount = 0;
let data = null;
while (retryCount < 3) {
try {
const res = await fetch(`/api/orders?page=${page}&pageSize=${pageSize}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
data = await res.json();
break;
} catch (err) {
retryCount++;
if (retryCount === 3) {
throw new Error(`第 ${page} 页请求失败,已重试3次: ${err.message}`);
}
await sleep(500 * retryCount);
}
}
for (const order of data.list) {
yield order;
}
hasMore = data.hasMore;
page++;
await sleep(delayMs); // 控制请求频率
}
}
这里生成器内部自己管理重试,外部代码完全无感知。调用者还是原来那个 for await,但健壮性已经提升了一个档次。
无限滚动:用 for await 实现真正的流式加载
做移动端列表时,常见的是页面到底部自动加载下一页。以前用 IntersectionObserver + 手动状态管理,代码需要维护 loading、hasMore、currentPage。用异步生成器,可以把整个加载逻辑变成一个异步迭代器。
async function* feedGenerator() {
let page = 1;
while (true) {
const res = await fetch(`/api/feed?page=${page}`);
const data = await res.json();
for (const item of data.items) {
yield item;
}
if (!data.hasMore) break;
page++;
}
}
// 在页面初始化时创建一个生成器实例
const feedIterator = feedGenerator();
// 滚动到底部时,拉取下一页并追加
async function loadNextPage() {
const { value, done } = await feedIterator.next();
if (!done) {
// 把 value 追加到列表
appendItems(value);
} else {
// 没有更多了
}
}
这样你可以按需调用 next(),和滚动事件完全解耦。不需要额外的“是否正在加载”标志,因为生成器每次 next() 只返回一页中的一条数据。注意这里我故意每页 yield 一条,而不是 yield 一个数组,以便更细致地控制加载粒度。如果你倾向于一页全部加载,也可以把 yield 改成数组。
取消请求:AbortController 与生成器一起用
一个常见需求是用户切换页面或关闭弹窗时,应该中止后续请求。虽然我们不能真正取消生成器,但可以通过传入 AbortSignal 来终止内部 fetch。
async function* cancellableFeed(signal) {
let page = 1;
while (true) {
const res = await fetch(`/api/feed?page=${page}`, { signal });
const data = await res.json();
for (const item of data.items) {
yield item;
}
if (!data.hasMore) break;
page++;
}
}
// 使用
const controller = new AbortController();
// 用户点击取消时
controller.abort();
// 生成器会在下一次fetch时抛出AbortError
for await (const item of cancellableFeed(controller.signal)) {
// 处理item
}
唯一要注意的是,AbortError 会直接冒泡到消费方,所以 for await 外围最好加上 try/catch。
内存回收:别让生成器停留在内存里
生成器一旦创建,会维护自己的执行上下文。如果你在无限滚动中创建了生成器,但页面销毁后没有引用它,它就会成为垃圾。但在单页应用里,你得手动调用 return() 来让生成器彻底结束。
const feed = feedGenerator();
// 页面卸载时
onUnmounted(() => {
feed.return?.(); // 让生成器停止
});
生成器的 return() 方法会执行 finally 块并停止后续 yield。我的建议是在生成器内部用 try/finally 做清理,比如清除定时器或标记状态。
async function* generatorWithCleanup() {
try {
while (true) {
yield await fetchData();
}
} finally {
console.log('生成器被关闭,清理资源');
}
}
更多的玩法:用异步生成器串行处理文件上传/读取
除了分页,异步生成器在处理文件流时也很香。比如读取一个超大文件并逐行处理,Node.js 中可读流和异步生成器就是好搭档。
async function* readLines(file) {
const stream = file.stream();
const reader = stream.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newlineIndex;
while ((newlineIndex = buffer.indexOf('n')) !== -1) {
const line = buffer.slice(0, newlineIndex);
buffer = buffer.slice(newlineIndex + 1);
yield line;
}
}
if (buffer.length) yield buffer;
} finally {
reader.releaseLock();
}
}
// 消费
for await (const line of readLines(fileObj)) {
console.log('处理一行:', line);
}
注意这里 finally 非常重要,确保 reader 被释放。
异步生成器的性能边界
每 yield 一次,生成器都要恢复一次执行上下文。如果每条数据非常轻量(比如简单对象),每次 next() 的开销几乎可以忽略。但如果每条数据很大,或者消费端处理很慢,生成器内部不会预取下一批,不会浪费请求。
不过有一个潜在问题:如果你在 for await 中做了很重的操作,生成器会一直等待当前操作完成,不会提前请求下一页。这在某些高吞吐场景下可能成为瓶颈。解决办法是引入“预取”机制,但那样会增加复杂度。实际上,对大多数应用场景来说,这种串行行为反而是我们想要的。
最后:为什么我建议你多用 AsyncGenerator
它不是银弹,但确实让“异步数据产生/消费”的代码变得更像同步思维。没有嵌套回调,没有状态机,没有一堆中间变量。尤其是需要逐步处理大量数据时,生成器的惰性执行可以大大降低内存占用。
如果你还在用传统的数组拼接分页数据,建议试试把它重构成异步生成器。第一次可能有点绕,但用顺手后,你会发现自己的代码前所未有地干净。
有什么更好的技巧或踩坑案例,欢迎在评论区聊,我有问必答。

