Promise详解

7,203 阅读11分钟

      Promise是我最喜欢的es6语法,也是面试中最容易问到的部分。那么怎么做到在使用中得心应手,在面试中脱颖而出呢?
      先来个面试题做做:

面试题:用Promise封装一下原生ajax

      面试官经常会让手写一个Promise封装,写出下面这一版就行了(想了解更多的可自行扩展):


function ajaxMise(url, method, data, async, timeout) {
    var xhr = new XMLHttpRequest()
    return new Promise(function (resolve, reject) {
        xhr.open(method, url, async);
        xhr.timeout = options.timeout;
        xhr.onloadend = function () {
            if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304)
                resolve(xhr);
            else
                reject({
                    errorType: 'status_error',
                    xhr: xhr
                })
        }
        xhr.send(data);
        //错误处理
        xhr.onabort = function () {
            reject(new Error({
                errorType: 'abort_error',
                xhr: xhr
            }));
        }
        xhr.ontimeout = function () {
            reject({
                errorType: 'timeout_error',
                xhr: xhr
            });
        }
        xhr.onerror = function () {
            reject({
                errorType: 'onerror',
                xhr: xhr
            })
        }
    })
}

Promise简介

      Promise是一个对象,保存着未来将要结束的事件。她有两个特征,引用阮一峰老师的描述就是:

(1)对象的状态不受外界影响。Promise对象代表一个异步操作,有三种状态:pending(进行中)、fulfilled(已成功)和rejected(已失败)。只有异步操作的结果,可以决定当前是哪一种状态,任何其他操作都无法改变这个状态。这也是Promise这个名字的由来,它的英语意思就是“承诺”,表示其他手段无法改变。
(2)一旦状态改变,就不会再变,任何时候都可以得到这个结果。Promise对象的状态改变,只有两种可能:从pending变为fulfilled和从pending变为rejected。只要这两种情况发生,状态就凝固了,不会再变了,会一直保持这个结果,这时就称为 resolved(已定型)。如果改变已经发生了,你再对Promise对象添加回调函数,也会立即得到这个结果。这与事件(Event)完全不同,事件的特点是,如果你错过了它,再去监听,是得不到结果的。

Promise基本用法
let promise1 = new Promise(function (resolve, reject){
    setTimeout(function (){
        resolve('ok') //将这个promise置为成功态(fulfilled),会触发成功的回调
    },1000)
})
promise1.then(fucntion success(val) {
    console.log(val) //一秒之后会打印'ok'
})
最简单代码实现一个Promise
class PromiseM {
    constructor (process) {
        this.status = 'pending'
        this.msg = ''
        process(this.resolve.bind(this), this.reject.bind(this))
        return this
    }
    resolve (val) {
        this.status = 'fulfilled'
        this.msg = val
    }
    reject (err) {
        this.status = 'rejected'
        this.msg = err
    }
    then (fufilled, reject) {
        if(this.status === 'fulfilled') {
            fufilled(this.msg)
        }
        if(this.status === 'rejected') {
            reject(this.msg)
        }
    }

}
//测试代码
var mm=new PromiseM(function(resolve,reject){
    resolve('123');
});
mm.then(function(success){
    console.log(success);
},function(){
    console.log('fail!');
});

Micro-task / event loop

      上面提到Promise和事件的不同,除此之外还有一个重要不同,就是Promise创建是micro-task。再看一道面试题:

面试题:写出下面代码的输出顺序

console.log('script start');

setTimeout(function () {
    console.log('setTimeout');
}, 0);

Promise.resolve().then(function () {
    console.log('promise1');
}).then(function () {
    console.log('promise2');
});

