
最近两轮面试我都让候选人在白板上手写 Promise 相关代码。四个人里两个挂在 Promise.all 的手写 上一个在 并发限制器 卡了十几分钟。说实话题并不偏反而是日常工作里天天会遇到的能力用 for 循环安排异步任务的串行、批量收集结果、控制并发数。这篇就把我常用的四道核心题完整拆开讲一遍——一道基础构造函数两道 for 循环和 Promise 结合的典型场景一道进阶的并发限制器。每道题都会给可运行的解法同时把坑点和面试官追问方向写明白。1. 为什么手写题总绕不开 for 循环大家背 Promise 的 API 都背得很熟then、catch、finally、all、race张口就来。但面试官一旦把问题和 for 循环绑在一起很多人就露馅了。原因在于 Promise 本身解决的是异步编排问题而 for 循环是前端批量操作最常用的同步结构两者一结合考察的就不只是 API 记忆而是对异步时序、闭包、共享状态这些底层机制的理解。for 循环在 Promise 场景里通常扮演三个角色。第一个角色是批量发起者。比如给一个图片数组都加上加载逻辑或者在循环里给多个请求注册回调。此时 for 循环同步执行完毕Promise 的回调却会在之后的事件循环才触发。很多面试者会把同步和异步的先后顺序弄混。第二个角色是串行协调者。我们需要多个异步任务按顺序执行比如逐个上传文件、逐条写入数据库。这个需求下 for 循环配合await是天然的组合但对forEach和async的关系不理解就会写出静默失败的代码。第三个角色是并发池的初始化器。比如限制同时最多 5 个请求典型的写法是用 for 循环启动 5 个 worker再用 while 循环让它们持续领取任务。这里面的共享索引和任务分配逻辑是考察候选人工程能力的好地方。另外for 循环本身的变量作用域也是考点var、let、const在循环中的表现结合 Promise 的异步回调会形成经典的闭包陷阱。下面四道题里这些问题全部会出现。2. 第一题手写 Promise 构造函数先把地基打牢不管后面的题多花哨手写 Promise 构造函数始终是面试的第一关。我一般要求候选人实现以下核心能力状态只能从pending变为fulfilled或rejected且不可逆then能注册回调状态变化后执行executor执行时如果抛出异常要自动走reject能支持基本链式调用2.1 骨架状态机、闭包、回调数组先来基础版本class SimplePromise { constructor(executor) { this.state pending; this.value undefined; this.reason undefined; this.fulfilledFns []; this.rejectedFns []; const resolve value { if (this.state ! pending) return; this.state fulfilled; this.value value; this.fulfilledFns.forEach(fn fn()); }; const reject reason { if (this.state ! pending) return; this.state rejected; this.reason reason; this.rejectedFns.forEach(fn fn()); }; try { executor(resolve, reject); } catch (error) { reject(error); } } then(onFulfilled, onRejected) { if (this.state fulfilled) { onFulfilled(this.value); } else if (this.state rejected) { onRejected(this.reason); } else { this.fulfilledFns.push(() onFulfilled(this.value)); this.rejectedFns.push(() onRejected(this.reason)); } } }这段代码里有三个关键点我面试时一定会追问第一是为什么用闭包里的 resolve/reject 而不是构造函数的 this 方法。因为 executor 是外部传入的它拿到的是resolve和reject这两个函数。用闭包可以让状态变量state、value、reason一直留在函数内部外部只能通过调用resolve来影响它。如果把状态定义成this.state用户可能意外篡改内部状态。第二是状态保护。if (this.state ! pending) return;这行必须在resolve和reject里都写上。Promise 规范要求第一次调用有效之后的调用全部忽略。面试时我会故意问如果先 resolve 再 reject会怎样 答案不取决于调用顺序而取决于resolve已经合法地改过状态后一个调用会被状态判断拦住。第三是回调数组。因为executor可能是异步的比如new Promise(resolve setTimeout(resolve, 1000))此时then注册回调时状态还在pending回调必须存起来。数组结构保证了多个then能注册多个回调将来依次触发。2.2 链式调用怎么补上面的版本没法真正链式因为then返回的是undefined第二个.then会直接报错。面试中写出这个版本只是第一步紧接着就要上车到链式版本。链式的核心思路是then方法返回一个全新的 Promise由这个新 Promise 来决定上一次回调结果如何传递。先看实现class SimplePromise { constructor(executor) { this.state pending; this.value undefined; this.reason undefined; this.fulfilledFns []; this.rejectedFns []; const resolve value { if (this.state ! pending) return; this.state fulfilled; this.value value; this.fulfilledFns.forEach(fn fn()); }; const reject reason { if (this.state ! pending) return; this.state rejected; this.reason reason; this.rejectedFns.forEach(fn fn()); }; try { executor(resolve, reject); } catch (error) { reject(error); } } then(onFulfilled, onRejected) { const promise2 new SimplePromise((resolve, reject) { const handle () { try { if (this.state fulfilled) { const result typeof onFulfilled function ? onFulfilled(this.value) : this.value; resolve(result); } if (this.state rejected) { const result typeof onRejected function ? onRejected(this.reason) : this.reason; reject(result); } } catch (error) { reject(error); } }; if (this.state fulfilled || this.state rejected) { setTimeout(handle, 0); } else { this.fulfilledFns.push(handle); this.rejectedFns.push(handle); } }); return promise2; } catch(onRejected) { return this.then(null, onRejected); } }这个版本里有几个细节是面试加分项。onFulfilled或onRejected不是函数时怎么办进行值穿透。比如.then(null, onRejected)实际上由catch来用而.then()不带任何参数时Promise 的值应该原样向下传递。上面代码里typeof onFulfilled function ? ... : this.value这段就是在处理穿透。handle函数为什么包try/catch因为用户传给then的回调可能本身抛出异常这个异常必须被捕获并转化成新 Promise 的reject否则链式调用就会在回调抛错后中断。原生 Promise 对这块处理得很严格手写版本至少要保证错误能沿链往下传。用setTimeout来模拟异步调度严格说是用宏任务模拟了微任务。面试时我会直接说明真正的 Promise 回调排在微任务队列里这里用setTimeout只是为了演示then的回调不能同步执行。如果想更接近规范可以用queueMicrotask(handle)替换setTimeout(handle, 0)。一个微小的改动面试官对你的印象会完全不同。2.3 追问方向微任务模拟和 resolvePromise绝大多数面试到这里就足够了但遇到大厂追问会问两个更深的点。第一是resolve 里如果传入一个 Promise 怎么办。原生的Promise.resolve(p)会展开 p等 p 的状态落定后再决定外层 Promise。手写版本里没有处理这种情况。如果要处理需要在resolve里判断value instanceof SimplePromise如果是就调用它的then来传递状态。这不是核心面试题但提一句能展示你了解 Promise/A 规范的resolvePromise过程。第二是then回调的返回值如果是 Promise外层如何处理。上面的实现会把返回值直接resolve(result)而规范要求如果 result 是 Promise需要递归地等待。完整的 Promise/A 实现有几百行面试中暴露你不清楚没关系但最好主动说完整规范还有其他细节比如 onFulfilled 返回值需要进一步 resolve还有 thenable 的处理这里我先给出主流程。第一道题结束。这道题和 for 循环没直接关系但它是后三题的基础。后三道题里所有的 Promise 使用方式都会建立在状态机、回调执行时机、链式传递这三个认知上。3. 第二题for 循环串行执行 Promise三种写法两种是坑这道题的实际场景太常见了上传文件、逐条存库、依次请求分页数据。面试官通常给一段很简单的需求——数组里有 5 个异步任务必须一个完成后再执行下一个请写出实现。3.1 反面教材forEach async 的静默失败最多人踩的坑是下面这个写法async function uploadAll(files) { files.forEach(async (file) { await uploadFile(file); }); console.log(全部完成); }console.log会几乎瞬间执行因为forEach本身是同步方法它不会等待回调函数里的await。传入forEach的async函数被调用后里面的await只会暂停这个匿名函数自己的执行对forEach毫无影响。每个uploadFile任务实际上是并发开始的和串行两个字完全不沾边。forEach也根本不在乎你传的回调返回的是什么——它丢弃了那个 Promise。这就像你把任务交给五个人同时去做却告诉领导我安排他们一个个排队进场了实际上队伍早乱套了。另一个类似的问题是map配合异步。map会返回一个 Promise 数组如果用await files.map(async ...)结果是一个数组每个元素是 Promise任务还是并发的只是你得到了一个并发结果集合。3.2 正解一async/await for 循环最简单的正确写法是async function serialRun(tasks) { const results []; for (const task of tasks) { results.push(await task()); } return results; }为什么 for 循环就可以因为await task()会真正阻塞当前async函数的作用域直到task()返回的 Promise 落定才进入下一轮循环。for 循环的每一轮都在等待上一轮结束自然达到串行效果。这里有一个非常重要的细节循环变量必须用let或const。如果写成for (var i 0; i tasks.length; i)虽然这个场景下只使用task本身暂时不会出错但一旦你在循环体内使用了i比如创建了依赖i的回调var的共享变量行为就会让所有回调读到最后的值。经典的问题是for (var i 0; i 5; i) { setTimeout(() console.log(i), 0); } // 输出 5, 5, 5, 5, 5换成let后每一轮循环都会创建独立的作用域绑定输出 0 到 4。这个坑在 Promise 场景里同样存在尤其是当你把i传入某个返回 Promise 的函数时// 错误 for (var i 0; i 5; i) { fetchData(i).then(data console.log(i, data)); } // 正确 for (let i 0; i 5; i) { fetchData(i).then(data console.log(i, data)); }第二种写法里每个回调捕获的i都是本轮循环的值。既然是for 循环 Promise的题目这个细节几乎是必问的。3.3 正解二reduce 构建 Promise 链除了 async/await还有一种函数式写法也经常被提起——用reduce把任务串成一条链路function serialRun(tasks) { return tasks.reduce((chain, task) { return chain.then(() task()); }, Promise.resolve()); }reduce的初始值是Promise.resolve()第一轮把 chain 替换成Promise.resolve().then(() task1())第二轮再替换成task1().then(() task2())以此类推。最终整个reduce返回的 Promise 就代表所有任务串行执行完毕。这个写法的优点是没有任何显式循环变量代码短且天然支持链式复用。缺点是当任务数量很多时代码可读性不如for那版直观。如果面试官让你二选一我建议先写for await因为面试官一眼就能看出串行逻辑不易误解等追问有没有别的实现时再补reduce版本。3.4 失败处理和中断串行执行时遇到 reject 怎么处理这题值得专门聊一聊。上面两种写法任何一个任务 reject 都会中断整条链。如果需求是某个任务失败后跳过继续执行后面的需要在每个任务外面包一层catchasync function serialRunWithTolerance(tasks) { const results []; for (const task of tasks) { try { results.push(await task()); } catch (error) { results.push({ error }); } } return results; }如果需求是失败后立刻停止后面的任务for 循环版本直接用throw打断即可try { await serialRun(tasks); } catch (error) { console.error(串行任务中止, error); }面试官问我最喜欢哪种我会说for await最适合表达串行逻辑因为它能把失败即停止变成自然的异常抛出如果要在失败后继续只需要在循环内部做局部拦截。所有流程控制都一目了然不会出现回调地狱时期的困惑。4. 第三题手写 Promise.allfor 循环里的索引细节Promise.all是前端面试频率极高的手写题。它的语义是接收一组 Promise或任意值并行执行全部成功后返回结果数组任一失败则整体 reject。结果数组的元素顺序必须和入参数组一致这是最大的考察点。4.1 核心实现function myPromiseAll(promises) { return new Promise((resolve, reject) { const results []; let count 0; if (promises.length 0) { resolve(results); return; } for (let i 0; i promises.length; i) { Promise.resolve(promises[i]).then(value { results[i] value; count; if (count promises.length) { resolve(results); } }, reject); } }); }这段代码一般能拿到八十分面试官接着就会问两个致命细节。第一是为什么用results[i]而不是results.push(value)。这是整道题的核心。因为入参的 Promise 完成时间不同如果都用 push先完成的会排到前面导致结果顺序和输入顺序不一致。举个例子[taskA(3000), taskB(1000)]B 先完成如果用 pushresults 是[B, undefined]A 完成后变成[B, A]顺序乱了。用索引赋值B 完成后results[1] BA 完成后results[0] A最终一定是[A, B]。第二是为什么用count而不是判断results.length。这有个隐蔽的坑JS 数组的 length 属性等于最大索引值加一。如果你用results[i] value动态扩展数组比如 i3 时先赋值results.length会变成 4即使前面的索引都还是空位。数组的空位不算值但length已经变化了。用results.length promises.length做终止条件会过早触发resolve把空位当成结果。计数器 count 只在每次真正拿到值后自增它才是最可靠的任务完成度指标。4.2 边界情况空数组直接 resolve这个边界如果漏掉promises.length 0会让for循环从未执行resolve 永远不会被调用Promise 一直处于 pending调用方会卡死。这是很多人写第一版时最容易翻车的地方。非 Promise 元素要用Promise.resolve()包裹。因为传入的可能是普通值比如Promise.all([1, 2, Promise.resolve(3)])原生实现会把 1、2 自动包装成已落定的 Promise。上面代码中Promise.resolve(promises[i])就是干这个的保证了.then一定能调用。错误处理方面使用Promise.resolve(...).then(value ..., reject)的写法让任何一个 Promise reject 都会直接触发外层reject。这里有个语义细节原生Promise.all在第一个 reject 出现时立即 reject不当做失败的其他 Promise 仍然在跑只是结果不再被等待。我们手写的版本同样如此因为reject是外层的 rejectthen的第二参数相当于把错误透传出去了。4.3 延续Promise.allSettled 变体如果面试官问你怎么实现 allSettled只需要在循环体里把 reject 也转化为结果而不是直接 reject 外层function myPromiseAllSettled(promises) { return new Promise((resolve) { const results []; let count 0; if (promises.length 0) { resolve(results); return; } for (let i 0; i promises.length; i) { Promise.resolve(promises[i]).then( value { results[i] { status: fulfilled, value }; count; if (count promises.length) resolve(results); }, reason { results[i] { status: rejected, reason }; count; if (count promises.length) resolve(results); }, ); } }); }allSettled永远不 reject它只在乎所有任务都给出结果。写这个变体时我习惯顺手强调一下all与allSettled的区别不是有没有 catch而是失败时是否需要中断等待。 面试官听到这样的总结基本就知道你对这两个 API 的理解不是背出来的。5. 第四题并发限制器for 循环启动工人while 循环分配任务这道题是四道题里最能拉分的一道也是实际工程价值最高的一道。场景非常普遍批量接口请求、文件上传、爬虫抓取总任务量很大但为了避免把服务器打爆要求最多同时 N 个请求在执行。我面试时的题干一般是这样写一个函数runWithLimit(tasks, limit)tasks是返回 Promise 的函数数组limit是最大并发数函数最终返回按原顺序排列的结果数组。5.1 设计思路工人模式最直观的思路是维护一个正在执行的任务池但写起来容易乱。更有说服力的设计是工人模式worker pattern先创建limit个工人每个工人不停地领取任务执行任务取完就下班。工人之间共享一个下一个任务索引保证了每个任务只会被一个工人取到。async function runWithLimit(tasks, limit) { const results new Array(tasks.length); let current 0; async function worker() { while (current tasks.length) { const index current; current; const task tasks[index]; results[index] await task(); } } const workerCount Math.min(limit, tasks.length); const workers []; for (let i 0; i workerCount; i) { workers.push(worker()); } await Promise.all(workers); return results; }这里 for 循环的角色是批量创建工人while 循环的角色是工人领取任务。current是一个共享索引所有工人代码都能访问到它但同一时刻只有一个工人会执行const index current; current;这两行同步代码所以不会出现两个工人领到同一个任务的情况。results数组在创建时就用new Array(tasks.length)预分配了长度结合按索引赋值最终返回的结果能保持输入顺序。5.2 为什么 for 循环负责启动而不是负责执行很多人第一次写这道题会试图在一个 for 循环里做所有事比如// 错误示范for 循环直接控制所有 Promise for (let i 0; i tasks.length; i) { if (i limit) { // 试图在这里等待 } tasks[i]().then(/* ... */); }这种写法很难准确表达等待某个任务完成后再开启下一个容易把代码写成轮询或硬编码。工人模式的优雅之处是把并发调度抽象成共享索引 若干自治工人每个工人内部独立决定自己下一步做什么。for 循环只负责把工人拉起来任务分配交给 while 循环自主运转。这也解释了为什么外面用 for里面用 while——外面是固定次数的初始化里面是次数未知的工作循环。5.3 并发数的检验和隐性问题这个实现能不能真的把并发数控制在 limit 内我建议你亲手测试下。假设 tasks 有 10 个limit 是 3代码执行过程如下worker1 被调用进入 while取 index 0执行results[0] await tasks[0]()挂起。worker2 被调用取 index 1挂起。worker3 被调用取 index 2挂起。此时当前并发任务数是 3current 是 3。当某个任务完成对应 worker 恢复继续 whilecurrent 还小于 10就取下一个任务。于是永远保持完成一个补一个的状态。如果你仔细观察会发现初始阶段 for 循环会连续调用 3 次 worker()但每次调用都只往前走一步就因 await 挂起。这是正确的。如果某个任务同步返回一个已经 resolve 的 Promiseawait 会以微任务形式让渡执行权可能造成某个 worker 连续领取多个任务。这种情况下并发上限依然不会被突破只是任务分配可能不均衡。面试官若追问负载不均衡怎么办可以回答换用基于队列的显式调度比如维护一个 running 池每个任务完成后再从待执行队列里取下一个。但就题目本身而言工人模式已经足够清晰。5.4 失败策略整体 reject 还是局部容错上面代码中如果某个 task rejectawait task()会抛出异常导致该 worker 中断。加上Promise.all(workers)也会 reject最终整个runWithLimit会 reject。这是一个失败全体失败的策略和Promise.all一致。如果想做成失败任务跳过、其他任务继续只需要在 worker 里 catchasync function worker() { while (current tasks.length) { const index current; current; const task tasks[index]; try { results[index] await task(); } catch (error) { results[index] { error }; } } }这个变体常被面试官当作追加题那如果你希望失败后继续执行但最终结果里能看出哪个失败了怎么写 上面这段就是答案。如果要求失败后直接终止但已经启动的任务不取消可以给 worker 一个标志位或直接 reject。通常面试追到这里就够了因为题目核心是并发控制失败语义只是应用层的延伸。这道题如果能在白板上一次写对基本可以给候选人最近面试表现加分不少。6. 四道题串起来的复习建议把这四道题放在一起其实是一条很清晰的递进线。第一题是 Promise 的地基你不理解状态机和回调注册机制后面全是空中楼阁。第二题考察 for 循环与异步时机的协作本质是你能不能掌控什么时候把事情交给下一步。第三题考察批量任务结果收集时最容易忽略的索引与顺序问题。第四题把共享索引、循环结构、失败策略全部组合起来形成一个完整的工程方案。我在实际面试中的考察顺序也是这样。先让候选人写构造函数观察他们对同步异步的基础理解然后给串行题看能不能把 for 循环和 await 正确组合再写Promise.all重点听他们能不能解释索引赋值和 count 的用法最后上并发限制器来一次综合检验。如果还有一周就要面试我给你的实操建议是这样的第一把第二题的 for 循环串行和Promise.all手写在node环境里跑通至少各跑一次包含 reject 的用例。很多候选人白板上写得很对但一执行就发现数组顺序错了或者空数组卡死这些只有跑过才知道。第二第四题的并发限制器建议完整默写两遍。第一遍可以看答案写第二遍合上文档直接在编辑器里敲出来然后自己出几个测试用例验证并发数确实被限制住了。验证方法很粗暴在 task 内部打印开始和结束时间观察是否同时有超过 limit 个任务在运行。第三养成主动说边界条件的习惯。比如写完Promise.all说一句空数组这里要直接 resolve写完并发限制器说一句limit 大于 tasks 长度时我取Math.min来限制工人数。这种表达在面试里非常加分因为它说明你不只是在背代码而是真的把这些边界情况当作工程问题对待过。我自己的体会是手写题不是死记硬背它更像是在白板上展示你的编程思维。面试官真正想看的不是正确答案本身而是你面对一个异步问题时能不能从状态流转和事件循环的角度推理出来。把这些题练熟你以后写代码时的异步设计能力也会有本质提升。