1. 从“我会用”到“想自己造”,中间差了什么
1.1 面试常见的那个手写题,其实是异步思维体检
先聊一个现象:前端面试里“手写 Promise”已经快和“手写深拷贝”一样烂大街了,但真能一次写对的人,我这些年见过的比例不高。能把 then 用得飞起的人,一上手写 resolve、reject、then 三者之间的联动,往往会卡在同一个地方:状态到底是谁改的、回调谁来触发、链式调用为什么能拿到上一个回调的返回值。
我说句实话,别把手写 Promise 当成应付面试的八股。你把它写完、跑通、再被刁钻用例打脸几轮之后,你对 async/await、对 Promise.all、对“为什么有时候错误没被捕获”的理解,会明显不一样。这篇文章就是把你从“我会用”推到“我会造”的那条路,我会把每一步为什么这样做讲清楚,代码也给全,你可以直接抄下来跑。
1.2 用了一年 Promise,我被问住了三次
先说我自己。写业务写了很久,fetch().then().catch().finally() 这套闭着眼睛能写,但有一次同事问我:then 里返回一个 new Promise,和直接返回一个普通值,底层处理方式有什么不同?我当场没答上来。
第二次是线上出现 uncaught (in promise) TypeError: Cannot read properties of undefined (reading 'xxx'),定位了很久才发现是某个回调里解构了 undefined 的字段,而因为 Promise 链太长,错误对象把真正的调用栈盖住了。那时候我意识到,Promise 对我来说还是个黑盒:我只会往里面丢函数,它怎么调度、怎么传递、怎么冒泡,我不清楚。
第三次是在做接口层封装时,当时想实现一个“请求超时自动中断”的功能,有人提议用 Promise.race。race 确实能实现超时报错,但底层那个先落定的 Promise 并不会被取消,该发的请求还是发出去了,回调也还是会执行。这个“回调还是执行了”的现象,让我对 Promise 的底层机制产生了强烈的探求欲。后来我花了两三个晚上,对照 Promise/A+ 规范手写了一遍实现,从那以后再写异步逻辑,思路清晰了很多。这篇文章就按我当时推导的顺序来写。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先拆黑盒:Promise 的两种规则和三张底牌
2.1 状态机:为什么 Promise 的状态只能走一次
手写 Promise 之前,脑子里必须先有一个状态机的概念。Promise 实例内部维护了一个状态,取值只有三种:pending(等待中)、fulfilled(已成功)、rejected(已失败)。
这个状态设计有两个硬性规则:
- 状态只能从
pending变成fulfilled或rejected,一旦变更就不可逆。 - 已经
fulfilled或rejected的 Promise,再调用resolve或reject是无效的。
你可以在浏览器里做个实验:
javascript复制const p = new Promise((resolve, reject) => {
resolve(1);
resolve(2);
reject(new Error('不会生效'));
});
p.then(console.log); // 只打印 1
这段代码的输出只有 1,后面的 resolve(2) 和 reject 都被忽略了。底层的实现方式,其实就是加了一个 state 字段和两个方法,方法内部先判断 state !== PENDING 就直接返回。状态机在手写实现里是最简单、也最不能被搞错的部分,否则后面所有逻辑都会跟着崩。
理解状态机还有一个重要意义:Promise 的“幂等性”正是靠它保证的。比如你封装一个获取用户信息的函数,内部用一个变量缓存 Promise 实例,多次调用都返回同一个 Promise,那么后加入的 then 回调一定能在状态确定后拿到同一个结果。这背后就是“状态只走一次”的功劳。
2.2 resolve 函数的双重身份
我见过很多初学者手写 Promise,第一版 resolve 是这样写的:
javascript复制function resolve(value) {
this.state = FULFILLED;
this.value = value;
}
这样写能跑通最简单的例子,但有一个致命缺陷:resolve 的参数不一定是一个普通值,它可能是一个 Promise,也可能是一个带 then 方法的对象(通常叫 thenable)。Promise/A+ 规范要求,如果 resolve 接收了一个 thenable,那么最终的 Promise 状态要由这个 thenable 决定。
换句话说,resolve 函数有两重身份:对于普通值,直接进入 fulfilled;对于 Promise 或 thenable,先“展平”,让这个值自己决定后续状态。这个“展平”机制,是手写实现中第一个比较容易绕晕的地方,后续我会专门讲 resolvePromise 这个工具函数。
2.3 微任务:Promise 为什么总在同步代码之后执行
如果在浏览器里执行这段代码:
javascript复制console.log('start');
Promise.resolve().then(() => console.log('promise'));
console.log('end');
输出顺序永远是 start、end、promise。原因是 then 注册的回调不会立即执行,而是被放入微任务队列,要等当前宏任务结束、调用栈清空之后再执行。
手写实现里要模拟这个调度行为,就得找到一种“把函数延后到当前同步代码之后执行”的机制。浏览器里首选 queueMicrotask,其次是 Promise.resolve().then 或 MutationObserver,Node 老环境还可以用 process.nextTick,最通用的兜底方案是 setTimeout(虽然它是宏任务,时机不够精确,但能保证延后)。我在代码实现里会封装一个 nextTick 函数,先把调度统一起来,后面所有异步触发都走它。这里先记住结论:等我们手写实现跑通后,then 回调也一定是在同步代码之后打印的。
3. 核心骨架:状态存储与 resolve/reject 的实现
3.1 从构造器开始的代码骨架
有了上面的铺垫,我们可以开始写代码了。我把实现分几个步骤,每步都能单独跑通,最后再合成一个完整版本。
第一步,定义构造器函数和状态常量:
javascript复制const PENDING = 'pending';
const FULFILLED = 'fulfilled';
const REJECTED = 'rejected';
function MyPromise(executor) {
this.state = PENDING;
this.value = undefined; // 成功结果
this.reason = undefined; // 失败原因
this.onFulfilledCallbacks = []; // 等待中的成功回调
this.onRejectedCallbacks = []; // 等待中的失败回调
const resolve = (value) => {
if (this.state !== PENDING) return;
// 这里暂时不处理 value 是 Promise 的情况,留到下一步
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);
}
}
这段代码解决了三件事:状态字段和结果字段的存储、resolve/reject 的幂等保护、执行器抛异常时的兜底捕获。
执行器同步抛错这一点容易被忽略。比如 new Promise(() => { throw new Error('boom') }),语义上等同于 reject,所以构造器里必须用 try/catch 包住 executor 的调用。这也是使用者常遇到的问题:构造器里同步代码出错,catch 为什么能接到?根本原因就在这里。
3.2 让 resolve 支持传入 Promise 的递归分支
接下来处理 resolve 收到 Promise 的情况。如果 resolve 的参数 value 本身是 MyPromise 实例,我们就不能立刻改状态,而应该让这个传入的 Promise 决定后续走向。修改 resolve:
javascript复制const resolve = (value) => {
if (this.state !== PENDING) return;
if (value instanceof MyPromise) {
value.then(
(val) => resolve(val),
(err) => reject(err)
);
return;
}
this.state = FULFILLED;
this.value = value;
this.onFulfilledCallbacks.forEach((fn) => fn());
};
这一步就是 Promise 的“递归吸收”。写成 value.then(resolve, reject) 之后,如果传入的 Promise 最终成功,我们的 Promise 也跟着成功,反之亦然。这也是 Promise.resolve(somePromise) 能返回一个与 somePromise 状态一致的 Promise 的底层原因。
要注意这里 resolve(val) 会再次检查状态,但由于此时 state === PENDING,可以继续进入下一次判断。如果 val 还是一个 Promise,就会再次递归下去,直到拿到普通值为止。这种“展平”在规范里是必须的,实际写的时候要小心别漏掉 instanceof 判断,否则内部 Promise 的状态变化不会传导到外层。
3.3 回调数组:then 可能注册在状态确定之前或之后
很多初学实现的人会遇到一个奇怪现象:在 new Promise 里同步调用 resolve 之后,紧接着注册 then,回调却不会执行。原因很简单——他们的 then 里只有“状态是 fulfilled 就直接执行回调”的逻辑,没有处理“状态还是 pending 时先缓存回调”的情况。
所以构造器里的 onFulfilledCallbacks 和 onRejectedCallbacks 两个数组不是装饰,它们是 then 注册与状态触发之间的“消息队列”。
具体协作模式是:then 注册回调时,如果当前是 pending,先把回调推入数组;等 resolve/reject 被调用,再一次性把数组清空执行。当状态已经确定后再调用 then,则直接异步执行回调,不再入数组。这套逻辑看起来不复杂,但它是事件驱动模型在 Promise 里的具体体现,理解之后你对“订阅/发布”模式也会有更深的体感。
4. then 方法:返回值与新 Promise 的联动
4.1 then 为什么必须返回一个新的 Promise
前面铺垫了状态存储和构造器,接下来是重头戏:then 方法。在开始写之前,先想清楚一个核心问题:为什么 then 要返回一个“新的” Promise,而不是返回 this?
我见过有人图省事直接 return this,结果链式写起来全是问题。因为如果返回 this,那么第一个 then 注册的回调返回值,就没法被第二个 then 拿到了——你总不能去改写 this.value 吧?就算真的改写,状态已经确定,无法再进入 pending,语义就完全乱了。
所以 then 的正确姿势是:创建一个新的 Promise,把回调的返回值透传给新 Promise 的 resolve,由这个新 Promise 继续驱动后续链。这是整个链式调用的发动机。
then 的骨架如下:
javascript复制MyPromise.prototype.then = function (onFulfilled, onRejected) {
const next = new MyPromise((resolve, reject) => {
// 根据当前状态,决定是立即调度执行,还是入队等待
});
return next;
};
在 next 的构造器回调里,如果当前 Promise 已经 fulfilled,我们就异步执行 onFulfilled,并把结果交给一个新的工具函数 resolvePromise 去处理。如果还在 pending,则把这段逻辑封装成一个函数推入 onFulfilledCallbacks 等待后续触发。失败回调同理。
4.2 微任务降级方案:不同环境的兼容做法
为了确保 then 回调是异步执行的,先封装一个通用的调度函数:
javascript复制function nextTick(fn) {
if (typeof queueMicrotask === 'function') {
queueMicrotask(fn);
} else if (typeof MutationObserver === 'function') {
const observer = new MutationObserver(fn);
const node = document.createTextNode('');
observer.observe(node, { characterData: true });
node.data = 'trigger';
} else if (typeof process !== 'undefined' && process.nextTick) {
process.nextTick(fn);
} else {
setTimeout(fn, 0);
}
}
实际开发里绝大多数现代环境都有 queueMicrotask,所以这个函数基本只会走第一分支。但在手写实现里保留后面的降级很有必要,它让你明白:Promise 不是“立刻执行回调”,而是“把回调塞进一个异步队列末尾”,环境不同,队列名称也不同。
code复制
4.3 回调执行与状态穿透
只看骨架容易犯一个误区:拿到回调返回值后直接 resolve。这样写普通值没问题,但没法处理返回值是 Promise 的情况,而且没有异常捕获。所以完整写法是:
javascript复制MyPromise.prototype.then = function (onFulfilled, onRejected) {
const next = new MyPromise((resolve, reject) => {
const handleFulfilled = () => {
nextTick(() => {
try {
if (typeof onFulfilled !== 'function') {
resolve(this.value);
return;
}
const result = onFulfilled(this.value);
resolvePromise(next, result, resolve, reject);
} catch (err) {
reject(err);
}
});
};
const handleRejected = () => {
nextTick(() => {
try {
if (typeof onRejected !== 'function') {
reject(this.reason);
return;
}
const result = onRejected(this.reason);
resolvePromise(next, result, 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 next;
};
这里有两个容易忽略的细节。
第一个是“无回调时的穿透处理”。当 onFulfilled 不是函数时,直接把 this.value 传给下一个 Promise;对应地,onRejected 不是函数时,直接把 this.reason 传给下一个,实现错误往下传递。这正是“值穿透”和“拒绝穿透”的底层实现。
第二个是 handleFulfilled 和 handleRejected 被推入数组时,它们捕获的是同一个 next 的 resolve/reject,而 next 在 then 的返回处已经被创建。也就是说,then 一旦调用,新的 Promise 就已经诞生,无论回调何时触发,最终都会把结果灌注到这个新 Promise 里。
第二个细节我实际调代码时踩过坑:如果把 new MyPromise 的创建放在 if (this.state === FULFILLED) 分支里面,就会导致每个分支各自创建一个新的 Promise,外部 return 只能返回其中一个,无法统一。所以 next 必须在 then 的第一行就创建好,后面的分支只调度执行逻辑,不负责创建新对象。
5. resolvePromise:手写 Promise 最难啃的一块骨头
5.1 防自循环:TypeError 从哪里来
来到手写实现里最绕的部分:resolvePromise。这个函数接收四个参数:新 Promise(记为 next)、回调返回值(记为 x)、新 Promise 的 resolve 和 reject。
先处理一种异常情况:x 和 next 是同一个对象。比如:
javascript复制const p = new MyPromise((resolve) => resolve(1));
const q = p.then(() => q); // 回调返回它所在链上的下一个 Promise
这会导致无限循环引用,规范要求抛出 TypeError。代码实现:
javascript复制function resolvePromise(next, x, resolve, reject) {
if (x === next) {
reject(new TypeError('Chaining cycle detected for promise'));
return;
}
if (x instanceof MyPromise) {
x.then(
(val) => resolvePromise(next, val, resolve, reject),
(err) => reject(err)
);
return;
}
if (x !== null && (typeof x === 'object' || typeof x === 'function')) {
// thenable 处理分支,下一步展开
} else {
resolve(x);
}
}
实际工程里,return p.then(() => q) 这种代码不多见,但你封装递归异步函数时有可能无意中踩中。这个检查的存在,让错误从“死循环页面崩溃”变成“一个可捕获的 TypeError”,体验天差地别。
5.2 thenable 展平与 called 标志
继续处理 thenable。所谓 thenable,就是“有 then 方法的对象或函数”,比如某个第三方库返回的类 Promise 对象。规范要求,如果 x 是对象或函数,先尝试取 x.then,如果 then 是函数,就以 x 为 this 调用它。
这里有一个细节容易忽略:取 x.then 这个动作本身可能抛错(比如 getter 抛异常),调用 then 也可能抛错,所以需要 try/catch。另外,then 内部可能会同时调用 resolvePromise 和 rejectPromise,规范要求只认第一次调用,后面的忽略,因此必须加一个 called 标志位。
javascript复制if (x !== null && (typeof x === 'object' || typeof x === 'function')) {
let then;
try {
then = x.then;
} catch (e) {
reject(e);
return;
}
if (typeof then === 'function') {
let called = false;
try {
then.call(
x,
(y) => {
if (called) return;
called = true;
resolvePromise(next, y, resolve, reject);
},
(r) => {
if (called) return;
called = true;
reject(r);
}
);
} catch (e) {
if (called) return;
called = true;
reject(e);
}
} else {
resolve(x);
}
} else {
resolve(x);
}
called 标志位的作用,正是为了拦截那种“既调用了 resolve 又调用了 reject”的异常 thenable。你可以在自己的实现里故意制造一个这样的 thenable,会发现如果没有标志位,新 Promise 会被连续 settle 两次,状态覆盖混乱。
5.3 完整代码定稿
把所有片段拼起来,加上 catch、finally 和 resolve/reject/all/race 等静态方法,就是一份相对完整的实现。下面给出精简版,方便你直接跑:
javascript复制const PENDING = 'pending';
const FULFILLED = 'fulfilled';
const REJECTED = 'rejected';
function nextTick(fn) {
if (typeof queueMicrotask === 'function') {
queueMicrotask(fn);
} else if (typeof process !== 'undefined' && process.nextTick) {
process.nextTick(fn);
} else {
setTimeout(fn, 0);
}
}
function resolvePromise(next, x, resolve, reject) {
if (x === next) {
reject(new TypeError('Chaining cycle detected for promise'));
return;
}
if (x instanceof MyPromise) {
x.then(
(val) => resolvePromise(next, val, resolve, reject),
(err) => reject(err)
);
return;
}
if (x !== null && (typeof x === 'object' || typeof x === 'function')) {
let then;
try {
then = x.then;
} catch (e) {
reject(e);
return;
}
if (typeof then === 'function') {
let called = false;
try {
then.call(
x,
(y) => {
if (called) return;
called = true;
resolvePromise(next, y, resolve, reject);
},
(r) => {
if (called) return;
called = true;
reject(r);
}
);
} catch (e) {
if (called) return;
called = true;
reject(e);
}
} else {
resolve(x);
}
} else {
resolve(x);
}
}
function MyPromise(executor) {
this.state = PENDING;
this.value = undefined;
this.reason = undefined;
this.onFulfilledCallbacks = [];
this.onRejectedCallbacks = [];
const resolve = (value) => {
if (this.state !== PENDING) return;
if (value instanceof MyPromise) {
value.then(resolve, reject);
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 (e) {
reject(e);
}
}
MyPromise.prototype.then = function (onFulfilled, onRejected) {
const next = new MyPromise((resolve, reject) => {
const handleFulfilled = () => {
nextTick(() => {
try {
if (typeof onFulfilled !== 'function') {
resolve(this.value);
return;
}
const result = onFulfilled(this.value);
resolvePromise(next, result, resolve, reject);
} catch (e) {
reject(e);
}
});
};
const handleRejected = () => {
nextTick(() => {
try {
if (typeof onRejected !== 'function') {
reject(this.reason);
return;
}
const result = onRejected(this.reason);
resolvePromise(next, result, resolve, reject);
} catch (e) {
reject(e);
}
});
};
if (this.state === FULFILLED) {
handleFulfilled();
} else if (this.state === REJECTED) {
handleRejected();
} else {
this.onFulfilledCallbacks.push(handleFulfilled);
this.onRejectedCallbacks.push(handleRejected);
}
});
return next;
};
MyPromise.prototype.catch = function (onRejected) {
return this.then(null, onRejected);
};
MyPromise.prototype.finally = function (callback) {
return this.then(
(value) => MyPromise.resolve(callback()).then(() => value),
(reason) => MyPromise.resolve(callback()).then(() => { throw reason; })
);
};
MyPromise.resolve = function (value) {
if (value instanceof MyPromise) return value;
return new MyPromise((resolve) => resolve(value));
};
MyPromise.reject = function (reason) {
return new MyPromise((_, reject) => reject(reason));
};
MyPromise.all = function (promises) {
return new MyPromise((resolve, reject) => {
const results = [];
let count = 0;
promises.forEach((p, i) => {
MyPromise.resolve(p).then(
(val) => {
results[i] = val;
count += 1;
if (count === promises.length) resolve(results);
},
(err) => reject(err)
);
});
if (promises.length === 0) resolve(results);
});
};
MyPromise.race = function (promises) {
return new MyPromise((resolve, reject) => {
promises.forEach((p) => {
MyPromise.resolve(p).then(resolve, reject);
});
});
};
这份代码不是最短的,但结构清楚,逐段能看懂。如果你想更严格地验证,可以去跑 Promise/A+ 官方的 promises-aplus-tests 测试套件。跑之前要在文件里加上 adapter:
javascript复制MyPromise.deferred = function () {
const result = {};
result.promise = new MyPromise((resolve, reject) => {
result.resolve = resolve;
result.reject = reject;
});
return result;
};
module.exports = MyPromise;
然后用 npx promises-aplus-tests 文件路径 跑。第一次跑不过很正常,我当时对着失败用例一行行查,最后发现是 thenable 分支里 resolve(x) 的位置放错了,这个排查过程本身比直接抄一份满分实现更有收获。
6. 边界测试:光会跑 Hello World 不算数
6.1 用“爸妈转账”场景验证异步流程
写完了不代表是对的,得拿真实场景验。我建议先把几个经典用例跑一遍:
场景一:状态只走一次
javascript复制const p = new MyPromise((resolve, reject) => {
resolve('到账 100 元');
reject('银行拒绝');
});
p.then(console.log, console.error); // 只输出"到账 100 元"
场景二:链式返回值传递
javascript复制const p = new MyPromise((resolve) => resolve(10));
p.then((n) => n + 5)
.then((n) => n * 2)
.then(console.log); // 30
场景三:链式中途返回 Promise
javascript复制const p = new MyPromise((resolve) => resolve(10));
p.then((n) => {
return new MyPromise((resolve) => {
setTimeout(() => resolve(n + 20), 100);
});
}).then(console.log); // 30
这三个跑通,说明状态机、值传递、Promise 展平都基本正确。特别是第三个,如果 resolvePromise 写错,返回的 Promise 结果不会透传到下一个 then。
6.2 拒绝穿透到底穿的是什么
很多时候你会在控制台看到 uncaught (in promise) 错误。用刚写的代码就能复现这个过程:
javascript复制const p = new MyPromise((_, reject) => {
reject(new Error('订单创建失败'));
});
p.then(() => {
console.log('这个不会执行');
});
上面的代码里,then 没传第二个参数,根据我们 then 的实现,onRejected 不是函数时,会把 this.reason 原封不动地 reject 给下一个 Promise。但如果没人给下一个 Promise 注册失败回调,这个错误就成了“没人认领”的状态,进而触发全局的 unhandledrejection 事件。
从底层视角看,Promise 的“错误冒泡”本质上是“拒绝穿透”,也就是回调缺失时把 reason 向下传递,而不是真的把错误重新抛出。理解这一点后,线上排查 uncaught (in promise) 时你会更有方向:要么链的末端缺少 catch,要么某个中间环节没有把 rejection 处理掉。
6.3 uncaught (in promise) 到底是怎么来的
再展开说说最常见的 uncaught (in promise) TypeError: Cannot read properties of undefined。这个错误的根源经常出在“解构的源头是 undefined”:
javascript复制fetch('/api/user')
.then((res) => res.json())
.then((data) => {
const name = data.user.name; // data.user 是 undefined
});
在原生 Promise 里,这个错误会在最后一个 then 的 onFulfilled 中被 resolvePromise 捕获并 reject 新 Promise。但因为你的后续没有接 catch,这个 rejected 的 Promise 就没人处理,最终触发全局错误。
用我们手写的代码走一遍执行流:第二个 then 的 handleFulfilled 进入 try/catch,data.user.name 抛出 TypeError,被 catch 捕获后调用 reject(err)。也就是说,newPromsie 变成了 rejected,但谁也没给它挂失败回调,于是错误悬空。这个流程你亲手在代码里走一遍,以后写 Promise 链就会本能地在末尾补 catch,或者确保所有中间层都正确透传。
7. 写完 Promise 之后,我对用 Promise 的理解变了哪几层
7.1 错误处理的位置比想象中更重要
之前用别人封装的请求库,我经常在 catch 里拿到一个笼统的“网络错误”,不知道到底哪一层出的问题。写完 Promise 后我才意识到,catch 捕获的其实不是“整个链上的所有错误”,而是“从某个节点往下,所有还没被其他失败回调处理过的错误”。如果你在链中间某个 then 已经处理了错误并正常返回,后面的 catch 就接不到。
手写过程中对这些行为看得越细,写代码时就越会主动思考:哪些错误应该在当前层消化,哪些错误应该继续往下抛给全局提示。这个判断能力,是排错能力的核心,也是从“会用”到“会造”之后最直接的转变。
7.2 第二层 then 的第二个参数,什么时候生效
热搜词里有一条是“promise 第二层then第二个参数是不是无效”,这个话题很有代表性。直接说结论:第二层 then 的第二个参数,只有当“第一层回调返回的 Promise 被 reject,或者第一层回调执行抛错”时才会触发。
举个最典型的场景:
javascript复制const p = new MyPromise((resolve) => resolve(1));
p.then((n) => {
return new MyPromise((resolve, reject) => {
reject(new Error('第二层内部失败'));
});
}).then(
() => console.log('成功'),
(err) => console.log('失败', err.message)
);
这里第二层 then 的第二个参数是有效的,因为第一层回调返回了一个被 reject 的 Promise。而如果第一层回调只是普通返回值,第二层第二个参数就不会执行。理解了 then 返回新 Promise、新 Promise 状态由回调返回值决定之后,这类疑问自然就消失了。
7.3 在普通函数里“赋值”一个 Promise,需要注意什么
热词里还有一条:promise 在普通函数里面赋值。展开来说,很多人在普通函数里这样写:
javascript复制function getUser() {
let user = null;
fetch('/api/user').then((data) => {
user = data;
});
return user; // 永远是 null
}
原因在于 then 回调是异步执行的,函数本身同步返回,user 还没被赋值就已经被 return 了。手写实现之后,你会很清楚微任务的调度时机:fetch 注册的 .then 回调被放进了微任务队列,而 return user 在当前宏任务就执行了,两者存在天然的时间差。
正确做法有两种。一种是返回 Promise,让调用方用 await 或 then 获取;另一种是用 async/await 把异步流程变成同步写法。理解了底层,你会发现 async 函数返回的也是 Promise,这本质上还是同一个机制。
最后分享一个我个人的使用习惯:手写完 Promise 的代码不要太早删掉,留在本地当参考答案。遇到异步行为异常的 bug,我会先把相关逻辑用原生 Promise 实现跑一遍,跑不通就用自己手写的那份在关键节点加日志,一步步跟踪状态变化。这个“量子态”般的黑盒被打开之后,很多事都会变得简单很多。
