有些题目,知识点你全都学过,就是没写出来。
这道题就是这样。Promise.all 我用过,二分拆数组练习过,递归思路也写过——但坐在那里看着题目,脑子里这三块东西各自飘着,就是没拼在一起。
后来我意识到:问题不是"不知道",而是不知道怎么从题目里读出信号,把分散的知识激活。这篇文章就是在复盘这个读题 → 拆解 → 拼接的过程。
先读题,不要急着写代码
const addRemote = async (a, b) => new Promise(resolve => {
setTimeout(() => resolve(a + b), 1000)
})
async function add(...inputs) {
// 你的实现
}
刚看到题目时,我的第一反应是:这不就是数组求和吗,循环一遍不就好了?
但这道题有两个关键限制,藏在题目结构里,值得逐条拎出来。
限制一:addRemote(a, b) 只接受两个参数。
意味着多个数字无法一次性求和,必须拆成多次两两相加。如果有 n 个数,至少需要调用 n-1 次。
限制二:每次调用耗时约 1 秒(setTimeout 1000ms)。
这个细节是关键信号。出题人特意设置了延迟,暗示的是:如何安排调用顺序,决定了总耗时。如果所有调用只能串行,n 个数就要等 (n-1) 秒。但如果可以并发——
这时候第一个问题自然就出来了:哪些调用可以同时发出去?
第一步:识别"可并发"的结构
带着这个问题重新看题目,想象 inputs = [1, 2, 3, 4, 5, 6, 7, 8] 八个数。
串行的方案是:
add(1,2) → 结果3
add(3,3) → 结果6
add(6,4) → 结果10
...依次等待,共 7 次,7 秒
每一步都依赖上一步的结果,没有任何并发空间。
但如果换个角度:把互相独立的数先两两配对,它们之间没有依赖关系,就可以同时发出去:
Round 1:add(1,2) add(3,4) add(5,6) add(7,8) → 4 个请求同时发出,等 1 秒
Round 2:add(3,7) add(11,15) → 2 个请求同时发出,等 1 秒
Round 3:add(10,26) → 1 个请求,等 1 秒
总耗时:3 秒,而不是 7 秒
这个结构有个名字:二分归约。把数组两两配对,每轮并发处理,结果收拢后进入下一轮,直到只剩一个数。
到这里,我从题目里读到了两个信号:
- "只接受两个参数" → 必须两两操作 → 自然联想到两两配对、二分
- "固定 1 秒延迟" → 出题人在暗示时间是变量 → 要想办法让调用并发起来
第二步:把结构翻译成代码工具
现在结构清楚了,下一步是:用什么工具来实现"同时发出多个请求,等所有结果回来"?
这时候 Promise.all 就被激活了。
它的语义刚好匹配这个需求:接收一个 Promise 数组,并发执行,等全部完成后返回结果数组。
// 环境:Node.js / 浏览器
// 验证 Promise.all 的并发语义
const p1 = new Promise(r => setTimeout(() => r('A'), 1000));
const p2 = new Promise(r => setTimeout(() => r('B'), 1000));
const p3 = new Promise(r => setTimeout(() => r('C'), 1000));
console.time('parallel');
const results = await Promise.all([p1, p2, p3]);
console.timeEnd('parallel'); // ~1000ms,而不是 3000ms
console.log(results); // ['A', 'B', 'C']
三个独立的 1 秒请求,Promise.all 让它们并发,总耗时还是约 1 秒。这正是每一轮我们需要的行为。
第三步:找到"每轮之后"的逻辑,识别递归结构
现在我有了"每轮怎么做":把当前数组两两配对,用 Promise.all 并发执行,拿到结果数组。
但还差一步:拿到结果数组之后,怎么办?
结果数组其实和原始 inputs 的结构是一样的——都是一组等待被求和的数字,只是变少了。这意味着:可以把同一套逻辑重新用在结果数组上。
这是识别递归的典型信号: "下一步的结构和当前步骤相同,只是规模缩小了" 。
加上终止条件——当数组只剩一个数时,直接返回——递归结构就完整了:
add([1,2,3,4,5,6,7,8])
→ Round 1 results: [3, 7, 11, 15]
→ add([3, 7, 11, 15])
→ Round 2 results: [10, 26]
→ add([10, 26])
→ Round 3 results: [36]
→ return 36 ✓
把三块拼在一起:完整实现
现在三个知识块的角色都清楚了:
- 二分配对:决定每轮如何拆分
inputs Promise.all:让每轮的请求并发执行- 递归:把"每轮之后拿到新数组"和"对新数组重复同样操作"连接起来
// 环境:Node.js 14+ / 现代浏览器
// 场景:多个异步加法的最优并发归约
async function add(...inputs) {
// base case: single element, nothing to add
if (inputs.length === 1) return inputs[0];
// build concurrent pairs for this round
const pairs = [];
for (let i = 0; i < inputs.length; i += 2) {
if (i + 1 < inputs.length) {
// normal pair
pairs.push(addRemote(inputs[i], inputs[i + 1]));
} else {
// odd element: carry forward without a remote call
pairs.push(Promise.resolve(inputs[i]));
}
}
// fire all pairs concurrently, wait for all results
const results = await Promise.all(pairs);
// recurse with the reduced array
return add(...results);
}
奇数元素的处理值得单独说一句:当 inputs 长度为奇数时,最后一个元素没有配对对象。用 Promise.resolve(inputs[i]) 把它原样"包装"成 Promise,和其他请求一起放入 Promise.all,这样结构上保持统一,也不浪费一次远程调用。
复杂度分析
回头看这个执行结构,其实是一棵并发执行的完全二叉树:
- 叶节点:原始输入(
n个) - 内部节点:每次
addRemote调用(共n - 1次) - 树高:
⌈log₂n⌉,即总轮数,也是实际等待的秒数
| n(输入个数) | 串行方案耗时 | 二分方案耗时 |
|---|---|---|
| 4 | 3 秒 | 2 秒 |
| 8 | 7 秒 | 3 秒 |
| 64 | 63 秒 | 6 秒 |
| 1024 | 1023 秒 | 10 秒 |
时间复杂度从 O(n) 降到了 O(log n) ,调用次数仍然是最少的 n - 1 次。
同一套方法,换三道题来验证
方法论只说一遍不够,要能迁移才算真的理解。下面用同样的框架——先找约束,再识别结构,再选工具——来拆解三道看起来"不相关"的题。
例一:并发请求图片,但最多同时发 3 个
题目是这样的:给定一批图片 URL,要求并发加载,但同时进行中的请求不能超过 3 个。
先读约束:
"并发加载" → 不是串行,需要同时发多个请求,Promise.all 的方向。
"不超过 3 个" → 但不是全部并发,有上限。这是新的约束,意味着 Promise.all 直接用不够,需要一个"滑动窗口":有请求完成时,立刻补进来新的,保持始终有 3 个在飞。
这个结构有个描述:并发控制池。请求完成一个,槽位释放一个,马上填入下一个。
// 环境:浏览器 / Node.js
// 场景:限制最大并发数为 concurrency 的批量请求
async function loadWithLimit(urls, concurrency = 3) {
const results = new Array(urls.length);
let index = 0;
async function worker() {
while (index < urls.length) {
const current = index++; // claim a slot
results[current] = await fetch(urls[current]); // process it
}
}
// start exactly `concurrency` workers, each loops until exhausted
await Promise.all(
Array.from({ length: concurrency }, worker)
);
return results;
}
这里有一个容易误读的地方:看到 Promise.all 加上数量限制,很容易以为执行方式是"每批 3 个,等这批全完成再开下一批"——
// 误以为是这样:
Round 1: fetch(url[0]) fetch(url[1]) fetch(url[2]) → 等全部完成
Round 2: fetch(url[3]) fetch(url[4]) fetch(url[5]) → 等全部完成
但实际上不是。Array.from({ length: 3 }, worker) 启动的是 3 个各自独立跑 while 循环的 worker,它们共享 index 这个取号机。每个 worker 完成一个请求后,立刻自己去取下一个号,不等其他 worker。
具体走一遍,假设有 6 个 URL:
初始:index = 0
worker-1:current = 0,index → 1,开始 fetch(url[0]),await,暂停
worker-2:current = 1,index → 2,开始 fetch(url[1]),await,暂停
worker-3:current = 2,index → 3,开始 fetch(url[2]),await,暂停
此刻飞行中:url[0], url[1], url[2]
假设 url[1] 最先完成,worker-2 从 await 恢复,继续 while:
worker-2:current = 3,index → 4,开始 fetch(url[3]),await,暂停
此刻飞行中:url[0], url[2], url[3] ← url[0] 和 url[2] 还没完成,url[3] 已经开始了
任何时刻飞行中的请求始终维持在 3 个,谁先完成谁先取下一个任务,不空转。批次模式里,这一批最慢的请求会拖住所有人;worker 池没有"这一批"的概念,快的 worker 永远不等慢的。
这个模式能工作,依赖两个前提:任务之间相互独立(谁先做谁后做不影响结果),以及 index++ 是同步操作(JS 单线程保证不会两个 worker 拿到同一个号,多线程语言里这里需要加锁)。
回头对比原题:add 每一轮依赖上一轮的结果,任务之间有依赖,没法让 worker 自由抢占,只能按轮次显式控制。 "任务之间有没有依赖",是选择 worker 池还是按轮次归约的那个约束。
例二:实现 pipe,把多个函数串起来
这道题不涉及异步,但读题路径和原题几乎是镜像:
// 要求:pipe(f, g, h)(x) 等价于 h(g(f(x)))
function pipe(...fns) {
// 你的实现
}
读约束:
"每个函数只接受一个参数" → 和 addRemote 只接受两个数一样,是操作粒度的限制,意味着必须多步串联。
"前一个函数的输出是后一个函数的输入" → 每步之间有数据依赖,不能并发,只能串行。这和原题的串行阶段一样,但原题想办法破除了串行,这道题的串行依赖是无法破除的——题目本身就是在建模串行。
这两个约束读出来后,结构就清楚了:线性归约,每步把上一步的结果传给下一步。工具是 reduce——它刚好描述的是"用一个函数把数组折叠成一个值,每步的中间结果传递给下一步"。
// 环境:浏览器 / Node.js
// 场景:函数组合,从左到右执行
function pipe(...fns) {
return (x) => fns.reduce((acc, fn) => fn(acc), x);
}
// usage
const process = pipe(
x => x * 2,
x => x + 1,
x => `result: ${x}`
);
console.log(process(3)); // "result: 7"
和原题的对比很有意思:同样是"只能两两操作"的约束,原题因为操作之间相互独立,所以可以并发;这道题因为操作之间有依赖,所以只能串行。 约束不同,结构不同,工具不同——但读题的框架是同一套。
例三:实现 debounce
// 要求:在事件持续触发时,只在停止触发后的 delay 毫秒执行一次
function debounce(fn, delay) {
// 你的实现
}
读约束:
"持续触发时不执行" → 不是每次调用都触发,说明需要某种"抑制"机制,已有的定时器需要被取消。
"停止后 delay 毫秒才执行" → 每次新触发都重置等待时间,意味着要清掉上一次的计时,重新开始。这是"重置"语义。
"只执行一次" → 是最后那次触发后的延迟结束时执行,不是第一次,也不是每次。
三个约束叠加描述了一个结构:维护一个定时器,每次触发时清掉它(clearTimeout)再重新设(setTimeout),只有没有被清掉的那次才真正执行。
// 环境:浏览器
// 场景:输入框搜索、窗口 resize 等高频事件节流
function debounce(fn, delay) {
let timer = null;
return function (...args) {
// cancel previous pending execution
clearTimeout(timer);
// reschedule
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
这道题几乎没有什么"知识点"需要记忆——setTimeout 和 clearTimeout 人人都知道。真正的难点在于:能不能从"持续触发时抑制、停止后执行"这个描述里,读出"每次都重置定时器"这个结构。
读出来了,代码几乎是自然而然写出来的。
小结:读题是一种可以练习的能力
把这四道题放在一起看,读题路径是一致的:
1. 把约束逐条拎出来:不要整体看题,要把每一句限制单独列出来,想想它在暗示什么。
2. 把约束翻译成结构描述:不是"这道题要用 Promise.all",而是"这道题有一组相互独立的操作,需要同时发出、统一等待结果"——先用自然语言描述结构,工具是最后才出现的。
3. 结构对上了,工具自然浮出来:每个工具背后都有一个它最适合解决的结构问题。Promise.all = 独立操作并发等待,reduce = 线性归约,clearTimeout + setTimeout = 重置式延迟执行。记住的是"结构—工具"的映射,而不是"题型—答案"的映射。
这套路径练多了,题目里的约束会越来越像"提示词",而不是干扰信息。
下次遇到不会的题,与其直接看答案,不如先问自己:这道题的约束,在描述一个什么样的结构?
参考资料
- MDN - Promise.all() - 并发等待的标准工具
- MDN - Promise.allSettled() - 不短路的并发等待,适合处理部分失败
- MDN - Promise.race() - 竞速语义,常用于超时控制