ES2025 实战:用 Promise.withResolvers 和迭代器辅助方法重写分页聚合

分页拉全量数据这件事,写过的都懂:第一版永远是一层 for 循环套 await,串行跑完六页要两秒多;第二版改成 Promise.all,速度上去了,但只要第三页抖一下,整批直接报废;第三版加上重试和并发限制,代码就已经膨胀到没人愿意看了。

最近一年 JavaScript 语言层面陆续落地了几个东西,正好卡在这个场景的痛点上。本文拿一个 117 条数据、每页 20 条、第 3 页必然抽风一次的假接口,把整套流程重写一遍,顺便把几个容易踩的坑标出来。

先约定数据源

为了让后面所有代码都能直接贴进控制台跑,接口用 setTimeout 模拟。第 3 页第一次请求固定抛 502,第二次成功 —— 这样重试逻辑是真能验证到的,而不是写完看着像对的。

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

const PAGE_SIZE = 20;
const TOTAL = 117;
const flakyOnce = new Set([3]);

async function fetchPage(page) {
  await sleep(80 + Math.random() * 160);

  if (flakyOnce.has(page)) {
    flakyOnce.delete(page);
    throw Object.assign(new Error(`HTTP 502 /api/items?page=${page}`), { code: 502 });
  }

  const start = (page - 1) * PAGE_SIZE;
  const size = Math.min(PAGE_SIZE, TOTAL - start);
  const items = Array.from({ length: size }, (_, i) => {
    const id = start + i + 1;
    return {
      id,
      title: `entry-${id}`,
      tag: ['js', 'css', 'html', 'rust'][id % 4],
      score: (id * 37) % 100
    };
  });

  return { page, total: TOTAL, items };
}

并发闸门:这次为什么用 Promise.withResolvers

并发控制器的经典写法是 new Promise((resolve, reject) => { ... }),把 resolve 和 reject 手动塞进队列里。麻烦在于这两个函数在 executor 内部才拿得到,想往外传只能先声明变量再赋值,多一层别扭的闭包。

Promise.withResolvers() 就是干这个的:一次拿到 { promise, resolve, reject },外面随便存。

function createLimit(concurrency) {
  const queue = [];
  let running = 0;

  function pump() {
    while (running < concurrency && queue.length) {
      const job = queue.shift();
      running += 1;
      Promise.try(job.task)
        .then(job.resolve, job.reject)
        .finally(() => {
          running -= 1;
          pump();
        });
    }
  }

  return function limit(task) {
    const { promise, resolve, reject } = Promise.withResolvers();
    queue.push({ task, resolve, reject });
    pump();
    return promise;
  };
}

注意这里是 Promise.try(job.task) 而不是 job.task().then(...)。区别在于:如果 task 内部有同步 throw(比如参数校验失败、读了个未定义的属性),job.task() 会在 .then 挂上去之前就把异常甩到调用栈上,队列的 running 计数永远不会减回去,整个闸门卡死。Promise.try 把同步异常统一转成 rejection,闸门才是安全的。

重试:Promise.try 补上的那个坑

重试逻辑本身不复杂,指数退避三步就写完了。真正容易出问题的是「被重试的那个函数可能同步抛错」—— 这种错误不会变成 rejection,try/catch 里的 await 根本接不住它。

async function retry(task, { attempts = 2, baseDelay = 250 } = {}) {
  let lastError;

  for (let i = 0; i <= attempts; i += 1) {
    try {
      return await Promise.try(task);
    } catch (err) {
      lastError = err;
      if (i === attempts) break;
      await sleep(baseDelay * 2 ** i);
    }
  }

  throw lastError;
}

两次重试、250ms 起步翻倍,总等待最长 750ms。第 3 页那次 502 会在第二次尝试时成功,日志里能看到一次 250ms 的间隔。

超时包装:withResolvers 最舒服的用法

超时是个典型的「我需要在外部主动结束这个 promise」的场景。用 withResolvers 写出来只有几行,而且不用管 resolve 有没有被用到(没用到就放着,不影响)。

