3. Promise核心机制面试题
Promise 是异步编程面试的必考区,而真正拉开差距的,不是“会不会用 then”,而是能否把状态机、链式调用、值穿透、错误冒泡这四件事讲清楚、写出来、判得准。本章围绕这四个核心机制,给出手写 Promise 的关键实现与输出判断类题目,帮助你把“会用”升级为“能解释、能实现、能预测”。
一、状态机:Promise 的不可逆内核
Promise 的本质是一个只有三种状态的状态机:pending(待定)、fulfilled(已兑现)、rejected(已拒绝)。它有三条铁律:初始必为 pending;pending 只能单向转为 fulfilled 或 rejected;一旦转换,状态与值都不可再变。所有 then 的行为、错误冒泡、值穿透,都建立在这条铁律之上。
面试常问:“连续调用两次 resolve 会怎样?”答案是第二次及以后的调用被忽略,因为状态已锁定。下面是一个最小可运行的状态机骨架:
const PENDING = 'pending';
const FULFILLED = 'fulfilled';
const REJECTED = 'rejected';
class MyPromise {
constructor(executor) {
this.state = PENDING;
this.value = undefined;
this.reason = undefined;
this.onFulfilledCallbacks = [];
this.onRejectedCallbacks = [];
const resolve = (value) => {
if (this.state !== PENDING) return; // 状态锁定
this.state = FULFILLED;
this.value = value;
this.onFulfilledCallbacks.forEach(fn => fn());
};
const reject = (reason) => {
if (this.state !== PENDING) return;
this.state = REJECTED;
this.reason = reason;
this.onRejectedCallbacks.forEach(fn => fn());
};
try {
executor(resolve, reject);
} catch (err) {
reject(err); // 执行器抛错 → 拒绝
}
}
}
这里有两个易错点:一是 executor 必须同步执行,否则 then 注册的回调可能错过状态变化;二是 executor 内部抛出的异常必须被捕获并转为 reject,否则会变成未捕获异常。面试中若只写 resolve/reject 而漏掉 try/catch,通常会被追问。
二、then 链式调用:每次返回新 Promise
then 的关键在于:它永远返回一个新的 Promise,而不是原 Promise。这个新 Promise 的状态由回调的返回值决定:返回普通值则兑现为该值;返回 Promise 则等待其落定;抛出异常则拒绝。正是“每次返回新 Promise”,才让链式调用成为可能。
下面给出 then 的核心实现,重点看返回值如何决定下一个 Promise:
then(onFulfilled, onRejected) {
onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : v => v;
onRejected = typeof onRejected === 'function' ? onRejected : e => { throw e; };
const promise2 = new MyPromise((resolve, reject) => {
const handleFulfilled = () => {
queueMicrotask(() => { // 回调异步执行
try {
const x = onFulfilled(this.value);
resolvePromise(promise2, x, resolve, reject);
} catch (err) {
reject(err);
}
});
};
const handleRejected = () => {
queueMicrotask(() => {
try {
const x = onRejected(this.reason);
resolvePromise(promise2, x, resolve, reject);
} catch (err) {
reject(err);
}
});
};
if (this.state === FULFILLED) handleFulfilled();
else if (this.state === REJECTED) handleRejected();
else {
this.onFulfilledCallbacks.push(handleFulfilled);
this.onRejectedCallbacks.push(handleRejected);
}
});
return promise2;
}
其中 resolvePromise 负责处理回调返回值 x:若 x 是 Promise,则递归等待;若 x 与 promise2 相同,则抛出 TypeError(防止自我循环);否则以 x 兑现 promise2。面试中能主动提到“回调必须异步执行(微任务)”和“then 返回新 Promise”这两点,基本就抓住了链式调用的命脉。
三、值穿透:为什么 .then().then() 能拿到原值
值穿透指的是:当 then 的参数不是函数时,该环节被“跳过”,值继续向后传递。例如 Promise.resolve(1).then(null).then(v => console.log(v)) 会输出 1。原因就在上面代码的前两行:非函数参数被替换为 v => v(兑现透传)或 e => { throw e; }(拒绝透传)。
输出判断题示例:
Promise.resolve('A')
.then(null)
.then(undefined)
.then(v => console.log('1:', v));
Promise.reject('B')
.then(v => console.log('不会执行'))
.catch(e => console.log('2:', e));
结果:先输出 1: A,再输出 2: B。判断依据是:兑现链上的 null/undefined 被透传,拒绝链上的 then 无 onRejected,错误继续向后冒泡直到 catch 捕获。这里要提醒:.then(null) 与 .then() 行为一致,都是透传,不要误以为会报错。
四、错误冒泡:错误如何一路向后找 catch
错误冒泡的规则是:Promise 链中任何一环抛出异常或返回 rejected Promise,都会跳过后续的 onFulfilled,直到遇到最近的 onRejected 或 catch。若整条链都没有处理,则触发 unhandledrejection。catch 本质上就是 then(undefined, onRejected) 的语法糖。
输出判断题示例:
Promise.resolve()
.then(() => { throw new Error('E1'); })
.then(() => console.log('跳过'))
.catch(e => { console.log('捕获:', e.message); return '恢复'; })
.then(v => console.log('继续:', v));
结果依次输出 捕获: E1 和 继续: 恢复。关键判断点有两个:一是 throw 使当前 Promise 变为 rejected,后续 then 的兑现回调被跳过;二是 catch 回调返回普通值 '恢复',使链重新回到 fulfilled,因此后面的 then 能继续执行。若把 catch 换成 then 的第二个参数,效果相同,但要注意 then(onFulfilled, onRejected) 无法捕获 onFulfilled 自身抛出的错误,而 catch 可以,这是面试高频追问点。
五、手写 Promise 的验证清单
写完实现后,建议用下面这组用例逐条验证,观察输出是否符合预期,从而判断实现是否达标:
| 用例 | 输入 | 预期输出 | 考察点 |
|---|---|---|---|
| 状态锁定 | resolve(1); resolve(2) | 1 | 不可逆 |
| 异步回调 | then 内同步打印 vs 外部打印 | 外部先打印 | 微任务 |
| 值穿透 | then(null).then(v=>log(v)) | 原值 | 透传 |
| 链式返回 | then(()=>Promise.resolve(3)) | 3 | 递归等待 |
| 错误冒泡 | throw 后接 catch | 被捕获 | 冒泡 |
| 自我循环 | then(()=>promise2) | TypeError | 循环检测 |
把这四组机制串起来看:状态机决定“能不能变”,then 决定“怎么接”,值穿透决定“空环节怎么过”,错误冒泡决定“出错往哪走”。面试时若能把它们讲成一条因果链,而不是四个孤立知识点,就已经超过大多数候选人。建议你亲手把上面的骨架补全为可运行版本,再用验证清单跑一遍,输出判断类题目自然就迎刃而解。