4千字保姆级教程:跟着 promises/A+ 规范手写 promise

820 阅读10分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第9天,点击查看活动详情

前言

对于学习 promise,好多人不太喜欢看 官方文档,在这篇文章中,作者带你深挖 《Promises/A+ 规范》的内容,通过 解析官方文档引导的方式与你一起手写 promise 源码

你将会收获到 👇:

  • promise深层次 的内容
  • 同步代码 中怎么处理 异步任务
  • 高级函数 设计技巧
  • 最重要的是将 异步等待、错误处理、链式调用 融入到开发思想里去

开始

在官方文档的 2.1.节 中指定 promise 必须处于 fulfilled(满足),rejected(拒绝),pending(等待) 三种状态

  • 2.1.1.:处于 pending 状态的可能会转变成其他两种状态
  • 2.1.2.:处于 fulfilled 状态不能转变为其他状态,而且必须有一个不可以转变的 value
  • 2.1.3.:处于 rejected 状态不能转变为其他状态,而且必须有一个不可以转变的 reason

这非常好理解,pending 状态的 promise 需要通过 resolve(value) 才能转变成 fulfilled或者 reject(reason) 转变成 rejected

当我们 new Promise(executor) 实际上是传入一个 执行器 函数,这个执行器函数接收两个参数 resolve, reject,而且是同步执行,不难写出如下代码:

// 使用常量维护 promise 状态
const STATUS = {
  PENDING: 'pending',
  FULFILLED: 'fulfilled',
  REJECTED: 'rejected',
};

class MyPromise { // 为了避免命名冲突,使用 MyPromise
  status = STATUS.PENDING;
  value;
  reason;

  constructor(executor) {
    const resolve = value => {
      if (this.status === STATUS.PENDING) { // 对应 2.1.2
        this.status = STATUS.FULFILLED;
        this.value = value;
      }
    };

    const reject = reason => {
      if (this.status === STATUS.PENDING) { // 对应 2.1.3
        this.status = STATUS.REJECTED;
        this.reason = reason;
      }
    };

    executor(resolve, reject);
  }
}

then

基本结构

在官方文档的 2.2. 节 中指出:一个 promise 必须指定一种方法访问当前或最终的 value 或者 reason,也就是说 只能访问 fulfilledrejected 状态的 promise,如果状态为 pending阻塞 Promise 链条

then 方法接收两个可选参数:

promise.then(onFulfilled, onRejected)
  • 2.2.1.:如果两个参数不是 function,则会被忽略
  • 2.2.2.onFulfilled 如果是 function,必须在 promise 变成 fulfilled 状态之后调用,value 作为这个函数的第一个参数。onFulfilled 不能在变成 fulfilled 状态之前被调用,并且不能被多次调用
  • 2.2.3.onRejected 的要求和 onFulfilled 差不多一致,使用 reason 作为函数的参数,必须在 rejected 状态之后被调用一次
  then(onFulfilled, onRejected) {
    // 对应 2.2.1. 也满足 2.2.7.3
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : value => value;
    onRejected = typeof onRejected === 'function' ? onRejected : reason => reason;
    
    // 对应 2.2.2. 也满足 2.2.7.4
    if (this.status === STATUS.FULFILLED) {
      onFulfilled(this.value);
    }
    
    // 对应 2.2.3.
    if (this.status === STATUS.REJECTED) {
      onRejected(this.reason);
    }
  }

这时候我们就完成了 Promise 的雏形,测试一下:

new MyPromise((resolve, reject) => {
  resolve(1);
  // reject(2);
}).then(
  value => {
    console.log(value);
  },
  reason => {
    console.log(reason);
  }
);

不论是 resolve 还是 reject 都能在 then 里被正确输出

总结:我们在构造函数中使用 status 维护 MyPromise 的状态,根据状态的不同 分发 对应的处理过程,在 then 方法中依然根据状态的不同调用对应状态的函数参数

处理异步

如果我们在 MyPromise 的异步任务中执行 resolve,程序不执行输出

new MyPromise((resolve, reject) => {
  setTimeout(() => {
    resolve(1);
  });
}).then(
  value => {
    console.log(value);
  },
  reason => {
    console.log(reason);
  }
);

因为 resolve 或者 reject 一旦被包裹在 异步任务 中,同步执行then 代码的时候, promise 的还是 pending,所有我们得在 then 方法里面处理 pending 的情况

