javascript 中的 Promise

原创性声明:本文完全为笔者原创,请尊重笔者劳动力。转载务必注明原文地址。

发现有一段时间没有写文章了,中了一段时间农药的毒,后面都变得有点懒了。

今天写写javascript中的这个Promise,还记得第一次碰到这个东西是好久以前在angularJS的环境下。angularJS$resource获取了后端RESTfull api 接口的数据后是一个Promise对象,直接用双向绑定到DOM上是能正确显示的,但是控制台打印它里面的某个属性值死活就是undefined,后来磕磕碰碰摸索久了就知道有个then,今天再来总结一下这个东西。

前戏

Promise译为保证。表示异步处理的方法承诺返回一个值。Promise对象也是用于解决异步中晦涩复杂的回调现象。至于如何解决,不妨先看看在Promise之前,一个异步回调的例子:

var getData = function(url, callback) {
  var req = new XMLHttpRequest();
  req.open('GET', URL, true);
  req.onload = function () {
    callback(req);
  };
  req.send();
}

var url = 'api/user/username/:id';
getData(url, function(req) {
  if (req.status == 200) {
    $('#username').text(req.statusText); // 用jquery将请求的数据写入dom
  } else {
    console.error(new Error(req.statusText);
  }
});

如果拿到username后不是显示这么简单,还需要把username作为请求参数继续请求别的数据(例如:age),然后展示age呢。

实际情况当然是直接请求一个user就行了,这里只是举例方便。

改动一下代码:

var getData = function(url, callback) {
  var req = new XMLHttpRequest();
  req.open('GET', URL, true);
  req.onload = function () {
    callback(req.responseText);
  };
  req.send();
}

var url = 'api/user/username/:id';
getData(url, function(data) {
});

var url = 'api/user/username/:id';
getData(url, function(req) {
  if (req.status == 200) {
    var url = 'api/user/age/:username';
    getData(url, function(req) {
      if (req.status == 200) {
        $('#age').text(req.statusText); // 用jquery将请求的数据写入dom
      } else {
        console.error(new Error(req.statusText));
      }
    });
  } else {
    console.error(new Error(req.statusText));
  }
});

显然,这看起来不仅有点理解费劲,而且代码也不好看。但是,要用回调实现这里面的异步请求,只能这样干。事实上,这是一种强行异步变同步的办法,通过回调,以确保数据拿到之后再做下一步的处理,所以这是事实上的同步。如果用Promise,会怎么样呢?

var getData = function(url) {
  return new Promise(function(resolve, reject) {
    var req = new XMLHttpRequest();
    req.open('GET', URL, true);
    req.onload = function () {
      if (req.status == 200) {
        resolve(req.responseText);
      } else {
        reject(new Error(req.statusText));
      }
    };
    req.send();
  });
}

var url = 'api/user/username/:id';
getData(url).then(function(username) {
  var url = 'api/user/age/:username';
  getData(url).then(function(age) {
    $('#age').text(age); // 用jquery将请求的数据写入dom
  }, function(error) {
    console.log(error);
  });
}, function(error) {
  console.log(error);
})

乍一看,代码也没有少很多嘛。的确,但是仔细理解这两段代码时,就会感受到它们的区别。采用Promise时,调用getData时你不需要关心它内部的代码,给你的感觉就是getData(url)已经返回了我想要的结果(username),于是自然地就在then()方法中的第一个参数(匿名函数)中直接用这个结果(username)去发起下一个请求(请求age),而在then()的第二个参数(匿名函数)中对错误进行处理。

而采用回调方法,你总是需要去把该参数函数带入getData中。虽然它代码的顺序看上去和Promise很像,但代码执行的流程却是离不开回调的本质。如果回调多了,就可能会陷入回调地狱。理解和维护都会很蛋疼。

Promise

上面的例子就基本阐明了Promise的简单用法。构建一个Promise对象可以这样:

var promise = new Promise(function(resolve, reject) {
  // 异步处理,处理结束后、调用resolve 或 reject
});

然后就可以调用Promise实例的then方法:

promise.then(onFulfilled, onRejected);

onFulfilled, onRejected是两个参数函数。

  • resolve(成功)时:
    onFulfilled 会被调用
  • reject(失败)时
    onRejected 会被调用

也就是说,promise执行成功(一般是后端交互请求数据),那么就会调用onFulfilled方法,所以在这个方法里继续写拿到数据后要执行的代码即可,如果发生了错误(例如:后端报错),则会执行onRejected方法,在里面定义错误处理的代码即可。

上面涉及Promise的两个API方法:1. 构造函数Promise(); 2.实例方法then()

怎么把一个常量包装为Promise对象?

var promise = Promise.resolve(99);

当然,这个对象的then方法就完全没有第二个函数参数的必要了。

说到第二个函数参数onRejected,它是用于发生异常时被调用的。此外,还可以通过catch来捕获异常,如下:

var promise = Promise.resolve(99);
promise.then(function(value){
  console.log(value); // 99
}).catch(function(error){
  console.log(error);
})

onRejected效果一致。当然,此处的catch也没有意义。

Promise.resolve(99)可以认为是下面代码片段的语法糖:

Promise.resolve(99).then(function(value){
    resolve(value); // 此时,这个promise对象立即进入确定(即resolved)状态,并将99传递给后面then里所指定的 onFulfilled 函数
});

这里涉及了Promise的一个静态方法: resolve,除构造方法之外的另一个构建Promise的方法。

Promise的状态

用new Promise 实例化的promise对象有以下三个状态。

  1. has-resolution -Fulfilled(译文:履行)
    resolve(成功)时。此时会调用 onFulfilled

  2. has-rejection - Rejected(译文:拒绝)
    reject(失败)时。此时会调用 onRejected

  3. unresolved - Pending(译文:未决定)
    既不是resolve也不是reject的状态。也就是promise对象刚被创建后的初始化状态等。

promise对象的状态,从Pending转换为FulfilledRejected之后, 这个promise对象的状态就不会再发生任何变化。也就是说,PromiseEvent等不同,在.then 后执行的函数可以肯定地说只会被调用一次。另外,FulfilledRejected这两个中的任一状态都可以表示为Settled(不变的,稳妥的:表示这个Promise的状态已经回归稳定,即完成了FulfilledRejected)。

Thenable对象

什么是Thenable对象?从名字上看,就是可以then的对象——可以调用then方法的对象。除了Promise还有对象可以调用then方法吗?答案是肯定的,比如:

$.ajax('/json/comment.json'); // 可以继续`.then()`

这是jquery的jqXHR对象,它其实就是一个Thenable对象。但它不是一个Promise,因为不符合ECMAScript 6中的标准。

如何将它转换为一个标准的Promise对象呢?

var promise = Promise.resolve($.ajax('/json/comment.json'));// => promise对象
promise.then(function(value){
   console.log(value);
});
  1. 简单总结一下 Promise.resolve 方法的话,可以认为它的作用就是将传递给它的参数填充(Fulfilled)到promise对象后并返回这个promise对象。
  2. 此外,Promise的很多处理内部也是使用了 Promise.resolve 算法将值转换为promise对象后再进行处理的。
Promise方法链(promise chain)

前面,接触了then().catch()这种链式调用的方式,其实Promise的链式调用更加丰富:

function taskA() {
    console.log("Task A");
}
function taskB() {
    console.log("Task B");
}
function onRejected(error) {
    console.log("Catch Error: A or B", error);
}
function finalTask() {
    console.log("Final Task");
}

var promise = Promise.resolve();
promise
    .then(taskA)
    .then(taskB)
    .catch(onRejected)
    .then(finalTask);

结果显而易见:

Task A
Task B
Final Task

上面的代码的执行流程用图来展示,就像这样:

javascript 中的 Promise_第1张图片
image.png

显然, catch会处理 TaskA,也会处理 TaskB。那如果 TaskA发生了错误,还会执行 TaskB吗? 答案是不会。如下图:

javascript 中的 Promise_第2张图片
image.png

这时,我们再回到文章最开头的那个Promise例子,先根据id获取username,再根据username获取age,最后写入dom中,这个过程显然对于promise chain链式调用的应用场景再符合不过了:

var getData = function(url) {
  return new Promise(function(resolve, reject) {
    var req = new XMLHttpRequest();
    req.open('GET', URL, true);
    req.onload = function () {
      if (req.status == 200) {
        resolve(req.responseText);
      } else {
        reject(new Error(req.statusText));
      }
    };
    req.send();
  });
}

var url = 'api/user/username/:id';
getData(url)
.then(function(username) {
  var url = 'api/user/age/:username';
  return getData(url);  // a. 向下一个then中传递参数
}).then(function(age) {
  $('#age').text(age);
}).catch(function(err){
  console.log(err);
});

我们是怎样往第二个then传递异步查询的结果age呢?通过一个关键的 return

通过连续的then方法调用,整个流程(两个步骤)无论在理解上,还是代码美观上,都变得非常优雅!甚至让你在编写它的时候忘记它原来是个异步的过程——这简直和写同步代码是一样一样的嘛。

另外,我们还注意到,原本应该有两个catch变成了一个catch倒金字塔的恶心没有了,满屏的花括号也没有了。流程变得清晰。这就是使用Promise的良好开发体验。

【疑问】:稍微细心点,不难发现(a)return的是getData(url),这个结果应该是一个Promise对象,而不是一个普通值(例如: 24(岁)),为什么这样也可以在下一个then中通过age参数接收到呢?经过实践测试,返回普通值Promise对象,都可以正常接收。
【结论】:每个方法中 return 的值不仅只局限于字符串或者数值类型,也可以是对象或者promise对象等复杂类型。

事实上,在每次调用then方法后,都会返回一个新的Promise对象,以供下一次链式调用。如果then中匿名函数返回的是普通类型,Promise在内部也会将其转换为一个新的Promise对象(注意:是新的Promise对象)。

javascript 中的 Promise_第3张图片
image.png

如上图:两个promise object一定是两个不同的Promise对象,尽管then中可能什么也没有做,他们resolve之后的值也尽管可能相同。

由此,可能引出一个可能常犯的错误

不正确的:

function asyncCall() {
    var promise = Promise.resolve();
    promise.then(function() { // a
        // 任意处理
        return newVar;
    });
    return promise;
}

(a)处调用then时,返回的是一个新的Promise对象,而不再是promise那个变量,因此这个then中的异常是不会被外部捕获的,而且它的返回值也无法在外部得到。

正确的:

function asyncCall() {
    var promise = Promise.resolve();
    return promise.then(function() {
        // 任意处理
        return newVar;
    });
}

或者

function asyncCall() {
    var promise = Promise.resolve();
    var promise1 = promise.then(function() {
        // 任意处理
        return newVar;
    });
    return promise1;
}
Promise.all方法

这个方法之前没有提到过。仍以最开始案例延伸。现在我们不需要根据username获取age了,可以直接根据id获取age,也就是说我们有两个请求:1. 根据id获取username2. 根据id获取age。我们希望这两个请求都完成后,再将结果usernameage插入到DOM中。这时,Promise.all就可以用上了:

var getData = function(url) {
  return new Promise(function(resolve, reject) {
    var req = new XMLHttpRequest();
    req.open('GET', URL, true);
    req.onload = function () {
      if (req.status == 200) {
        resolve(req.responseText);
      } else {
        reject(new Error(req.statusText));
      }
    };
    req.send();
  });
}

var urlUsername = 'api/user/username/:id';
var urlAge = 'api/user/age/:id';
var requestArr = [getData(urlUsername), getData(urlAge)]; // 两个Promise对象组成的数组
Promise.all(requestArr).then(function(data) { // a. data的值是什么呢?
  var username = data[0]; // b
  var age = data[1]; // c
  $('#username').text(username);
  $('#age').text(age);
}).catch(function(err) {
  console.log(err);
});

可以看出,Promise.all的参数是一个数组,这个数组的成员是Promise对象。有点像把多个异步请求放到一个Promise里处理的感觉,这样做的好处是一来代码简洁了一些,更重要的是(a)处的then方法能确保在两个请求完成后再执行,也就是两个Promise对象的状态都变成了Settled

Promise.all 在接收到的所有的对象promise都变为 FulFilled 或者 Rejected 状态之后才会继续进行后面的处理

(a)处的data又是什么值呢?username,or age?

显然从(b)(c)的代码可以看到data是个数组,对应于requestArr中两个Promise对象所代表的值(顺序也是保持一致的)。

Promise.all处理的多个Promise不是按顺序执行的,而是同时、并行执行。也就是说上面的两个请求是同时、并发进行的,而不是请求了username,再请求age

Promise.race方法

理解了Promise.all,再看Promise.race就简单了。Promise.race的用法和all一样,也是接收一个Promise对象组成的数组,resolve后的值(data)格式也是一样的。不同之处在于:

Promise.race只要有一个promise对象进入 FulFilled 或者 Rejected 状态的话,就会继续进行后面的处理

同样以上面那个例子。如果将all改为race,那么只要有一个promiseSettled,then就会开始执行,而其他promise就不会继续执行then方法了。同样then中接收到的data,也不再是数组,而是先Settled的那个Promise对象resolve的值。

race这个意思,也能大致看出它的这个特性。

Deferred和Promise

Deferred译为延期。它和Promise不同,它没有共通的规范,换句话说,在ECMAScript内容里有对Promise的定义标准,但是没有Deferred的定义标准,更通俗的说,你用javascript语言可以直接像用MathDate那样直接在代码里去用Promise,但是Deferred是不存在的。它的存在纯粹是市面上各种类库实现出来的。我们自己也可以简单实现一个Deferred类:

deferred.js

function Deferred() {
    this.promise = new Promise(function (resolve, reject) {
        this._resolve = resolve;
        this._reject = reject;
    }.bind(this));
}
Deferred.prototype.resolve = function (value) {
    this._resolve.call(this.promise, value);
};
Deferred.prototype.reject = function (reason) {
    this._reject.call(this.promise, reason);
};

上面的 Function Deferred就是我们实现的Deferred类。熟悉bindcall原型继承的话,不难理解上面代码的含义:新建了一个对象Deferred,它有一个属性promise, 内部构建了一个Promise实例,把这个实例的一些api方法通过原型继承给了Deferred,Deferred可以通过调用自己的resolve或reject等方法控制其属性promise的状态

为什么要这么麻烦呢?

为了更好的操作Promise

比如,在之前的案例中,Promise什么时候resolvereject,我们通常只能在Promise构造函数中调用。现在用上刚写的Deferred,会显得更灵活,我们改造一下getData

var getData = function(url) {
    var deferred = new Deferred();
    var req = new XMLHttpRequest();
    
    req.open('GET', URL, true);
    req.onload = function () {
      if (req.status == 200) {
        deferred.resolve(req.responseText);
      } else {
        deferred.reject(new Error(req.statusText));
      }
    };
    req.send();
    return deferred.promise;
}

var url = 'api/user/username/:id';
getData(url).then(function(data) {
  $('#username').text(data);
}).catch(function(err) {
  console.log(err);
});

我们看到getData里面已经看不到Promise的构造函数了,用defered可以自由调用resolvereject。试想,我们有一个Promise对象,我们不知道它的状态是否已经Settled,我们可以用Deferred手动resolve一下:

var promise = ... ;// 一个来源于其他地方的promise
var deferred = new Deferred();
deferred.resolve(promise);
return deferred.promise;

返回的deferred.promise状态是确定的。

所以,其实Deferred并不抽象,若要说抽象,那就是对callbindjavascript 原型继承的理解还不透彻。很多类库都有实现Deferred这个东西,只是大家都约定俗成用Deferred这个名字,例如jqueryangular,而且各类库的实现方式未必相同。

DeferredPromise并不是处于竞争的关系,而是Deferred内涵了Promise,也就包装了一下。

【个人观点】:就像Promise也是包装了一下resovle之后的具体值,附加一些api方法,避开了繁杂的回调,提升了开发体验和阅读代码体验。这让我想起了java 8中的Optional,它也是对一个对象的包装,提供一些api方法,避免了繁杂的非空判断,提升了开发体验和代码的优雅性。

【参考】:

  1. JavaScript Promise迷你书;
  2. ECMAScript 6 入门

你可能感兴趣的:(javascript 中的 Promise)