console.log('script end');

      正确答案是:'script start'、'script end'、'promise1'、'promise2'、'setTimeout'。原因就是:

  • setTimeout(或者事件)注册的是一个task,由Event Loop控制
  • Promise注册的是一个micro-task

      Event Loop是js的一个重要机制,就是遇到事件或者setTimeout等就会把对应的回调函数放入一个事件队列(task queue),等到主程序执行完毕就依次把队列里的函数压入栈中执行。可以参考阮一峰老师的JavaScript 运行机制详解:再谈Event Loop,不过貌似老师的网站被攻击还没有恢复。
      但是Promise不是上面的机制,她创建的是一个微任务(micro-task),micro-task的执行总是在当前执行栈结束和下一个task执行之前,顺序就是“当前执行栈” -> “micro-task” -> “task queue中取一个回调” -> “micro-task” -> ... (不断消费task queue) -> “micro-task”,总之就是当前执行栈为空时,就到了一个micro-task的检查点。
      下面是micro-task的定义:

Microtasks are usually scheduled for things that should happen straight after the currently executing script, such as reacting to a batch of actions, or to make something async without taking the penalty of a whole new task.

      Promise注册的是micro-task,所以上面题目中:主线程中'script start'、'script end'先打印,然后清空微任务队列,'promise1'、'promise2'打印,然后取出task queue中的回调执行,'setTimeout'打印。

为什么出现promise

      Promise提供了对js异步编程的新的解决方案,因为我们一直使用的回调函数其实是存在很大问题,只是限制于js的单线程等原因不得不大量书写。当然Promise并不是完全摆脱回调,她只是改变了传递回调的位置。那么传统的回调存在什么问题呢?

嵌套

      这里所说的嵌套是指大量的回调函数会使得代码难以读懂和修改,试想一个这个场景:让你把下面的url4的调用提到url2之前。你需要非常小心的剪切代码,并且笨拙的粘贴,result4这个参数你还不敢修改,因为这要额外花费很多功夫并且存在风险。

$.ajax('url1',function success(result1){
    $.ajax('url2',function success(result2){
        $.ajax('url3',function success(result3){
            $.ajax('url4',function success(result4){
                //……
            })
        })
    })
})

      当然,上面的问题有点戏剧成分,现实中极少出现这种难搞的情况。与此相比,回调函数带来的思维上的难以理解是更致命的,因为我们的大脑更喜欢同步的逻辑,这也是为什么await关键字那么受欢迎的原因。
      我记得有一次我给后端的同学做JS新特性分享的时候,说到await关键字,有个人惊呼:“哇!这个不错啊,这就可以像写java一样写代码了”。

信任

      除去书写的不优雅和维护的困难以外,回调函数其实还存在信任问题。
      事实上回调函数不一定会像你期望的那样被调用。因为控制权不在你的手上。这种问题被称作“控制反转”。例如下面的例子:

$.ajax('xxxxxx',function success(result1){
    //比如成功之后我会操作数据库记录结算金额
})

      上面是jQuery中的ajax调用,我们期望在某些事件结束后,让第三方(jQ)帮我们执行我的程序(回调)。
      那么,我们和第三方之间并没有一个契约或者规范可以遵循,除非你把你想使用的第三方库通读一遍,保证它做了你想做的事,但事实上你很难确定。即使在自己的代码中,或者自己编写的工具,我们都很难做到百分之百信任。

Promise解决方案

      Promise是一个规范,尝试以一种更加友好的方式书写代码。Promise对象接受一个函数作为参数,函数提供两个参数:

  • resolve:将promise从未完成切换到成功状态,也就是上面提到的从pending切换到fufilled,resolve可以传递参数,下一级promise中的成功函数会接收到它
  • reject:将promise从未完成切换到失败状态,即从pending切换到rejected
let promise1 = new Promise(function(reslove, reject){
    //reslove或者reject或者出错
})
promise1.then(fufilled, rejected).then().then() //这是伪代码
promise1.then(fufilled, rejected)//可以then多次

function fufilled(data) {
    console.log(data)
}
function rejected(e){
    console.log(e)
}

      正如上面提到的两个特征,一旦状态改变,这个Promise就已经完成决议(不会再更改),并且返回一个新的Promise,可以链式调用。并且可以注册多个then方法,他们同时决议并且互不影响。这种设计明显比回调函数要优雅的多,也更易于理解和维护。那么在信任问题上她又有哪些改善呢?
      Promise通过通知的机制将“控制反转”的关系又“反转”回来。回调是我传递给第三方一个函数,期望它在事件发生时帮我执行,而Promise是在大家都遵循规范的前提下,我会在事件发生时得到通知,这时我决定做一些事(执行一些函数)。看到了吧,这是有本质差异的。
      此外,回调函数还有以下信任问题,Promise也都做了相关约束:

  • 回调调用过早
  • 回调调用过晚(或者没有调用)
  • 调用次数太多
  • 没有把参数成功传递给你的回调
  • 吐掉了错误或者异常