class MyPromise {
  // ...
  onFulfilledCbs = []; // 收集 pending 状态 未执行 onFulfilled
  onRejectedCbs = []; // 收集 pending 状态 未执行 onRejected

  constructor(executor) {
    const resolve = value => {
      if (this.status === STATUS.PENDING) {
        // ...

        this.onFulfilledCbs.forEach(fn => fn()); // 等到异步任务完成后发布
      }
    };

    const reject = reason => {
      if (this.status === STATUS.PENDING) {
        // ...

        this.onRejectedCbs.forEach(fn => fn()); // 等到异步任务完成后发布
      }
    };

    // ...
  }
  
  then(onFulfilled, onRejected) {
    if (this.status === STATUS.PENDING) {
      this.onFulfilledCbs.push(() => { // 先订阅
        onFulfilled(this.value);
      });

      this.onRejectedCbs.push(() => { // 先订阅
        onRejected(this.reason);
      });
    }
  }
}

我们在 同步执行到 then 方法的时候,如果 promise 的 状态为 pending,我们将用一层函数包裹 回调函数 放到收集器里,等到 异步任务 完成后,resolve / reject 会通过 闭包 访问到构造函数里的变量,执行后续的代码

异步 then

在官方文档的 2.2.4. 节指出,promise.then 方法执行必须等到 上下文执行栈 只包含 平台代码,这里说的很难懂,好在 3.1. 节对 平台代码 进行说明:

翻译 / (解读):
平台代码 说的是实现 promise 的引擎,环境(比如 浏览器,node...)。在实践中,这样可以确保在事件循环的 轮询阶段 (借用 node 事件循环的概念)异步调用代码promise.then 方法是异步执行的,必须要等到同步任务执行完才能开启堆栈执行),并使用新的堆栈。可以用例如 setTimeoutsetInterval 这样的 宏任务,也可以使用 MutationObserver(h5 新概念)、process.nextTick(node 里面的内容)这样的微任务实现上文所说的异步。因为 Promise 的实现被当做平台代码,因此它本身可能包含调用处理程序的任务调度队列(宏任务或者微任务)或“蹦床”)。

官方解释最后一句可能想表达这样的意思:

const promise = Promise.resolve(1);

promise.then(value => {
  console.log(value, 1);
  promise.then(value => {
    console.log(value, 2);
  });
});

// 1 1 
// 1 2

对于同一个 promise 可以在 promise.then 里面嵌套使用。而且多个 then 接收到的 valuereason 都是全等的

根据上文我们可以将 MyPromise 的代码改造成

then(onFulfilled, onRejected) {
    // ...
    
    if (this.status === STATUS.FULFILLED) {
      setTimeout(() => {
        onFulfilled(this.value);
      });
    }

    if (this.status === STATUS.REJECTED) {
      setTimeout(() => {
        onRejected(this.reason);
      });
    }


    if (this.status === STATUS.PENDING) {
      this.onFulfilledCbs.push(() => {
        onFulfilled(this.value);
      });

      this.onRejectedCbs.push(() => {
        onRejected(this.reason);
      });
    }
  }

我们在 then 里对 promise 状态分发到对应的处理函数之后,立即进行 异步处理,使用 定时器 这个 宏任务 包内部程序(对于官方要求 2.2.4.),在 v8 引擎 底层是用 c++ 写的 微任务,所以这里我们很难做到一模一样

为什么 pending 状态的 promise 不用异步代码包裹:因为 then 中判断 pending 里面的内容一定会执行。如果 new Promise 中没有调用 resolve / rejectonFulfilledCbs 或者 onRejectedCbs 只有收集的函数,但没有机会调用;如果异步调用 resolve / reject,再异步调用 then 中的 pending,可能造成 then 后于 resolve / reject 执行,此时收集器内并无 onFulfilled / onRejcted

2.2.5. onFulfilled 必须作为作为函数,尽管没有值
2.2.6. then 可以在同一个 promise 多次调用,不管是 rejected 还是 fulfilled 状态,各自的回调函数按照初始的顺序执行

这两个要求我们在之前的代码已经达到了

2.2.7. then 必须返回一个 promise

promise2 = promise1.then(onFulfilled, onRejected);

说明 then 返回的 promise2 与之前的 promise1 不是同一个 promise

2.2.7.1:如果 onFulfilled / onRejected 返回一个值 x ,请运行 解析Promise程序 解析 x,在官方文档 3.1. 节会有解释,下文也会详细讲解这个过程
2.2.7.2.:如果 onFulfilled / onRejected 抛出一个不可以预期的错误 epromise2reject 这个 reason

