期约Promise的实践与原理

484 阅读12分钟

这篇文章投稿来自「字学镜像计划」创作者笔记 —— 2021.7

一、前言

Promise是ES6推出的一类对象,用来解决“异步”编程问题。JavaScript的核心脚本可以看作单线程运行环境(不论是浏览器环境还是NodeJS环境),为了不让等待时间较长的网络请求等I/O操作阻塞了主线程的运行,就需要一种异步+回调的机制。

简单来说,“异步”主要涉及到两个要素

  • 数据,当然请求方也可以不带数据
  • 回调函数:I/O操作结束后要执行的行为,一般分为执行成功后的回调,以及执行出错后的错误处理

大部分情况下,我们需要根据返回的结果来进行下一步操作,在Promise出现之前,一种名为“回调地狱”(Callback Hell)的现象会让开发者痛不欲生,

call-hell

实现了Pormise规范以后,我们可以通过优雅的“链式调用”来解决问题,以下例子来自MDN文档

const myPromise =
  (new Promise(myExecutorFunc))
  .then(handleFulfilledA,handleRejectedA)
  .then(handleFulfilledB,handleRejectedB)
  .then(handleFulfilledC,handleRejectedC);
​
// 或者,这样可能会更好...const myPromise =
  (new Promise(myExecutorFunc))
  .then(handleFulfilledA)
  .then(handleFulfilledB)
  .then(handleFulfilledC)
  .catch(handleRejectedAny);

可以看到,一个then操作就让代码优雅了很多,避免了层层嵌套,上一层的返回结果可以直接作为下一层的参数,并且可以在链最后通过catch处理错误,可以说是非常方便了。

二、为什么要学习Promise

除了以上原因,学习Promise的一个重要动机是,一些开发时经常用到的API或库本身就是基于Promise的,如果你没有了解过Prommise,那么使用过程肯定也不会那么顺利,以上,就是我们要学习Promise的原因。

Promise的应用体现在三个方面,第一是Web自带的API,importfetch等,这些都是非常基础且通用的函数;第二是NodeJS的核心库,NodeJS自带的核心库几乎处处体现了这种异步的思想,比如基于fs的文件I/O,基于child_process的子进程创建等;第三是一些重要的库函数,比如封装了发起http请求的axios库,它同时支持在浏览器和node环境发起请求操作。

2.1 Web API

来看一个Web API的例子,以下代码运行在最新的Chrome环境中,其支持顶层的await调用。因为跨域问题,代码在掘金主页下执行,

const a = fetch("https://juejin.cn/");
const b = await fetch("https://juejin.cn/");
console.log(a); // Promise {<fulfilled>: Response}
console.log(b); // Response {type: "basic", url: "https://juejin.cn/", redirected: false, status: // 200, ok: true, …}

这里我们可以看到,直接fetch返回的结果a本身是一个Promise对象,不可以直接使用它,而正确的方式是在fetch函数之后通过then调用来继续处理返回的结果。

b变量是通过await取得异步返回的结果,这是另外的话题了,这里暂时搁置。

ES2017 标准引入了async/await关键字,它们是基于Promise,通过这种语法糖让我们可以更加方便地使用异步编程。await以前是只能在async函数中使用,V8自9.1版本以来支持了await的顶层使用。

2.2 Node核心库

通过Node来读取文件的操作大概长这样,

fs.readFile('/etc/passwd', (err, data) => {
  if (err) throw err;
  console.log(data);
});

回调函数的第一个参数是发生错误err,第二个参数是读取到的数据data

当然,如果开发者不习惯异步的思想,Node的一些函数也同时支持了同步调用,比如上面这个函数还可以同步地使用,

const password = fs.readFileSync('/etc/passwd');
// ...
// 对password操作

关于NodeJS的设计思想和诞生初衷,可以看看这个视频:Ryan Dahl: Node JS,这是Node的作者在第一次发布后的推广讲演。

2.3 库函数

一些I/O操作的库函数本身也是基于Promise的,比如通过axios发送一个post请求,

axios.post('/user', {
    firstName: 'Fred',
    lastName: 'Flintstone'
  })
  .then(function (response) {
    console.log(response);
  })
  .catch(function (error) {
    console.log(error);
  });