function withTimeout(task, ms) {
  const { promise, resolve, reject } = Promise.withResolvers();

  const timer = setTimeout(() => {
    reject(Object.assign(new Error(`请求超时 ${ms}ms`), { name: 'TimeoutError' }));
  }, ms);

  Promise.try(task).then(resolve, reject);

  return promise.finally(() => clearTimeout(timer));
}

有个细节值得说清楚:超时只是让外层的 promise 提前 reject,底层那个 fetchPage 并不会真的中断,它还会占着并发槽位直到自己结束。真要能取消,得把 AbortController 一路传下去,那是另一个话题了。所以 ms 不要设得比单次请求的预期耗时还小,否则闸门会被僵尸请求堵住。

聚合:单页失败不该拖垮整批

这里我不用 Promise.all。Promise.all 是 fail-fast 的,一页挂了整批 reject,前五页白拉。Promise.allSettled 保留结果数组的顺序(跟输入顺序一致),可以精确定位是哪一页挂了。

async function loadAll(limit) {
  const first = await withTimeout(() => fetchPage(1), 3000);

  const pageCount = Math.ceil(first.total / PAGE_SIZE);
  const rest = Array.from({ length: pageCount - 1 }, (_, i) => i + 2);

  const settled = await Promise.allSettled(
    rest.map((p) => limit(() => retry(() => withTimeout(() => fetchPage(p), 3000),
      { attempts: 2 })))
  );

  const items = [...first.items];
  const failed = [];

  settled.forEach((r, i) => {
    if (r.status === 'fulfilled') items.push(...r.value.items);
    else failed.push({ page: rest[i], message: r.reason.message });
  });

  return { items, failed };
}

第一页先单独拉,是因为总页数要靠 total 算出来,这一步没法并发。剩下的 5 页一次性丢进闸门,并发上限设 3。

惰性筛选:迭代器辅助方法真正省下的东西

过去想「先过滤、再映射、只取前 10 条」,只能写 filter().map().slice(0, 10)。问题是 filter 和 map 都会把 117 条完整遍历并生成两个中间数组,最后只留 10 条,中间那些全是浪费。

迭代器辅助方法是惰性的:数据一条一条往下流,take(10) 凑够了就直接停,后面的元素根本不会被碰到。

const top10 = items
  .values()
  .filter((it) => it.score >= 40)
  .map((it) => ({ ...it, tier: it.score >= 80 ? 'A' : 'B' }))
  .take(10)
  .toArray();

两个必须记住的点:

  • 这些方法挂在 Iterator.prototype 上,链式调用的返回值是迭代器,不是数组。要用结果必须显式 .toArray(),或者 for...of 消费掉,或者 Array.from()。
  • 迭代器是一次性的。同一个迭代器跑完再跑一遍,拿到的是空数组,不会报错,非常容易在调试时误判成数据丢了。

可用的方法一共 12 个:map、filter、take、drop、flatMap、reduce、toArray、forEach、some、every、find、take。数组、Set、Map、字符串的 values() / entries() / keys() 全部能用。

分组统计:Object.groupBy 和它的 null 原型

按标签分组算平均分,以前要用 reduce 手搓累加器,现在一行搞定:

const byTag = Object.groupBy(items, (it) => it.tag);

const summary = Object.entries(byTag).map(([tag, list]) => ({
  tag,
  count: list.length,
  avg: +(list.reduce((s, it) => s + it.score, 0) / list.length).toFixed(1)
}));

console.table(summary);

Object.groupBy 返回的是一个原型为 null 的对象。这是有意设计的 —— 避免 __proto__、constructor 这类键名污染。但副作用是:

byTag.hasOwnProperty;   // undefined
byTag.toString;         // undefined
byTag instanceof Object; // false

所以别对返回值调任何 Object 原型方法,要判断键存在就用 Object.hasOwn(byTag, 'js') 或者 Object.keys(byTag)。

另一个限制:键会被强制转成字符串。如果你要按对象、数组这类引用做分组依据,得换 Map.groupBy,它保留原始键类型:

const byBucket = Map.groupBy(items, (it) => Math.floor(it.score / 25));
for (const [bucket, list] of byBucket) {
  console.log(`分数段 ${bucket}: ${list.length} 条`);
}

拼起来的完整实现

把上面几段按顺序放进同一个文件(顶层 await 需要 ES Module 环境,或者用 node --experimental-top-level-await,新版 Node 默认已支持):

const limit = createLimit(3);

const started = performance.now();
const { items, failed } = await loadAll(limit);
const cost = (performance.now() - started).toFixed(0);

console.log(`共 ${items.length} 条,耗时 ${cost}ms`);
console.log(failed.length ? `失败页:${JSON.stringify(failed)}` : '全部成功');

const top10 = items
  .values()
  .filter((it) => it.score >= 40)
  .map((it) => ({ ...it, tier: it.score >= 80 ? 'A' : 'B' }))
  .take(10)
  .toArray();

console.log(top10.map((it) => `${it.title}(${it.score}/${it.tier})`).join(' '));

const byTag = Object.groupBy(items, (it) => it.tag);
console.table(
  Object.entries(byTag).map(([tag, list]) => ({
    tag,
    count: list.length,
    avg: +(list.reduce((s, it) => s + it.score, 0) / list.length).toFixed(1)
  }))
);

预期输出里 共 117 条、全部成功,总耗时大致在 600–900ms 之间。把并发上限从 3 改成 1 试一下,时间会跳到 1.2 秒往上;改成 6,因为页数只有 5,反而没有提升 —— 这也是并发上限该按实际页数定的原因,不是越大越好。

兼容性对照

API Chrome Firefox Safari Node
Promise.withResolvers 119+ 121+ 17.4+ 22+
Promise.try 128+ 134+ 18.2+ 23+
Iterator helpers 122+ 131+ 18.4+ 22+
Object.groupBy 117+ 119+ 17.4+ 21+

四个 API 里跨度最大的是 Promise.try,如果你要支持 Safari 18.0 或者 Node 22 LTS,它得打补丁。好在补丁短得离谱,而且行为完全一致:

Promise.try ??= function tryFn(fn, ...args) {
  return new Promise((resolve) => resolve(fn(...args)));
};

同步抛错会被 new Promise 的 executor 捕获并转成 rejection,跟原生实现走的是同一条路径。Object.groupBy 的补丁也不长:

Object.groupBy ??= function groupBy(iterable, keyFn) {
  const out = Object.create(null);
  let i = 0;
  for (const item of iterable) {
    const key = keyFn(item, i++);
    (out[key] ??= []).push(item);
  }
  return out;
};

几个踩过的坑,顺手记一下

  • 迭代器辅助方法不能链在数组上。items.filter(...).take(10) 会报 take is not a function,因为 Array.prototype.filter 返回的是数组。必须先从 .values() 进去。
  • 不要把一个迭代器存起来反复用。第二次消费拿到空结果,而且没有任何报错提示,排查起来很费劲。
  • Promise.allSettled 的结果数组顺序是固定的,和输入顺序严格对应,可以放心用下标去反查是哪一页失败。
  • 超时不等于取消。超时 reject 之后底层请求还在跑,还在占并发槽。要么把 AbortSignal 传下去,要么把超时时间设得足够宽。
  • Object.groupBy 返回 null 原型对象,JSON.stringify 正常,但任何 hasOwnProperty 调用都会炸。

这套组合下来,代码行数比传统写法少了一截,但真正的收益不在行数上 —— 在于每一步的意图都更直白:闸门管并发、Promise.try 管异常收敛、迭代器管惰性、groupBy 管聚合。下次再遇到「多接口聚合 + 容错 + 统计」的活,直接从这份骨架改就行。

ES2025 实战:用 Promise.withResolvers 和迭代器辅助方法重写分页聚合
收藏 (0) 打赏

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

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

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

淘吗网 javascript ES2025 实战:用 Promise.withResolvers 和迭代器辅助方法重写分页聚合 https://www.taomawang.com/web/javascript/2962.html

常见问题

相关文章

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

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