Promise 执行机制——共有三种状态:pending(进行中)、fulfilled(已成功)和rejected(已失败) & 宏任务(主线) 微任务(穿插)

深入探讨 Promise 之前,有个概念先交代一下,有助于对后文进行了解。

微任务

通常,我们把消息队列中的任务成为宏任务,而每一个宏任务中都包含了一个微任务队列。等到当前宏任务中的主要任务执行完成了以后,渲染引擎其实并不着急执行下一个宏任务,而是先去执行当前宏任务中的微任务。

只是引入了概念,我想大家会跟我有同样的疑惑,平常大部分的开发中,宏任务就能满足需求了,为什么要引入微任务呢?

现在来设想一下,在的消息队列中,可能随时被添加:页面渲染事件、各种IO的完成事件,执行 JavaScript 脚本事件、用户交互事件等等,而添加事件是由系统操作的,JavaScript 并不能准确的掌控任务要添加到队列的位置,进而也没法掌控开始执行任务的时间。举个例子:

function time2() {
    for (let a = 0; a < 2000; a++) {
      console.log('time2');
    }
  }
  function time1() {
    for (let a = 0; a < 5000; a++) {
      console.log('time1');
    }
    setTimeout(time2, 0)
  }
  setTimeout(time1, 0);

本来的目的是想要通过设置 setTimeout 来设置俩回调任务,按照顺序执行并且中间也不要插入任务。但是按照图中的执行情况来看,我们并不能控制。在两个任务中间被插入了很多系统级别的任务。这显然不符合预期。

如果概念还是感觉难以理解,可以这样想,现在你是主线程,去参加项目评审会并且被分配了一个个的任务,这些任务就是任务队列,每一个任务可以认为是一个宏任务,在你做第一个任务(A 任务)的时候,这时候产品经理突然过来给你说:”现在对于 A 任务的需求有些变动,客户急着要,不能放到第二期“,这时候,作为社畜的你也没有选择,看了一下改动也不大,就打算先完成当前需求后再进行新增A任务新增需求的开发,不过因为很紧急,也得在 B 任务开始之前改好,这些A任务新增需求整理成的任务可以被理解为微任务。

产生微任务的两种方式

  1. 是使用 MutationObserver 监控某个 DOM 节点,然后在通过 JavaScript 来修改这个节点,当 DOM 节点发生变化时,就会产生 DOM 变化记录的微任务。
  2. 使用 Promise ,当调用 Promise.resolve() 或者 Promise.reject() 的时候,也会产生微任务。

通过上述两种方式产生的微任务都会被 JavaScript 引擎按照顺序保存到微任务队列中。

好了,有了这部分概念,就可以开始进入激动人心的时刻了,我们来聊一聊Promise 吧。

什么是 Promise

Promise 对象用于表示一个异步操作的最终完成(或失败),及其结果值。Promise 一旦被创建,会立即执行,共有三种状态:pending(进行中)、fulfilled(已成功)和rejected(已失败)。只有异步结果可以决定当前是哪一种状态,状态一旦被确定就再也不会更改。也就是说, Promise 对象状态的更改只有两种可能:从 pending 变为 fulfilled 和从 pending 变为 rejected。

在这里插入图片描述

Promise 的规范

Promise 必须遵循一组特定的规则:

  • Promise 或者 “thenable” 必须是一个提供符合标准的 .then() 方法的对象
  • 一个 pendding 状态的 Promise 会转换为 fulfilled 状态或 rejected 状态
  • Promise 对象一旦变为 fulfilled 状态或 rejected 状态,则状态永远不会再次发生改变
  • 一旦 Promise 执行完成后,一定会产生一个值(这个值可能是 undefined)。并且当前值永远不会被更改。

在这里插入图片描述

Promise 是为了解决什么问题

简单概括一句话,Promise 的出现主要是解决 异步代码风格 的问题。在 Promise 出现以前,如果不用JQuery ,有很多人都是这么去写异步代码的:

function XFetch(request, resolve, reject) {
    let xhr = new XMLHttpRequest()
    xhr.ontimeout = function (e) {
      reject(e);
    }
    xhr.onerror = function (e) {
      reject(e);
    }
    xhr.onreadystatechange = function () {
      if (xhr.status = 200) resolve(xhr.response)
    }
    xhr.open(request.method, URL, request.sync);
    xhr.timeout = request.timeout;
    xhr.responseType = request.responseType;
    //... 
    xhr.send();
  }