所以按照这两个要求继续改造 MyPromise.prototype.then:

  then(onFulfilled, onRejected) {
    // ...

    const promise2 = new MyPromise((resolve, reject) => {
      if (this.status === STATUS.FULFILLED) {
        setTimeout(() => {
          try {
            let x = onFulfilled(this.value);
            resolveX(promise2, x, resolve, reject);
          } catch (e) {
            reject(e);
          }
        });
      }

      if (this.status === STATUS.REJECTED) {
        setTimeout(() => {
          try {
            let x = onRejected(this.reason);
            resolveX(promise2, x, resolve, reject);
          } catch (e) {
            reject(e);
          }
        });
      }

      if (this.status === STATUS.PENDING) {
        this.onFulfilledCbs.push(() => {
          try {
            let x = onFulfilled(this.value);
            resolveX(promise2, x, resolve, reject);
          } catch (e) {
            reject(e);
          }
        });

        this.onRejectedCbs.push(() => {
          try {
            let x = onRejected(this.reason);
            resolveX(promise2, x, resolve, reject);
          } catch (e) {
            reject(e);
          }
        });
      }
    });

    return promise2; // 返回的 promise2 可以调用 then
  }
  
function resolveX(promise2, x, resolve, reject) {
  resolve(x); // 这里我们简单处理了一下 x 的值,让 x 可以继续被传递
}

2.2.7.3:如果 onFulfilled 不是函数并且 promise1 的状态时 fulfilledpromise2 必须以与 promise1 相同的 value 实现。
2.2.7.4:如果 onRejected 不是函数并且 promise1 的状态时 rejectedpromise2 必须以与 promise1 相同的 reason 实现。

这两个要求说的是:可以多个 then 穿透传递 value / reason,这里就是 链式调用 的核心实现步骤了

Promise.resolve(1)
  .then()
  .then()
  .then()
  .then(value => console.log(value));

这两个要求其实在之前我们已经实现了,如果 then 的两个参数不是函数的话,promisevalue / reason 会继续传递,我们只要给这两个参数添加上默认值即可。

  then(onFulfilled, onRejected) {
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : value => value;
    onRejected = typeof onRejected === 'function' ? onRejected : reason => reason;
    
    // ...
  }

到此,整个 then 方法我们基本就还原完毕了。

测试一下:

new MyPromise((resolve, reject) => {
  // setTimeout(() => {
    resolve(1);
  // });
})
  .then()
  .then()
  .then()
  .then(
    value => {
      console.log(value, 'value');
    },
    reason => {
      console.log(reason, 'reason');
    }
  );

resolve 正常,如果我们将 resolve 换成 reject,依然会输出成 value,而不是 reason,这是为什么呢?

重点new MyPromise(executor)executor 执行器会先执行 resolve,一旦状态被修改为 fulfilled,就不会执行 rejected

解决方案:我们可以在 resolveX(promise2, x, resolve, reject, promise1) 多增加一个参数,这个参数在调用的时候只需要在 then 中将 this 传入,因为 this 就是 promise1,我们可以根据 promise1 的状态修改 promise2 的状态

这种做法不如 promise 原生手法来的精妙,首先我们的解决方案 违背了开闭原则resolveX 函数应该只处理 x,尽量不修改 promise2,其次我们处理过程 不能很好地区分 fulfilledrejected

v8 引擎 底层是利用错误机制处理这个问题:

class MyPromise {
  constructor (executor) {
    // ... 
   
    try {
      executor(resolve, reject);
    } catch (e) {
      reject(e);
    }
    // ...
  }
  
  then (onFulfilled, onRejected) {
    onRejected =
      typeof onRejected === 'function'
        ? onRejected
        : reason => {
            throw reason;
          };
  }
}

在调用 onRejected 函数会报错,同时在执行 MyPromise 构造函数的时候接收 reason,是不是很巧妙呢?

resolveX

在官方文档的 2.3 节的内容规定 resolveX 函数的实现

不知道大家思考过:为什么会有 resolveX 的过程?

因为 x 的返回值也可能是个 promise,而且这个 promise 经过 Promise.resolve() 包装后并抛出,在这个专栏的《其实你不知道 Promise.then - 掘金 (juejin.cn)》,我们详细地讲解了这个过程

