JavaScript异步生成器完全指南:20行代码搞定分页API和无限滚动

上个月接了个后台管理的需求,前端要拉取一个历史订单接口,每次只能返回 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

它不是银弹,但确实让“异步数据产生/消费”的代码变得更像同步思维。没有嵌套回调,没有状态机,没有一堆中间变量。尤其是需要逐步处理大量数据时,生成器的惰性执行可以大大降低内存占用。

如果你还在用传统的数组拼接分页数据,建议试试把它重构成异步生成器。第一次可能有点绕,但用顺手后,你会发现自己的代码前所未有地干净。

有什么更好的技巧或踩坑案例,欢迎在评论区聊,我有问必答。

JavaScript异步生成器完全指南:20行代码搞定分页API和无限滚动
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 javascript JavaScript异步生成器完全指南:20行代码搞定分页API和无限滚动 https://www.taomawang.com/web/javascript/2600.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务