☰
手写Promise四道核心题:从构造函数到并发限制器
2026/10/3 4:22:50 网站建设 项目流程

最近两轮面试,我都让候选人在白板上手写 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 都会中断整条链。如果需求是"某个任务失败后跳过,继续执行后面的",需要在每个任务外面包一层catch:

async 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.all,for 循环里的索引细节

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 先完成,如果用 push,results 是['B', undefined],A 完成后变成['B', 'A'],顺序乱了。用索引赋值,B 完成后results[1] = 'B',A 完成后results[0] = 'A',最终一定是['A', 'B']。

第二是为什么用count而不是判断results.length。这有个隐蔽的坑:JS 数组的 length 属性等于最大索引值加一。如果你用results[i] = value动态扩展数组,比如 i=3 时先赋值,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是外层的 reject,then的第二参数相当于把错误透传出去了。

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,挂起。此时当前并发任务数是 3,current 是 3。当某个任务完成,对应 worker 恢复,继续 while,current 还小于 10,就取下一个任务。于是永远保持"完成一个,补一个"的状态。

如果你仔细观察,会发现初始阶段 for 循环会连续调用 3 次 worker(),但每次调用都只往前走一步就因 await 挂起。这是正确的。如果某个任务同步返回一个已经 resolve 的 Promise,await 会以微任务形式让渡执行权,可能造成某个 worker 连续领取多个任务。这种情况下并发上限依然不会被突破,只是任务分配可能不均衡。面试官若追问"负载不均衡怎么办",可以回答:换用基于队列的显式调度,比如维护一个 running 池,每个任务完成后再从待执行队列里取下一个。但就题目本身而言,工人模式已经足够清晰。

5.4 失败策略:整体 reject 还是局部容错

上面代码中如果某个 task reject,await task()会抛出异常,导致该 worker 中断。加上Promise.all(workers)也会 reject,最终整个runWithLimit会 reject。这是"一个失败全体失败"的策略,和Promise.all一致。

如果想做成"失败任务跳过、其他任务继续",只需要在 worker 里 catch:

async 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来限制工人数"。这种表达在面试里非常加分,因为它说明你不只是在背代码,而是真的把这些边界情况当作工程问题对待过。

我自己的体会是,手写题不是死记硬背,它更像是在白板上展示你的编程思维。面试官真正想看的不是正确答案本身,而是你面对一个异步问题时,能不能从状态流转和事件循环的角度推理出来。把这些题练熟,你以后写代码时的异步设计能力也会有本质提升。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询