Promise.resolve(1)
  .then(() => {
    return Promise.resolve(2);
  })
  .then(value => {
    console.log(value); // 2
  });

image.png 来源于:Promises/A+ (promisesaplus.com)

这段话大概说的是也不一定要按照 2.3. 节的要求实现 thenv8 引擎 底层依然加强了这个规范的要求

2.3.1.:如果 xpromise 引用同一对象,则 reject promiseTypeError 作为原因拒绝
2.3.2.:如果 xpromise,则要维护它的 状态value/reason
2.3.3:如果 x 是个函数或对象:

  • 2.3.3.1:让 then = x.then
  • 2.3.3.2:如果 x.then 导致抛出异常 e(比如发生数据劫持),reject promisereasone
  • 2.3.3.3:然后是一个函数,用 x 调用它,第一个参数 resolvePromise,第二个参数rejectPromise ,(这里是处理 thenable 接口,如果不了解 thenable 可以阅读《你真的明白 promise 的 then 吗?》)
  • 2.3.3.4:如果不是函数,则将 x 设置为 fulfilled 状态

2.3.4.:如果 x 不是 function / object,则将 x 设置为 fulfilled 状态

根据 2.3 节的要求我们能写出如下代码:这里的代码逻辑完全是与官方文档的要求同步

function resolveX(promise2, x, resolve, reject) {
  // 对应 2.3.3.1
  if (x === promise2) {
    return reject(new TypeError('Chaining cycle detected for promise #<MyPromise>'));
  }

  // 对应 2.3.3.2
  // 处理 Promise
  if (x instanceof MyPromise) {
    if (x.status === 'pending') {
      // 对应 2.3.3.2.1
      x.then(function (v) {
        resolveX(promise2, v, resolve, reject);
      }, reject);
    } else {
      // 对应 2.3.3.2.2 和 2.3.3.2.3
      x.then(resolve, reject);
    }
  }

  // 对应 2.3.3.3
  // 处理 thenable
  let called = false; // 对应 2.3.3.3.3 resolvePromise 和 rejectPromise 只能被调用一次
  if (x !== null && (typeof x === 'object' || typeof x === 'function')) {
    try {
      then = x.then;
      if (typeof then === 'function') {
        then.call(
          x,
          function resolvePromise(y) {
            // 对应 2.3.3.3.1
            if (called) return; // 对应 2.3.3.3.3
            called = true;
            return resolveX(promise2, y, resolve, reject);
          },
          function rejectPromise(r) {
            // 对应 2.3.3.3.2
            if (called) return; // 对应 2.3.3.3.3
            called = true;
            return reject(r);
          }
        );
      } else {
        // 对应 2.3.3.4
        resolve(x);
      }
    } catch (e) {
      // 对应 2.3.3.3.4
      if (called) return; // 对应 2.3.3.3.3
      called = true;
      return reject(e);
    }
  } else {
    // 对应 2.3.4
    resolve(x);
  }
}

测试案例:

const p = new MyPromise((resolve, reject) => {
  setTimeout(() => {
    resolve(1);
  });
})
  .then(value => {
    console.log(value, 'value'); // 1

    return new MyPromise((resolve, reject) => {
      resolve(2);
    });
  })
  .then(value => {
    console.log(value); // 2
  });
  
const p = new MyPromise((resolve, reject) => {
  setTimeout(() => {
    resolve(1);
  });
})
  .then(value => {
    console.log(value, 'value'); // 1

    return {
      then(resolve, reject) {
        reject(2);
      },
    };
  })
  .then(
    value => {
      console.log(value, 'value');
    },
    reason => {
      console.log(reason, 'reason'); // 2
    }
  );

至此,关于 promise 的源码已经完全还原了,除了在 3.1 节我们无法使用微任务包裹对应的处理程序,其他的部分我们都完全按照 《Promises/A+ 规范》的要求实现了

catch

catch 方法就比较简单了,本质就是一个简化版的 then

  catch(onRejected){
    return this.then(null, onRejected);
  }

总结

手写 promise 源码有很多值得学习的地方,例如处理怎么 处理异步?怎么利用 错误机制 优化代码?如何将 异步等待链式调用 的思想融入到你的代码里,熟练地使用 高阶函数 封装 底层库

如果觉得本文不错的话,可以给作者点一个小小的赞,你的鼓励将是我前进的动力
如果你本文对你有帮助或者你有不同的意见,欢迎在评论区留下你的足迹

Promise 相关文章推荐