过早或者过晚

      一个Promise回调一定会在当前栈执行完毕和下一个异步时机点上调用,即使像下面这样的同步resolve代码也会异步执行,而你传给工具库的回调函数却可能被同步执行(调用过早)或者被忘记执行(或者过晚)。

new Promise(function (resolve) {
    resolve(111111);
})
次数太多或者没有传递参数

      Promise只能被决议一次,如果你多次决议,她只会执行第一次决议,例如:

new Promise(function (reslove, reject) {
    resolve()
    setTimeout(function () {
        resolve(2)
    },1000)
    resolve(3)
}).then(function (val) {
    console.log(val)   //undefined
})

      成功回调的参数是通过resolve传递的,例如像上面的代码一样,没有传递参数,那么val收到的会是undefined,所以,无论如何都会收到参数。注意:resolve只接收一个参数,之后的参数会被忽略。

吞掉错误

      Promise的错误处理机制是这样的:如果显示的调用reject并传递错误理由,这个消息会传递给拒绝回调。
      此外,如果任意过程中出现错误(例如TypeError或者ReferenceError),这个错误会被捕捉,并且使这个Promise拒绝,也就是说这个错误消息也会传递给拒绝回掉,这与传统的回调是不同的,传统的回调一旦出错会引起同步相应,而不出错则是异步。

promise并发控制

all / race

      allrace两个函数都是并发执行promise的方法,他们的返回值也是promiseall会等所有的promise都决议之后决议,而race是只要有一个决议就会决议。

Promise.all([promise1, promise2, promise3]).then(function(values) {
  console.log(values);
});

注意:如果参数为空,all方法会立刻决议,而race方法会挂住。

面试题:封装一个promise.all方法
Promise.all = function(ary) {
    let num = 0
    let result = []
    return new Promise(function(reslove, reject){
        ary.forEach(promise => {
            promise.then(function(val){
                if(num >= ary.length){
                    reslove(result)
                }else{
                    result.push(val)
                    num++
                }
            },function(e){
                reject(e)
            })
        })
    })
}

thenalbe

如何检测一个对象是Promise?

      你肯能会想到 instanceof Promise,但遗憾的是不可以。原因是每种环境都封装了自己的Promise,而不是使用原生的ES6 Promise
      所以目前判断Promise的一种方法就是判断它是不是thenable对象(如果它是一个对象或者函数,并且它具有then方法)。
      这是一种js常见的类型检测方法——鸭子类型检测:

鸭子类型检测:如果它看起来像鸭子,叫起来也像鸭子,那么它就是鸭子

resolve/reject

      resolve返回一个立即成功的Promisereject返回一个立即失败的Promise,他们是new Promise的语法糖,所以下面两个写法是等价的:

let p1 = new Promise(function(resolve, reject){
    reslove(11111)
})

let p2 = Promise.resolve(11111) //这和上面的写法结果一样

      此外,如果传入reslove方法的参数不是promise而是一个thenable值,那么reslove会将它展开。最终的决议值由then方法来决定。

错误处理

      上面提到,Promise是异步处理错误,也就是说我的错误要在下一个Promise才能捕获到,大多情况这是好的,但是存在一个问题:如果捕获错误的代码再出现错误呢?
      我的做法通常是在代码的最后加catch

let p1 = new Promise(function(reslove, reject){
    ajax('xxxxx')
})

p1
    .then(fullfilled, rejected)
    .then(fullfilled, rejected)
    .catch(function(e){
        //处理错误
    })

结尾

      文章到这里就结束了,如果你看完了并且因此思考了一些东西,我很高兴。
      接下来会继续更新Promise+generator、异步函数等Promise相关知识,愿共同进步。