分页拉全量数据这件事,写过的都懂:第一版永远是一层 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 管聚合。下次再遇到「多接口聚合 + 容错 + 统计」的活,直接从这份骨架改就行。