感觉挺完美,在业务逻辑比较简单的情况下也能很好的满足需求,但是,一旦功能变得复杂起来,会发现面临新的问题—回调地狱。

XFetch(
    makeRequest('https://time.geekbang.org/?category'),
    function resolve(response) {
      console.log(response);
      XFetch(makeRequest('https://time.geekbang.org/column'),
        function resolve(response) {
          console.log(response);
          XFetch(makeRequest('https://time.geekbang.org'),
            function resolve(response) {
              console.log(response)
            },
            function reject(e) {
              console.log(e)
            })
        },
        function reject(e) {
          console.log(e)
        })
    },
    function reject(e) {
      console.log(e)
    })

这一坨代码一看是不是就觉得累觉不爱?等到维护的时候,第一反应估计就是:”我的妈呀,这是什么?“

这坨代码看上去很乱,主要是因为:

  1. 嵌套调用
  2. 任务具有不确定性。可能成功,也可能失败,所以每个任务都需要进行两次判断。

那么,现在的目标现在就变的很明确了,只要干掉第一个问题,解决掉第二个问题,那就完美了。

好了,让我们先来看一下如果使用 Promise 重构 XFetch 的代码

function XFetch(request) {
    function executor(resolve, reject) {
      let xhr = new XMLHttpRequest();
      xhr.open('GET', request.url, true);
      xhr.ontimeout = function (e) {
        reject(e);
      }
      xhr.onerror = function (e) {
        reject(e);
      }
      xhr.onreadystatechange = function () {
        if (this.readyState === 4) {
          if (this.status === 200) {
            resolve(this.responseText, this);
          } else {
            let error = { code: this.status, response: this.response };
            reject(error, this)
          }
        }
      }
      xhr.send()
    }
    return new Promise(executor)
  }

这个时候再利用 XFetch 来构造请求流程,就变的非常容易了

const x1 = XFetch(makeRequest('https://time.geekbang.org/?category'));
  var x2 = x1.then(value => {
    console.log(value);
    return XFetch(makeRequest('https://www.geekbang.org/column'))
  });
  var x3 = x2.then(value => {
    console.log(value);
    return XFetch(makeRequest('https://time.geekbang.org'))
  })
  x3.catch(error => { console.log(error) })

那么,问题来了,Promise 是怎么解决第一个问题(嵌套回调)的呢?

首先,Promise 实现了回调函数的延时绑定。

//创建Promise对象x1,并在executor函数中执行业务逻辑
  function executor(resolve, reject) {
    resolve(100)
  }
  let x1 = new Promise(executor);
  //x1延迟绑定回调函数onResolve
  function onResolve(value) {
    console.log(value);
  }
  x1.then(onResolve);

还记得在文章开头提到的微任务吗? 提到了延时绑定的概念,现在就可以把它与微任务串起来了。

当要执行 Promise 中的 reslove 函数的时候就会触发 x1.then 中设置的 onResolve,由于 Promise 使用了延时绑定技术,所以在执行 resolve 函数的时候,回调函数还没有绑定,只能推迟回调函数的执行,如果要是使用定时器的话,我们都知道定时器是一个宏任务,效率并不高,所以,Promise 为了提升代码的执行效率,就改造成了微任务,这就是 Promise 中使用 微任务的原因。

其次,需要将回调函数 onResolve 的返回值穿透到最外层。

// 创建Promise对象x1,并在executor函数中执行业务逻辑
  function executor(resolve, reject) {
    resolve(100)
  }
  let x1 = new Promise(executor);
  //x1延迟绑定回调函数onResolve
  function onResolve(value) {
    console.log(`onResolve 中的 value:${value}`);

    const x = new Promise((resolve, reject) => {
      resolve(value + 1);
    })
    console.log('onResolve 中新建的 Promise:');
    console.log(x);
    return x;
  }
  let x2 = x1.then(onResolve);
  console.log(x2);
  x2.then((value) => {
    console.log(`x2.then中的value:${value}`);
    console.log(`x2.then中的x2: `);
    console.log(x2);
  })

打印结果如下图所示:

在这里插入图片描述

这样大概明白了,Promise 是怎么实现回调函数穿透的:

