Skip to content

自定义 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

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

课堂画面 6

代码示例 ​

代码 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

小结 ​

  1. 类内直接写的方法是原型方法(给实例用),加 static 才是类本身的方法,写之前先分清归属。
  2. 构造器是唯一同步执行的入口,它把两个 change 函数交给执行器,执行器再交回给使用者的两个形参——整条链就是一次来回传球。
  3. 状态是 pending → fulfilled / rejected 的单向一次状态机,把守卫 #state !== 'pending' 统一收在 change 函数里,比在每个调用点判断更可靠。
  4. 执行器抛异常和调用 reject 是同一件事,用 try/catch 把异常归一进 changeRejected,守卫也会自动保护它。
  5. then 的回调必须异步执行,本实现用 setTimeout 模拟微任务;内置的微任务时序和「未处理拒绝」报错无法完全复刻,接受这个差距。
  6. then 有两个执行时机:调用时状态已定就排进定时器,状态未定就先存进私有属性,等 change 函数触发时回查调用——这是整个实现里最绕的一环。
  7. 存回调时必须两个一起存,回查时必须用可选链或存在性判断兜底,否则「先改状态后 then」和「先 then 后改状态」两种时序必挂一种。
  8. 私有字段 # 正好模拟内置的 [[PromiseState]] / [[PromiseResult]]:内部随便用,外部只能看不能改。