总之,Promise和async/await的异步操作十分常见,理解好这个概念在平时的编程中是十分有帮助的。

三、手写实现一个Promise

为了更深入地理解Promise,我们在这里手动实现一个,这也是非常经典的面试题了。实现一个Promise的过程可以照着PromiseA+规范来操作,网上也有很多这样的文章,这里我们参考了其中一篇,提炼如下这几个步骤。

讲一个故事,Promise是一个美好的承诺,承诺本身会做一些异步的事情,做完之后会返回成功失败,以方便下一个承诺的执行;当成功的时候,下一个Promise的resolve会承接这个状态,当失败的时候,就让reject函数来执行解决失败的状态。 解决完了以后会接着返回一个pending(Promise),可以执行调用then继续执行,直到终止。

—— 摘自PromiseA+规范中文版评论区

3.1 构造函数

一个 Promise有两条比较重要的性质,第一条是,当前状态必须为以下三种状态中的一种:等待态(Pending) 执行态(Fulfilled)和拒绝态(Rejected),且状态转移的方式只有两个,

  • 从等待(Pending)到执行(Fulfilled),这个过程称为resolve

  • 从等待(Pending)到拒绝(Rejected),这个过程称为reject

    这个过程是不可逆的,也就是说,一旦Promise进入了后两个状态之一,其状态就固定了。

    // 三种状态
    const PENDING = 'PENDING';
    const FULFILLED = 'FULFILLED';
    const REJECTED = 'REJECTED';
    ​
    class Promise {
      // 传入执行函数executor,包含两个参数,即(resolve, reject)
      constructor(executor) {
        this.status = PENDING;
        this.value = undefined; // resolve对应的返回值
        this.reason = undefined; // reject对应的拒因
        this.onResolvedCallbacks = []; // resolve的回调列表
        this.onRejectedCallbacks = []; // reject的回调列表
    ​
        const resolve = (value) => {
          if (this.status === PENDING) {
            this.status = FULFILLED;
            this.value = value;
            this.onResolvedCallbacks.forEach((fn) => fn());
          }
        };
    ​
        const reject = (reason) => {
          if (this.status === PENDING) {
            this.status = REJECTED;
            this.reason = reason;
            this.onRejectedCallbacks.forEach((fn) => fn());
          }
        };
      
        // Promise构造函数中必须执行传入的函数
        try {
          executor(resolve, reject);
        } catch (error) {
          reject(error);
        }
      }
    

    这里有两个注意点,第一是下面这两个回调列表,

    this.onResolvedCallbacks = []; // resolve的回调列表
    this.onRejectedCallbacks = []; // reject的回调列表
    

    这个是为了解决在Promise里先通过setTimeout等异步操作执行,再调用then的情况,这个时候就只能先把回调函数存到列表里,等到resolvereject的时候再统一执行,就是下面这两行,

    this.onResolvedCallbacks.forEach((fn) => fn());
    this.onRejectedCallbacks.forEach((fn) => fn());
    

    现在听起来比较抽象,等到我们实现then方法时放入回调函数的时候就更清晰一些。

    第二个注意点是resolve和reject这两个函数的实现必须通过「箭头函数」实现,这样才会继承this作用域,指向这个实例里的status等属性,否则,如果用普通函数function的方法实现,就会导致this作用域发生改变,产生错误。

3.2 then的链式调用

上面说到,Promise会有两条比较重要的特性,第一条是状态的有限性和不可逆性,第二条就是:一个 promise 必须提供一个 then 方法以访问其当前值,返回其终值或拒因。

then方法是Promise思想的核心所在,它必须具有「链式调用」和「值穿透」的特性,让我们来看看吧。

  then(onFulfilled, onRejected) {
    // 1. 类型检查
    onFulfilled = typeof onFulfilled === "function" ? onFulfilled : (v) => v;
    onRejected =
      typeof onRejected === "function"
        ? onRejected
        : (err) => {
            throw err;
          };
    // 2. 每次调用 then 都返回一个新的promise
    const promise2 = new Promise((resolve, reject) => {
      if (this.status === FULFILLED) {
        // 3. 使用setTimeout模拟微任务的异步
        setTimeout(() => {
          try {
            let x = onFulfilled(this.value);
            // 4. resolvePromise引入新的一层抽象
            resolvePromise(promise2, x, resolve, reject);
          } catch (e) {
            reject(e);
          }
        }, 0);
      }
​
      if (this.status === REJECTED) {
        setTimeout(() => {
          try {
            let x = onRejected(this.reason);
            resolvePromise(promise2, x, resolve, reject);
          } catch (e) {
            reject(e);
          }
        }, 0);
      }
​
      if (this.status === PENDING) {
        // 4. 将要执行的回调先压入各自的回调列表里
        this.onResolvedCallbacks.push(() => {
          setTimeout(() => {
            try {
              let x = onFulfilled(this.value);
              resolvePromise(promise2, x, resolve, reject);
            } catch (e) {
              reject(e);
            }
          }, 0);
        });
​
        this.onRejectedCallbacks.push(() => {
          setTimeout(() => {
            try {
              let x = onRejected(this.reason);
              resolvePromise(promise2, x, resolve, reject);
            } catch (e) {
              reject(e);
            }
          }, 0);
        });
      }
    });
​
    return promise2;
  }

then的实现是Promise的核心,也是精华所在,因为代码略显复杂,所以我在代码的注释里标注了几个关键的地方,一一解读。

3.2.1 类型检查

onFulfilled = typeof onFulfilled === "function" ? onFulfilled : (v) => v;
onRejected =
  typeof onRejected === "function"
  ? onRejected
: (err) => {
  throw err;
};

我们通过then函数的链式调用传入的参数必须是函数,分别对应Fulfilled态和Rejected态的回调执行,那如果不是函数,而是一个值呢?那么可以包装在一个函数里面,直接返回或者抛出(throw)这个值。

3.2.2 promise2

这个promise2就是then函数要返回的结果(规范里说过,then的返回值也必须是一个Promise)。这里就体现了递归的思想,一个Promise通过then方法返回一个新的Promise,它又具有自己的then方法,于是继续调用,周而复始。

但是,不论一个Promise调用了多少次then方法,它都是有且仅有一个确定的状态,这个状态就是这里的this.status,这就是为什么我们通过判断这个唯一状态源*,来决定执行怎样的操作。

*唯一真相源(single source of truth):这是一个React中经常出现的概念,没有明确的含义,用在这里当作一个梗..

3.2.3 setTimeout

为什么我们说这里的setTimeout只是模拟了异步的执行呢?这个和事件循环机制有关,setTimeout是外部的大循环,Promise/async/await是内部的微任务循环。

这里只是要澄清,使用setTimeout并不是最准确的做法,只是为了兼容各种运行环境的模拟方法。

3.2.4 resolvePromise

resolvePromise是一个需要我们自己实现的函数,它的作用是对then传入的onFulfilledonRejected执行后的结果做一些判断(尽可能地去实现一些PromiseA+的规范),比如说当执行的结果又是一个期约的情况,或者出现了自己调用自己等错误情况,做一些边界处理。

简单来说,如果你的Promise不会出现这些复杂情况,也就不需要这个函数,直接resolve或者reject执行的结果就好。

完整的resolvePromise代码可以在这个链接看到。

3.3 静态方法

ok,其实说了这么多,当我们实现完then方法的时候就基本大功告成了,这一段可以作为补充内容。

相比于传统的回调模式,Promise还提供了一系列静态方法作为特性,这其实也是Promise的优势所在,下面我们一一来看这些静态方法都有什么作用吧!

3.3.1 Promise.resolve

下面这段话摘自MDN:Promise.resolve(value)方法返回一个以给定值解析后的Promise对象。如果这个值是一个 promise ,那么将返回这个 promise ;如果这个值是thenable的,返回的promise会“跟随”这个thenable的对象,采用它的最终状态;否则返回的promise将以此值完成。此函数将类promise对象的多层嵌套展平。

实现如下,

Promise.resolve = (value) => {
  // 1. 本身是Promise
  if (value instanceof Promise) {
    return value;
  } else if (typeof value === "object" && typeof value.then === "function") {
    // 2. 具有then方法
    return new Promise((resolve) => {
      value.then(resolve);
    });
  } else {
    // 3. 用value创建一个Promise
    return new Promise((resolve) => resolve(value));
  }
};
​

类似的,Promise.reject方法的实现思路也是这样,不过简单很多,

Promise.reject = (reason) => {
  return new Promise((resolve, reject) => reject(reason));
};

3.3.2 Promise.all

Promise.all()方法接收一个promise的iterable类型(注:Array,Map,Set都属于ES6的iterable类型)的输入,并且只返回一个Promise实例, 那个输入的所有promise的resolve回调的结果是一个数组。这个Promise的resolve回调执行是在所有输入的promise的resolve回调都结束,或者输入的iterable里没有promise了的时候。另外,它的reject回调执行是,只要任何一个输入的promise的reject回调执行或者输入不合法的promise就会立即抛出错误,并且reject的是第一个抛出的错误信息。

解释一下,

  • 传入的所有 Promsie 都是 fulfilled,则返回由他们的值组成的,状态为 fulfilled 的新 Promise;
  • 只要有一个 Promise 是 rejected,则返回 rejected 状态的新 Promsie,且它的值是第一个 rejected 的 Promise 的值;
  • 只要有一个 Promise 是 pending,则返回一个 pending 状态的新 Promise;
  • 输入的参数arr只要是一个可迭代对象就可以,所以用Array.from转换一下;

实现如下,

Promise.all = (arr = Array.from(arr)) => {
  let index = 0,
    result = [];
  return new Promise((resolve, reject) => {
    arr.forEach((p, i) => {
      Promise.resolve(p).then(
        (val) => {
          index++;
          result[i] = val;
          if (index === arr.length) {
            resolve(result);
          }
        },
        (err) => {
          reject(err);
        }
      );
    });
  });
};
​

3.3.3 Promise.race

Promise.race(iterable) 方法返回一个 promise,一旦迭代器中的某个promise解决或拒绝,返回的 promise就会解决或拒绝。

Promise.race = (arr = Array.from(arr)) => {
  return new Promise((resolve, reject) => {
    arr.forEach((p) => {
      Promise.resolve(p).then(
        (val) => {
          resolve(val);
        },
        (err) => {
          rejecte(err);
        }
      );
    });
  });
};
​

3.3.4 Promise.any

Promise.any() 是 ES2021 新增的特性,它接收一个 Promise 可迭代对象(例如数组),

  • 只要其中的一个 promise 成功,就返回那个已经成功的 promise
  • 如果可迭代对象中没有一个 promise 成功(即所有的 promises 都失败/拒绝),就返回一个失败的 promiseAggregateError 类型的实例,它是 Error 的一个子类,用于把单一的错误集合在一起
Promise.any = function (promises = Array.from(promises)) {
  return new Promise((resolve, reject) => {
    let len = promises.length;
    // 用于收集所有 reject
    let errs = [];
    // 如果传入的是一个空数组,那么就直接返回 AggregateError
    if (len === 0)
      return reject(new AggregateError("All promises were rejected"));
    promises.forEach((promise) => {
      promise.then(
        (value) => {
          resolve(value);
        },
        (err) => {
          len--;
          errs.push(err);
          if (len === 0) {
            reject(new AggregateError(errs));
          }
        }
      );
    });
  });
};
​

可以看出,Promise.racePromise.any方法非常像,但是它们有各自适用的场景。

举个例子,假如你想要从多个目标服务器获取资源,那么就可以使用.any方法,获取速度最快(返回)的那个,而不是race方法。这是因为,race方法一遇到拒绝的Promise就会返回错误,而any会持续等待到一个成功的返回。

最后,用阮一峰老师对Promise评价来结束这篇长文吧。

有了Promise对象,就可以将异步操作以同步操作的流程表达出来,避免了层层嵌套的回调函数。此外,Promise对象提供统一的接口,使得控制异步操作更加容易。

Promise也有一些缺点。首先,无法取消Promise,一旦新建它就会立即执行,无法中途取消。其次,如果不设置回调函数,Promise内部抛出的错误,不会反应到外部。第三,当处于pending状态时,无法得知目前进展到哪一个阶段(刚刚开始还是即将完成)。

(感谢阅读,写作不易,请多支持,有错误或不足的地方也请多指点)