创建 Promise 执行结果先保存在 x1 变量中,然后 x1.then 执行的 onResolve() 返回值为new Promise((resolve,reject)⇒{…}),也就是说,当前 x2 = new Promise(resolved),这样,就实现了回到函数返回值穿透到最外层的工作。同样基于这个原因,Promise可以采用链式编程,即 then 方法后面再调用另一个 then 方法。

由此,我们知道了,Promise 通过回调函数的延时绑定和回调函数返回值穿透解决了多层嵌套的问题。

那么,Promise 应该怎么处理异常才能合并多个任务的错误处理呢?

function executor(resolve, reject) {
    let rand = Math.random();
    console.log(1);
    console.log(rand);
    if (rand > 0.5) resolve()
    else reject()
  }
  let p0 = new Promise(executor);
  let p1 = p0.then((value) => {
    console.log("succeed-1");
    return new Promise(executor)
  })
  let p3 = p1.then((value) => {
    console.log("succeed-2");
    return new Promise(executor)
  });
  let p4 = p3.then((value) => {
    console.log("succeed-3");
    return new Promise(executor)
  })
  p4.catch((error) => { console.log("error") });
  console.log(2)

Promise 对象的错误就有"冒泡"性质,会一直向后传递,直到被 onReject 或 catch 语句捕获为止,所以,上述例子中,P0~P4 无论哪个对象抛出异常,都可以通过最后一个对象 P4.chatch 来捕获。这样就不需要每个 Promise 对象单独捕获异常了。

那么,Promise 对象的错误又是如何具有"冒泡"性质的呢?

promise内部有resolved_和rejected_变量保存成功和失败的回调,进入.then(resolved,rejected)时会判断rejected参数是否为函数,若是函数,错误时使用rejected处理错误;若不是,则错误时直接throw错误,一直传递到最后的捕获,若最后没有被捕获,则会报错。会触发 unhandledrejection 事件。

window.addEventListener("unhandledrejection", event => {
  console.warn(`UNHANDLED PROMISE REJECTION: ${event.reason}`);
});

如果 Promise 状态已经变成了 resolved,再抛出就是无效的了。

const promise = new Promise(function(resolve, reject) {
  resolve('ok');
  throw new Error('test');
});
promise
  .then(function(value) { console.log(value) })
  .catch(function(error) { console.log(error) });
// ok

如何取消 Promise

很多人在最初学习 Promise 的时候会想要知道应该如何取消 Promise。这里有很多人在试图取消 Promise 时常犯的错误:

  • 试图将 .cancel() 添加到 Promise 中。
const promise = new Promise(function(resolve, reject) {
  resolve('ok');
});

// 注意,Promise 并不支持这种写法
promise
  .then(function(value) { console.log(value) })
  .catch(function(error) { console.log(error) })
    .cancel(function(cancel) { console.log(cancel) });
  • 忘记清理
    有些人将 Promse.race() 用作取消机制。
const p = Promise.race([
  fetch('/resource-that-may-take-a-while'),
  new Promise(function (resolve, reject) {
    setTimeout(() => reject(new Error('request timeout')), 5000)
  })
]);

p
.then(console.log)
.catch(console.error);

在5秒之内没有返回结果,Promise 的状态就会变成 reject,否则就是 resolved。这样可能存在隐患,取的取消控制权的地方是在创建 Promise 的地方,这里又是你唯一能进行适当清理数据的地方,比如说,清除超时或通过清除对数据的引用来释放内存。很多时候会忘记对添加的定时器等进行清理。

  • 忘记对拒绝 cancel 进行处理
  • 过于复杂

重新考虑取消 Promise 的实现方式

通常,我们使用默认参数告诉 Promise,在默认情况下不取消,不过当我们捕获到超时的 ID 时可以取消他。我们使用 cancel.then() 方法来处理取消和资源清理,只有在 Promise 状态更改为 fulfilled 或 rejected 之前才有机会取消,否则毫无意义

function cancelPromise(promise, token) {
  Promise.race([
    promise,
    new Promise((_, reject) => {
      token.cancel = function cancel(reason) {
        reject(reason);
      };
    })
  ]);
}
const cancelToken = {
  cancel() { }
};
cancelPromise(fetch('url'), cancelToken);
cancelToken.cancel('成功取消');
Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