主题
自定义 Promise与基本结构搭建(Day18 1/3)
本页对应 Day18-自定义Promise,共 6 段视频,约 1 小时 51 分。
学习目标
- 能够使用 class 搭建自定义 Promise 的基本结构,并将 then、catch 定义在实例原型上,将 resolve、reject、all、race 定义为类本身的静态方法。
- 能够在构造函数中接收执行器函数并立即调用,同时向执行器传入 resolve 和 reject 两个可改变状态的函数。
- 能够实现并说明修改 Promise 状态的三种方式:调用 resolve、调用 reject、执行器函数抛出异常,并同步维护 state 与 result。
- 能够通过判断状态是否为 pending 来保证状态只能更改一次,后续 resolve、reject 或异常不会覆盖已确定的状态和结果。
- 能够实现 then 方法接收成功与失败回调,根据状态异步执行对应回调,并在状态为 pending 时先缓存回调、待状态改变后再执行并传入结果。
- 能够通过控制台或示例代码验证:then 回调晚于同步代码执行,且 pending 状态下的回调会等到状态改变后才执行。
知识点
1. 先分清方法归属:实例方法 vs 静态方法
为什么需要:自定义 Promise 前,必须先知道哪些方法挂在实例原型上,哪些挂在类本身上,否则结构会乱。
怎么想:看 MDN 或内置 Promise:then/catch/finally 是实例方法,挂在 Promise.prototype;resolve/reject/all/race/allSettled 是静态方法,直接挂在 Promise 上。本节实现 6 个核心方法:实例 then/catch,静态 resolve/reject/all/race,finally/allSettled 暂不实现。
怎么做:用 class MyPromise,实例方法直接写在类体里;静态方法前加 static。
易错点:不要把 resolve/reject 写成实例方法;class 定义不会像函数声明那样提升,辅助代码要注意顺序。
js
class MyPromise {
then(onResolved, onRejected) {}
catch(onRejected) {}
static resolve(value) {}
static reject(reason) {}
static all(promises) {}
static race(promises) {}
}2. 用 class + IIFE 搭基本结构
为什么需要:class 更接近内置写法,IIFE 可只暴露 MyPromise,内部辅助变量不污染全局。
怎么想:转写稿里常见写法是立即执行函数传入 window 或 globalThis,再把类赋给全局。
怎么做:
js
(function (global) {
class MyPromise {
// 方法先占位
}
global.MyPromise = MyPromise;
})(typeof window !== 'undefined' ? window : globalThis);易错点:class 不能直接当普通函数调用;自己实现的 Promise 只能“模拟”内置行为,不可能和底层完全一致。
3. constructor 接收执行器并立即调用
为什么需要:new Promise(executor) 不传函数时,内置会报 “is not a function”;且 executor 是同步执行的。
怎么想:只写形参 executor 不会报错,必须在 constructor 里调用它,才能让“没传函数”自然报错,并把两个改状态函数传回去。
怎么做:
js
constructor(executor) {
const changeResolved = (value) => { /* 改成功 */ };
const changeRejected = (reason) => { /* 改失败 */ };
try {
executor(changeResolved, changeRejected);
} catch (error) {
changeRejected(error);
}
}易错点:executor 抛异常必须用 try/catch 捕获,否则状态无法变成失败;不要忘记调用 executor。
4. 私有属性与三种改状态方式
为什么需要:Promise 实例需要记录状态和结果,但外部不能直接读改。
怎么想:用 #state = 'pending'、#result 私有属性;三种改状态方式:调用第一个参数改成功、调用第二个参数改失败、执行器抛异常改失败。
怎么做:
js
#state = 'pending';
#result;
#callbackObj;
const changeResolved = (value) => {
if (this.#state !== 'pending') return;
this.#state = 'fulfilled';
this.#result = value;
this.#callbackObj?.onResolved?.(value);
};
const changeRejected = (reason) => {
if (this.#state !== 'pending') return;
this.#state = 'rejected';
this.#result = reason;
this.#callbackObj?.onRejected?.(reason);
};易错点:状态只能改一次,改状态函数开头必须判断 pending;否则先成功再失败会覆盖。转写稿调试时正是先 resolve('OK') 再 reject,发现被覆盖后才补判断。
5. then:回调异步执行,pending 时先存
为什么需要:then 回调必须异步执行;如果调用 then 时状态还没改,不能立即判断执行哪个回调。
怎么想:状态已改,用 setTimeout 模拟异步;状态还是 pending,先把两个回调存到 #callbackObj,等改状态函数执行时再调用。
怎么做:
js
then(onResolved, onRejected) {
if (this.#state === 'fulfilled') {
setTimeout(() => onResolved?.(this.#result));
} else if (this.#state === 'rejected') {
setTimeout(() => onRejected?.(this.#result));
} else {
this.#callbackObj = { onResolved, onRejected };
}
}调试时先输出 hello 再输出“成功”,说明 then 回调进入了异步队列;若 2 秒后才 resolve,then 先存回调,2 秒后 changeResolved 再触发。
易错点:pending 时不能直接执行;存回调后要在改状态函数里触发;用可选链避免没存回调时报错。
6. 静态方法与验证
为什么需要:Promise.resolve/reject/all/race 是常用静态方法。
怎么想:resolve/reject 返回新 Promise;all 等全部完成;race 第一个完成就返回。
怎么做:
js
static resolve(value) {
return new MyPromise((resolve) => resolve(value));
}
static reject(reason) {
return new MyPromise((_, reject) => reject(reason));
}易错点:静态方法不属于实例;all/race 参数是可迭代对象,本节先搭结构,内部细节后续补。
先确定要实现哪些方法
为什么需要:自己实现 Promise,第一步不是急着写逻辑,而是先分清哪些方法属于实例、哪些属于类本身。否则后面会把 then 写到静态方法里,或把 resolve 挂到原型上。
怎么想:转写稿里反复强调两类方法:实例方法设置在实例的原型上;类本身的方法就是静态方法,直接挂在类上。实例方法有 then()、catch()、finally();类本身的方法有 resolve()、reject()、all()、race()、allSettled()。本节主要实现 then、catch、resolve、reject、all、race,finally 和 allSettled 先不实现。
怎么做:先搭出如下骨架,保留本节笔记代码:
js
(window => {
class Promise {
/**
* 构造器方法,实例化的时候自动执行
*/
constructor() {
}
/**
* 给实例传递成功和失败的回调函数,该方法会添加到实例的原型上
*/
then() {
}
/**
* 给实例传递失败的回调函数,该方法会添加到实例的原型上
*/
catch() {
}
/**
* 根据参数返回新的Promise实例,该方法添加到类本身(静态方法)
*/
static resolve() {
}
/**
* 返回新的失败状态的Promise实例,该方法添加到类本身(静态方法)
*/
static reject() {
}
/**
* 当传入的所有Promise实例都完成才返回一个Promise实例,该方法添加到类本身(静态方法)
*/
static all() {
}
/**
* 传入的多个Promise实例只要一个先完成就返回一个Promise实例,该方法添加到类本身(静态方法)
*/
static race() {
}
}
window.Promise = Promise;
})(window || global);易错点:then、catch 写在 class 内部会自动加到原型上;static 方法才属于类本身。立即执行函数里的 window 是形参,调用时传 window || global,是为了兼顾浏览器和 Node 环境;函数内部只暴露 window.Promise,其他辅助变量留在作用域内。
构造器与执行器函数
为什么需要:原生 new Promise() 不传执行器函数会报错,自定义版本也要模拟这个行为。执行器函数就是 new Promise((resolve, reject) => {}) 里传进去的函数。
怎么想:构造器在实例化时自动执行。先给 constructor 加形参 executor,再在内部调用它。只写形参不会报错,必须真正调用 executor(),不传参数时才会触发 executor is not a function。
怎么做:构造器里调用执行器,并把两个改变状态的函数传回去。转写稿中用 changeResolved、changeRejected 命名,避免和静态 resolve/reject 混淆。注意如果用 const 箭头函数定义,必须先定义再调用,因为 const 不提升。
易错点:执行器函数是同步执行的;resolve/reject 不是静态方法本身,而是构造器传给执行器的两个能力函数。不要把“调用执行器”和“调用 then 回调”混在一起。
更改状态的三种方式与状态只能改一次
为什么需要:Promise 必须有状态和结果。转写稿总结三种改状态方式:执行器调用第一个参数改为成功;调用第二个参数改为失败;执行器里抛出异常改为失败。并且状态只能改一次。
怎么想:用私有字段 #state 和 #result 记录状态与结果,初始 #state = 'pending'。在 changeResolved、changeRejected 里先判断 #state !== 'pending' 就 return,否则再改状态和结果。执行器用 try/catch 包住,捕获异常后调用失败逻辑。
怎么做:成功时把 #state 改为 'fulfilled',结果设为传入值;失败时改为 'rejected',结果设为原因。catch 中可直接复用失败函数,把 error 传进去。这样后一次改状态会被 return 拦住。
易错点:判断条件不是“是否等于 fulfilled”,而是“只要不是 pending 就不能再改”;catch 里改完状态后要结束,避免继续覆盖;私有字段外部访问会报错,这正是模拟内部状态需要的。
then 的两个执行时机
为什么需要:then 的任务是传入成功/失败回调,但回调什么时候执行取决于状态。转写稿分两种情况:调用 then 时状态已经改变;调用 then 时状态还是 pending。
怎么想:如果状态已是 fulfilled 或 rejected,就异步执行对应回调,并把 #result 传进去;如果还是 pending,不能立刻判断执行哪个,要先把两个回调保存到私有属性 #callbackObj。等 changeResolved/changeRejected 改状态时,再调用保存的回调。
怎么做:then(onResolved, onRejected) 内部判断状态;已改变时用 setTimeout 模拟异步。未改变时保存 { onResolved, onRejected }。在改状态函数里用可选链调用,例如 this.#callbackObj?.onResolved(this.#result),避免回调还没保存时报错。
易错点:then 回调必须异步执行,即使状态早就改了,也要等同步代码先跑完;pending 时不能直接执行任何回调;保存回调后,要在状态改变的地方触发,而不是在 then 里空等。调试时可用 console.dir 查看原型方法和静态方法,console.log 只适合临时观察。
先明确本节目标:实例化时必须传入执行器函数
为什么需要:原生 Promise 在 new Promise() 时不传参数会直接报错,因为构造函数要求接收一个函数。我们自定义 Promise 时,也要模拟这个约束,否则 new Promise() 随便就能创建实例,和原生行为不一致。
怎么想:new Promise(执行器函数) 里的执行器函数,会在实例化时自动、同步执行。它本身不负责“稍后”做什么,而是让构造函数拿到它,并在构造函数内部调用它。调用时还要给执行器函数传两个函数:一个用于将来改成成功,一个用于将来改成失败。
怎么做:先看笔记中的验证代码:
js
// 实例化 Promise
const p1 = new Promise((resolve, reject) => {
console.log(resolve);
console.log(reject);
});这段代码运行后,会打印出两个函数。说明 resolve、reject 是构造函数在执行器函数执行时传进来的。自定义实现中,构造器先接收执行器函数,再在内部调用它,并把 changeResolved、changeRejected 传进去。
易错点:只写 constructor(exector) {} 而不调用 exector(...),那么不传参数也不会报错,因为形参可以自动得到 undefined。必须真正调用它,才会在实参不是函数时触发 TypeError。另外,执行器函数是同步执行的,不是等 then 时才执行。
基本结构:先列方法,再补构造器
为什么需要:在实现细节前,要先确定 Promise 实例方法和静态方法分别挂在哪里。实例方法加到原型上,静态方法加到类本身。这样后面写 then、catch、resolve、reject、all、race 时位置不会乱。
怎么想:用 class 写更接近现代写法,class 中普通方法自动加到原型上,static 方法加到类本身。外面用立即执行函数包一层,通过 window.Promise = Promise 暴露出去;形参写成 (window => {...})(window || global),是为了兼容浏览器和 Node 环境取全局对象。
怎么做:笔记中的完整结构如下,本节重点看 constructor:
js
(window => {
class Promise {
/**
* 构造器方法,实例化的时候自动执行
* @param {Function} exector 执行器函数,实例化的时候自动执行
*/
constructor(exector) {
// 执行该函数,可将实例状态更改为成功
const changeResolved = () => {
};
// 执行该函数,可将实例状态更改为成功
const changeRejected = () => {
};
// 调用执行器函数
exector(changeResolved, changeRejected);
}
/**
* 给实例传递成功和失败的回调函数,该方法会添加到实例的原型上
*/
then() {
}
/**
* 给实例传递失败的回调函数,该方法会添加到实例的原型上
*/
catch() {
}
/**
* 根据参数返回新的Promise实例,该方法添加到类本身(静态方法)
*/
static resolve() {
}
/**
* 返回新的失败状态的Promise实例,该方法添加到类本身(静态方法)
*/
static reject() {
}
/**
* 当传入的所有Promise实例都完成才返回一个Promise实例,该方法添加到类本身(静态方法)
*/
static all() {
}
/**
* 传入的多个Promise实例只要一个先完成就返回一个Promise实例,该方法添加到类本身(静态方法)
*/
static race() {
}
}
window.Promise = Promise;
})(window || global);易错点:then、catch 不要写成 static,否则会挂到类上而不是实例原型上;resolve、reject、all、race 要加 static。构造器里的 changeResolved、changeRejected 目前是空函数,只负责占位,不要误以为本节已经实现状态修改。
构造器调用执行器:完成“传入—接收—再传回”的链路
为什么需要:执行器函数由使用者传入,但两个改状态函数由 Promise 内部提供。只有把内部函数传给执行器,使用者才能在执行器里通过 resolve、reject 形参接收它们。
怎么想:调用链是:new Promise(执行器) → 构造器形参 exector 接收执行器 → 构造器内部调用 exector(changeResolved, changeRejected) → 执行器形参 resolve、reject 分别接收两个函数。转写稿里把这个过程形容为“礼尚往来”:使用者把执行器传给类,类调用执行器时再把两个内部函数传回去。
怎么做:在构造器中保留 exector(changeResolved, changeRejected);。调试时把自定义文件引入 HTML,运行 p1,控制台应打印两个函数;再把自定义代码注释掉,用原生 new Promise() 不传参数,会看到类似 is not a function 的报错。恢复自定义实现后,不传执行器也应报错,因为内部调用了 exector(...)。
易错点:参数名 exector 是笔记里的拼写,保持一致即可;不要把调用写成 exector 不传参,否则执行器里的 resolve、reject 都是 undefined。还要注意构造器内调用执行器是同步的,console.log(resolve) 会在 new 的那一刻执行。
先明确:本节要解决什么问题
自定义 Promise 时,实例必须能记录两件事:当前状态 state 和结果 result。状态初始为等待态,之后只能通过执行器函数提供的两个回调,或执行器抛出异常来改变。为什么需要这样做?因为 then、catch 后续都要根据状态决定执行哪个回调。怎么想?把“改状态”收口到构造器内部,不暴露私有属性。怎么做?用私有属性 #state、#result 保存,并在构造器中定义 changeResolved、changeRejected。易错点:笔记代码中 'pendding' 是拼写,规范通常写 pending,但本节代码按笔记保留原样。
三种更改状态的方式
第一种:执行器函数中调用第一个参数,把状态改为成功,结果就是传入的值。第二种:执行器函数中调用第二个参数,把状态改为失败,结果就是失败原因。第三种:执行器函数中抛出异常,用 try...catch 捕获后把状态改为失败,result 就是错误对象。
为什么需要三种?因为 Promise 既要支持主动成功、主动失败,也要支持执行器内部代码出错时自动失败。怎么想?执行器是 new Promise(exector) 时同步调用的函数,它接收 resolve、reject 两个能力函数;异常必须包住执行器调用。怎么做?在 constructor 中先定义两个改状态函数,再 try { exector(changeResolved, changeRejected) } catch (error) { ... }。易错点:try 要包住 exector(...) 的调用,而不是只包住函数定义;否则执行器内部异常捕获不到。转写稿中反复调试“不传执行器会报错”“异常要写到调用执行器的位置”,说的就是这个推导过程。
完整代码:按笔记保留
js
(window => {
class Promise {
// 设置私有属性,记录实例的状态和结果
#state = 'pendding';
#result;
/**
* 构造器方法,实例化的时候自动执行
* @param {Function} exector 执行器函数,实例化的时候自动执行
*/
constructor(exector) {
// 执行该函数,可将实例状态更改为成功
const changeResolved = value => {
// 修改状态和结果
this.#state = 'fulfilled';
this.#result = value;
};
// 执行该函数,可将实例状态更改为成功
const changeRejected = reason => {
// 修改状态和结果
this.#state = 'rejected';
this.#result = reason;
};
// 调用执行器函数, 如果执行器函数中有异常,修改状态为失败
try {
exector(changeResolved, changeRejected);
} catch (error) {
// 修改状态和结果
this.#state = 'rejected';
this.#result = error;
}
}
/**
* 给实例传递成功和失败的回调函数,该方法会添加到实例的原型上
*/
then() {
}
/**
* 给实例传递失败的回调函数,该方法会添加到实例的原型上
*/
catch() {
}
/**
* 根据参数返回新的Promise实例,该方法添加到类本身(静态方法)
*/
static resolve() {
}
/**
* 返回新的失败状态的Promise实例,该方法添加到类本身(静态方法)
*/
static reject() {
}
/**
* 当传入的所有Promise实例都完成才返回一个Promise实例,该方法添加到类本身(静态方法)
*/
static all() {
}
/**
* 传入的多个Promise实例只要一个先完成就返回一个Promise实例,该方法添加到类本身(静态方法)
*/
static race() {
}
}
window.Promise = Promise;
})(window || global);调试时怎么验证
先验证成功:执行器里调用 changeResolved('OK'),应得到 fulfilled 和结果 OK。再验证失败:调用 changeRejected('error'),应得到 rejected 和原因。最后验证异常:执行器里写 throw new Error('出错了'),应进入 catch,状态为 rejected,结果为错误对象。为什么这样测?因为三种入口对应三条路径,必须分别覆盖。易错点:如果连续调用成功和失败,本节代码会后者覆盖前者;转写稿后续才补“状态只能更改一次”的判断,本节不要误以为已经实现。另一个易错点是私有属性外部不能读取,调试时可在类内部临时打印,不能写成 p1.#state 去访问。
先明确:状态为什么只能改一次
为什么需要:Promise 的状态只有三种:等待(笔记代码中写作 pendding)、成功 fulfilled、失败 rejected。一旦从等待改成成功或失败,就不能再改回去,也不能被后一次调用覆盖。否则 then 拿到的结果会前后矛盾。
怎么想:状态修改只能发生在构造函数内部。执行器函数 exector 会收到两个改状态函数:changeResolved 和 changeRejected。每次修改前先判断:只要当前状态不是 pendding,说明已经改过,直接 return。
怎么做:在两个改状态函数开头加守卫。转写稿里调试时先调用 resolve 再调用 reject,发现后者把前者覆盖了;加上 if (this.#state !== 'pendding') return; 后,后一次就改不动了。
易错点:判断要写“不等于 pendding”,不是等于;两个函数都要加;执行器抛异常时,catch 里不要直接改 #state,而应调用 changeRejected(error),复用同一个守卫。转写稿中“喷定/喷丁”对应代码里的 pendding,“直行器/执行进函数”指执行器函数,“称职resolved/称职rejected”指两个改状态函数。
完整代码保留
js
(window => {
class Promise {
// 设置私有属性,记录实例的状态和结果
#state = 'pendding';
#result;
/**
* 构造器方法,实例化的时候自动执行
* @param {Function} exector 执行器函数,实例化的时候自动执行
*/
constructor(exector) {
// 执行该函数,可将实例状态更改为成功
const changeResolved = value => {
// 如果状态已经更改,结束该函数
if (this.#state !== 'pendding') {
return;
}
// 修改状态和结果
this.#state = 'fulfilled';
this.#result = value;
};
// 执行该函数,可将实例状态更改为成功
const changeRejected = reason => {
// 如果状态已经更改,结束该函数
if (this.#state !== 'pendding') {
return;
}
// 修改状态和结果
this.#state = 'rejected';
this.#result = reason;
};
// 调用执行器函数, 如果执行器函数中有异常,修改状态为失败
try {
exector(changeResolved, changeRejected);
} catch (error) {
// 更改为失败状态,错误对象作为结果
changeRejected(error);
}
}
/**
* 给实例传递成功和失败的回调函数,该方法会添加到实例的原型上
*/
then() {
}
/**
* 给实例传递失败的回调函数,该方法会添加到实例的原型上
*/
catch() {
}
/**
* 根据参数返回新的Promise实例,该方法添加到类本身(静态方法)
*/
static resolve() {
}
/**
* 返回新的失败状态的Promise实例,该方法添加到类本身(静态方法)
*/
static reject() {
}
/**
* 当传入的所有Promise实例都完成才返回一个Promise实例,该方法添加到类本身(静态方法)
*/
static all() {
}
/**
* 传入的多个Promise实例只要一个先完成就返回一个Promise实例,该方法添加到类本身(静态方法)
*/
static race() {
}
}
window.Promise = Promise;
})(window || global);三种改状态方式与“只改一次”的关系
为什么需要:Promise 状态变化只有三种入口:执行器里调用第一个参数改成成功;调用第二个参数改成失败;执行器抛异常改成失败。三者都必须受“只改一次”约束。
怎么想:前两种直接走 changeResolved / changeRejected。第三种在 try...catch 中捕获后,也交给 changeRejected(error),这样异常路径同样会先检查状态。
怎么做:转写稿中先演示“执行器里抛异常仍能覆盖成功状态”,原因是 catch 直接改了状态;改成调用 changeRejected(error) 后,守卫生效,状态已改就返回。
易错点:catch 里不要写 this.#state = 'rejected';错误对象要作为 #result;then 的回调必须异步执行,但本节代码中 then 还是空实现,后续实现时不要因为状态只改一次就同步调用回调。
then 的两个执行时机
为什么需要:then 传入回调时,状态可能已经改了,也可能还没改。状态只能改一次,所以回调不能丢。
怎么想:若 then 执行时状态已改,就按状态异步执行对应回调;若还是 pendding,先把两个回调存到私有属性里,等 changeResolved / changeRejected 改状态时再调用。
怎么做:转写稿里用 #callbackObj 保存回调,改状态函数里判断回调是否存在再调用;用 setTimeout 模拟异步。调试时先 resolve 再 then,回调仍要等同步代码 hello 输出后才执行。
易错点:回调必须异步;不要在 then 里同步调用;存回调时两个都要存;改状态函数里要判断回调是否已传入;转写稿中“认/zen/Z”指 then 方法。
then 的职责:先接收两个回调
为什么需要:Promise 实例的状态迟早会变成成功或失败,使用方需要通过 then 把“成功之后做什么、失败之后做什么”提前登记进去。
怎么想:then 是实例方法,应放在类的原型上;它接收两个参数:成功回调 onResolved、失败回调 onRejected。
怎么做:在 then(onResolved, onRejected) 中先保存形参,再根据当前实例状态决定调用哪一个。
易错点:把成功回调和失败回调写反;把 then 写成静态方法;状态名拼错,例如把 fulfilled 写成 forfiled。
为什么回调必须异步执行
为什么需要:即使调用 then 时实例状态已经改成成功,回调也不能立刻同步执行。课堂演示中先输出“hello 小鱼儿”,再输出“成功”,说明 then 里的回调要等当前同步任务执行完。
怎么想:原生 Promise 的回调会进入微任务队列;我们自己模拟时搞不定真正的微任务,只能用 setTimeout(..., 0) 模拟异步效果。转写稿里明确说“拿定时器模拟一下”。
怎么做:把回调调用包进 setTimeout,不要在 if 分支里直接调用。
易错点:直接写 onResolved(this.#result) 导致同步执行;误以为 setTimeout 就是原生微任务——它只是本课用来模拟异步的宏任务。
根据状态只执行一个回调
为什么需要:成功状态只能走成功回调,失败状态只能走失败回调,不能两个都执行。
怎么想:用 if / else if 判断 this.#state:fulfilled 调 onResolved,rejected 调 onRejected。
怎么做:本节代码中,pending 状态不执行任何回调;等状态改变后再执行回调,是后续步骤要补的能力。
易错点:写成 else 导致 pending 也执行;忘记用 this.#state 而用了外部变量;两个分支都调用同一个回调。
把结果或原因传给回调
为什么需要:成功回调需要拿到成功结果,失败回调需要拿到失败原因。
怎么做:在 setTimeout 内调用时传入 this.#result:onResolved(this.#result) 或 onRejected(this.#result)。
易错点:调用回调时忘传参;把结果传给失败回调;形参名和内部属性名混淆。
本节代码
js
(window => {
class Promise {
// 设置私有属性,记录实例的状态和结果
#state = 'pendding';
#result;
/**
* 构造器方法,实例化的时候自动执行
* @param {Function} exector 执行器函数,实例化的时候自动执行
*/
constructor(exector) {
// 执行该函数,可将实例状态更改为成功
const changeResolved = value => {
// 如果状态已经更改,结束该函数
if (this.#state !== 'pendding') {
return;
}
// 修改状态和结果
this.#state = 'fulfilled';
this.#result = value;
};
// 执行该函数,可将实例状态更改为成功
const changeRejected = reason => {
// 如果状态已经更改,结束该函数
if (this.#state !== 'pendding') {
return;
}
// 修改状态和结果
this.#state = 'rejected';
this.#result = reason;
};
// 调用执行器函数, 如果执行器函数中有异常,修改状态为失败
try {
exector(changeResolved, changeRejected);
} catch (error) {
// 更改为失败状态,错误对象作为结果
changeRejected(error);
}
}
/**
* 给实例传递成功和失败的回调函数,该方法会添加到实例的原型上
*/
then(onResolved, onRejected) {
if (this.#state === 'fulfilled') { // 当传入回调函数是,实例的状态已经改为成功
setTimeout(() => {
onResolved(this.#result);
});
} else if (this.#state === 'rejected') { // 当传入回调函数是,实例的状态已经改为失败
setTimeout(() => {
onRejected(this.#result);
})
}
}
/**
* 给实例传递失败的回调函数,该方法会添加到实例的原型上
*/
catch() {
}
/**
* 根据参数返回新的Promise实例,该方法添加到类本身(静态方法)
*/
static resolve() {
}
/**
* 返回新的失败状态的Promise实例,该方法添加到类本身(静态方法)
*/
static reject() {
}
/**
* 当传入的所有Promise实例都完成才返回一个Promise实例,该方法添加到类本身(静态方法)
*/
static all() {
}
/**
* 传入的多个Promise实例只要一个先完成就返回一个Promise实例,该方法添加到类本身(静态方法)
*/
static race() {
}
}
window.Promise = Promise;
})(window || global);调试与边界
课堂演示先注释自定义代码,用内置 Promise 观察:调用 then 时状态已是成功,但“hello 小鱼儿”仍先输出,证明回调异步。换回自定义实现后,成功状态会走 onResolved,失败状态会走 onRejected。若状态还是 pendding,本节实现不会执行回调——这是本节的边界,后续要在状态改变时再调用保存的回调。另一个易错点是只传一个回调时直接调用另一个可能报错,本节按笔记只处理两个回调都传入的场景。
课堂画面






代码示例
代码 1:promise.js —— 基本结构 + 执行器函数 + 三种改状态方式 + 状态只能改一次 + then 异步执行
js
(window => {
class Promise {
// 私有属性:记录实例的状态和结果
#state = 'pendding';
#result;
/**
* 构造器方法,实例化的时候自动执行
* @param {Function} exector 执行器函数,实例化的时候自动执行
*/
constructor(exector) {
// 执行该函数,可将实例状态更改为成功
const changeResolved = value => {
// 如果状态已经更改,结束该函数(状态只能改一次)
if (this.#state !== 'pendding') {
return;
}
// 修改状态和结果
this.#state = 'fulfilled';
this.#result = value;
};
// 执行该函数,可将实例状态更改为失败
const changeRejected = reason => {
// 如果状态已经更改,结束该函数
if (this.#state !== 'pendding') {
return;
}
// 修改状态和结果
this.#state = 'rejected';
this.#result = reason;
};
// 调用执行器函数,如果执行器函数中有异常,修改状态为失败
try {
exector(changeResolved, changeRejected);
} catch (error) {
// 更改为失败状态,错误对象作为结果
changeRejected(error);
}
}
/**
* 给实例传递成功和失败的回调函数,该方法会添加到实例的原型上
*/
then(onResolved, onRejected) {
if (this.#state === 'fulfilled') { // 传入回调时,实例状态已经改为成功
setTimeout(() => {
onResolved(this.#result);
});
} else if (this.#state === 'rejected') { // 传入回调时,实例状态已经改为失败
setTimeout(() => {
onRejected(this.#result);
});
}
}
/**
* 给实例传递失败的回调函数,该方法会添加到实例的原型上
*/
catch() {}
/**
* 根据参数返回新的 Promise 实例,该方法添加到类本身(静态方法)
*/
static resolve() {}
/**
* 返回新的失败状态的 Promise 实例,该方法添加到类本身(静态方法)
*/
static reject() {}
/**
* 当传入的所有 Promise 实例都完成才返回一个 Promise 实例,该方法添加到类本身(静态方法)
*/
static all() {}
/**
* 传入的多个 Promise 实例只要一个先完成就返回一个 Promise 实例,该方法添加到类本身(静态方法)
*/
static race() {}
}
window.Promise = Promise;
})(window || global);要点与易错点
- 最外层是立即执行函数
(window => {...})(window || global):既能在浏览器(window)跑,也能在 Node(global)跑;同时只有Promise被挂到全局,内部的辅助函数留在作用域里不对外暴露。 #state/#result是私有属性,外部p1.#state会直接语法报错,只能在类内部用this.#xxx访问——这正是内置 Promise 控制台里“看得见、取不出”的效果。- 执行器函数
exector是同步执行的:new的那一刻就被调用,所以形参写了exector还必须在内部主动调用一次exector(...),否则外部不传参也不会报错,做不到“必须传执行器函数”。 - 改状态共三种方式:调用第一个参数(成功)、调用第二个参数(失败)、执行器内抛异常(失败,
result是错误对象)。第三种用try/catch包住执行器函数的调用,不是包住执行器的定义。 - 状态只能改一次:两个
changeXxx开头都判断this.#state !== 'pendding'就return;catch里不要重复写赋值,直接调changeRejected(error),这样判断逻辑被复用,不会绕过“只改一次”。 then里必须用setTimeout包一层模拟异步(内置是微任务,自定义只能拿定时器模拟),否则回调会同步执行;回调执行时要把this.#result传进去,这个值来自执行器resolve时传入的实参。- 笔记中状态初始值写作
'pendding'(标准写法是pending),三个判断里要保持一致,改一个漏一个就会永远进不去分支。
代码 2:index.html —— 验证 then 回调异步执行
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>自定义 Promise - then 回调异步执行</title>
</head>
<body>
<!-- 先引入自己实现的 Promise,它会覆盖内置的 window.Promise -->
<script src="./promise.js"></script>
<script>
const p1 = new Promise((resolve, reject) => {
// 创建实例的同时就把状态改成成功
resolve('OK');
});
p1.then(
value => {
console.log('成功', value); // 成功 OK
},
reason => {
console.log('失败', reason);
}
);
console.log('hello 小乐');
</script>
</body>
</html>要点与易错点
script src="./promise.js"必须在业务代码之前引入,因为它是用window.Promise = Promise覆盖内置构造函数的,引入顺序错了测的还是内置 Promise。- 输出顺序一定是
hello 小乐先、成功 OK后:then里传入回调时状态早已是fulfilled,但then内部用setTimeout把回调推到了同步代码之后(模拟微任务)。 - 把
resolve('OK')换成reject('error')就能验证失败分支;两者都写时只有第一个生效,因为状态只能改一次。 - 想观察状态和结果,只能
console.log(p1)后展开看#state/#result,直接p1.#state会报错;也可以用console.table(...)或console.dir(...)看类本身上的静态方法。 - 执行器里写
undefinedFn()这类错误时,catch会把状态改成rejected、result是错误对象,但自定义实现不会像内置那样“未处理失败状态就抛 unhandled rejection”,这一点笔记里明确说了不实现。
代码 3:promise.js —— 状态未改变时先存回调,等改状态时再执行
js
(window => {
class Promise {
// 私有属性:记录实例的状态和结果
#state = 'pendding';
#result;
// 私有属性:保存 then 传入的回调函数(传入回调时状态还没改的情况)
#callbackObj;
/**
* 构造器方法,实例化的时候自动执行
* @param {Function} exector 执行器函数,实例化的时候自动执行
*/
constructor(exector) {
// 执行该函数,可将实例状态更改为成功
const changeResolved = value => {
if (this.#state !== 'pendding') {
return;
}
this.#state = 'fulfilled';
this.#result = value;
// 如果 then 已经把成功回调存下来了,直接调用
this.#callbackObj?.onResolved?.(this.#result);
};
// 执行该函数,可将实例状态更改为失败
const changeRejected = reason => {
if (this.#state !== 'pendding') {
return;
}
this.#state = 'rejected';
this.#result = reason;
// 如果 then 已经把失败回调存下来了,直接调用
this.#callbackObj?.onRejected?.(this.#result);
};
// 调用执行器函数,如果执行器函数中有异常,修改状态为失败
try {
exector(changeResolved, changeRejected);
} catch (error) {
changeRejected(error);
}
}
/**
* 给实例传递成功和失败的回调函数,该方法会添加到实例的原型上
*/
then(onResolved, onRejected) {
if (this.#state === 'fulfilled') { // 传入回调时,实例状态已经改为成功
setTimeout(() => {
onResolved(this.#result);
});
} else if (this.#state === 'rejected') { // 传入回调时,实例状态已经改为失败
setTimeout(() => {
onRejected(this.#result);
});
} else {
// 传入回调时状态还未改变,先把两个回调都存下来
this.#callbackObj = { onResolved, onRejected };
}
}
/**
* 给实例传递失败的回调函数,该方法会添加到实例的原型上
*/
catch() {}
static resolve() {}
static reject() {}
static all() {}
static race() {}
}
window.Promise = Promise;
})(window || global);配套测试(状态延迟 2 秒才改):
html
<script src="./promise.js"></script>
<script>
const p1 = new Promise((resolve, reject) => {
setTimeout(() => {
resolve('OK'); // 2 秒后才改状态
}, 2000);
});
p1.then(
value => console.log('成功', value), // 2 秒后输出:成功 OK
reason => console.log('失败', reason)
);
console.log('hello 小乐'); // 先输出
</script>要点与易错点
#callbackObj = { onResolved, onRejected }用的是 ES6 属性简写,等价于{ onResolved: onResolved, onRejected: onRejected };两个回调都要存,因为传的时候不知道将来会改成哪个状态。- 调用必须放在
changeResolved/changeRejected里:状态只能由这两个函数改,所以它们就是“状态改变”的唯一时机。存的时候状态还没改,等到定时器触发resolve才真正执行回调。 - 用可选链
this.#callbackObj?.onResolved?.(this.#result):先判断对象存没存(没存说明状态是先改的、回调是后传的),再判断方法存不存在,最后调用;写成this.#callbackObj.onResolved(...)在“状态先改、回调后传”的场景会报undefined错误。 - 调用时传的是
this.#result,不是value/reason——直接用参数变量在then存进来的那条路径里拿不到值,必须用实例上记录的结果。 - 两条时序要分清:①先改状态后传回调 → 走
then的前两个if,setTimeout异步执行;②先传回调后改状态 → 走else存起来,改状态时同步调用。两种情况下then的回调都不会“立刻”执行。 - 状态已改时不要在
then里再存#callbackObj,否则后面changeXxx里可能会重复调用回调。
常见错误
1. 控制台读不到状态,一读就报错
- 现象:想
console.log(p.#state)或p.state,抛 SyntaxError / 得到 undefined。 - 原因:
#state、#result是私有字段,只能在类内部访问;外部本就不该读。 - 修法:调试时直接
console.log(p)展开实例内部槽看状态,或在类里临时加一个get state(){ return this.#state }用完删掉。
2. 执行器里抛异常,实例永远停在 pending
- 现象:
new Promise(() => { foo() })之后.then(null, fn)的失败回调不触发。 - 原因:调用
exector(changeResolved, changeRejected)时没包 try/catch,异常直接冒到外层。 - 修法:
try { exector(changeResolved, changeRejected) } catch (e) { changeRejected(e) },让异常走和 reject 同一条路径。
3. 先 resolve 再 reject,最后状态变成 rejected
- 现象:执行器里连着调两个参数,后调的赢了。
- 原因:
changeResolved/changeRejected没有状态守卫,谁后执行谁覆盖。 - 修法:两个函数开头统一加
if (this.#state !== 'pending') return;;catch 分支不要自己写赋值,直接调用changeRejected(error),守卫自动生效。
4. Promise.resolve() 报 is not a function
- 现象:
Promise.resolve(1)报错,但new Promise(...).then正常。 - 原因:静态方法漏写
static,方法被挂到原型上,成了实例方法。 - 修法:补
static关键字;自检方式——console.dir(Promise)看方法是否在类本身而非prototype上。
5. then 回调里拿到的是 undefined
- 现象:
p.then(v => console.log(v))打印 undefined。 - 原因:改状态时只写了
#state没写#result;或 then 内部调用回调时忘了把this.#result传进去。 - 修法:
changeResolved里同时赋值#result = value,then 里onResolved(this.#result),两处都要传。
6. 异步 resolve 时 then 的回调死活不执行
- 现象:
setTimeout(() => resolve('ok'), 2000)配.then(...),两秒后毫无反应。 - 原因:then 只写了 fulfilled / rejected 两个分支,进 then 时状态还是 pending,落到 else 什么都不做,回调被丢弃。
- 修法:pending 分支把两个回调存进私有属性(如
#callbackObj = { onResolved, onRejected }),并在changeResolved/changeRejected里回查该属性,存在才调用,用可选链this.#callbackObj?.onResolved?.(this.#result)兜底。
7. then 回调同步执行,输出顺序和内置不一致
- 现象:
p.then(fn); console.log('sync')中 fn 先于 sync 打印。 - 原因:直接调用了回调,没做异步包装。
- 修法:所有回调调用统一包进
setTimeout(() => {...}),包括存回调后回查调用的那两处。
8. 用 const 定义 changeResolved 却写在 exector 调用之后
- 现象:ReferenceError: Cannot access 'changeResolved' before initialization。
- 原因:
const有暂时性死区,没有函数提升。 - 修法:先定义两个 change 函数,再调用
exector;不要指望像function声明那样提升。
练习
练习 1|补全 catch 不写新逻辑,用 catch(onRejected) { return this.then(null, onRejected); } 实现。写测试对比 p.catch(fn) 与 p.then(null, fn) 的执行结果和时序是否完全一致。
练习 2|验证 then 的两个执行时机 分别构造两种情况:(a) 构造器里同步 resolve,随后才 .then;(b) 构造器里 setTimeout(resolve, 1000),立即 .then。打印每次回调收到的值,并在纸上标出「回调存入 #callbackObj」和「改状态时回查调用」分别发生在哪一步。
练习 3|实现 static resolve / rejectstatic resolve(value) { return new Promise(resolve => resolve(value)); },reject 同理。测试 Promise.resolve(1).then(v => ...) 能拿到 1;再试 Promise.resolve(p)(p 本身是 Promise)时你的实现表现如何。
练习 4|让 then 能返回新实例并捕获异常 在 then 内部 return new Promise((resolve, reject) => {...}),把回调的返回值 resolve 出去,把回调抛出的异常 reject 掉。用 .then(v => v + 1).then(v => { throw new Error('x') }).catch(e => ...) 验证链式与异常穿透。
练习 5|实现 static all(选做) 参数是 Promise 数组,用计数器和按索引写入的结果数组收集,任一 reject 立即 reject。测试空数组、含非 Promise 值、中途 reject 三种情况。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 自定义Promise-基本结构设计 | 27:33 |
| 2 | 自定义Promise-实例化时传入执行器函数 | 11:52 |
| 3 | 自定义Promise-更改状态的三种方式 | 18:19 |
| 4 | 自定义Promise-状态只能更改一次 | 06:32 |
| 5 | 自定义Promise-then方法传入回调并异步执行 | 14:30 |
| 6 | 自定义Promise-then方法-等到状态改变回调才能执行 | 32:15 |
小结
- 类内直接写的方法是原型方法(给实例用),加
static才是类本身的方法,写之前先分清归属。 - 构造器是唯一同步执行的入口,它把两个 change 函数交给执行器,执行器再交回给使用者的两个形参——整条链就是一次来回传球。
- 状态是 pending → fulfilled / rejected 的单向一次状态机,把守卫
#state !== 'pending'统一收在 change 函数里,比在每个调用点判断更可靠。 - 执行器抛异常和调用 reject 是同一件事,用 try/catch 把异常归一进
changeRejected,守卫也会自动保护它。 - then 的回调必须异步执行,本实现用
setTimeout模拟微任务;内置的微任务时序和「未处理拒绝」报错无法完全复刻,接受这个差距。 - then 有两个执行时机:调用时状态已定就排进定时器,状态未定就先存进私有属性,等 change 函数触发时回查调用——这是整个实现里最绕的一环。
- 存回调时必须两个一起存,回查时必须用可选链或存在性判断兜底,否则「先改状态后 then」和「先 then 后改状态」两种时序必挂一种。
- 私有字段
#正好模拟内置的[[PromiseState]]/[[PromiseResult]]:内部随便用,外部只能看不能改。