来看看Promise到底是什么以及怎么用


要问最熟悉的陌生人是什么,我觉得Promise一定可以算一个吧。Promise到底是啥?总能听到这个词,好像是用来处理异步操作的一种东西,之前还做过相关的demo,但一用的时候,就完全给忘了。。。脑壳痛,在此就再回顾一下吧。希望写完这篇博客,我能够常常记起它到底是啥以及如何使用。

先来说说异步操作

记得第一次写异步操作,我就感觉这代码写起来很别扭,总感觉异步操作这种东西是不可控的。

例如,网页加载后,我发起一个Ajax来请求某些数据,数据来了,我再继续某些操作,然后再请求数据,数据来了,我再继续某些操作,然后再请求数据,数据来了,我再继续某些操作,然后再请求数据……终于,所有数据齐活了。

预期代码大致看起来如下:

function 异步函数a(){
    异步函数b(){
        异步函数c(){
            异步函数d(){
                函数()
            };
        }
    };
}

这样一看,代码不断缩进,呈现出了一个三角形(回调地狱)。

为了避免这种情况的发生,我记得我是这样实现的:请求的时候,设置两个定时器,一个setTimeout用于测试请求是否超时,另一个setInterval用于不断地轮询数据有木有拿到。数据拿到以后就清除定时器;但倘若没拿到,就清除定时器,同时报错提示“超时”(尴尬的是,即使超时,请求依然没停止,最后万一成功了还是会拿到数据)

这样一波操作搞得我很头疼:

  1. 因为我要设置很多个定时器,有时定时器忘了清理内存可能就泄露了
  2. 定时器事件不一定准确
    现在一想当前的做法很傻。。。

不过现在可好,据说Promise的出现,解决了这个问题。

Promise 的最简代码

var p1 = new Promise((resolve, reject) => {
    setTimeout(() => {
        var anyValue = Math.random()
        if (anyValue > 0.5) {
            resolve('第一次解决')
        } else {
            reject('第一次拒绝')
        }
    })
}).then((arg)=>{
    console.log(arg)
}).catch((err)=>{
    console.error(err)
}).finally(function(){
    console.log('Promise已完成')
})

p1是Promise的一个实例,Promise的构造函数接受一个函数作为参数,该函数中可以包含异步操作。(例如这里的异步操作是一个定时器,其中取一个随机值,>0.5时执行resolve函数,否则执行reject函数。)

resolve是一个函数,用于处理异步成功的情况,异步成功后,进入下方then流程;
then函数接受一个回调函数,其参数由上方的resolve传入(例如这里参数是'第一次解决')。

reject是一个函数,用于处理异步失败的情况,异步失败后,进入下方catch流程;
catch函数接受一个回调函数,其参数由上方的reject传入。(例如这里参数是'第一次拒绝')。

在Promise流程结束后,无论是否成功,都将会执行最终的finally函数。

来看看Promise链式调用

Promise能够实现链式调用,这让我们在编写异步代码的时候能够写出可读性更强的代码(写起来比较爽),而不是像以前那样编写出“回调地狱”。

这里指的链式调用,就是Promise流程开始后,能够通过如下的方式来执行代码:

new Promise()
.then()
.then()
...
.then()
.catch()
.finally()

可以看出,其中的.then()流程是被不断调用的,而.then()这个方法存在于Promise的实例上。因此要实现链式调用,必然在中间流程的.then()函数中需要返回新的Promise实例。

我们接上一步骤中的代码,在中间插入一个.then()流程(“第三次拒绝”),该流程中会执行reject()函数。

var p1 = new Promise((resolve, reject) => {
    setTimeout(() => {
        var anyValue = Math.random();
        if (anyValue > 0.5) {
            resolve('第一次解决');
        } else {
            reject('第一次拒绝')
        }
    })
}).then((arg) => {
    console.log(arg)
    return new Promise((resolve, reject) => {
        setTimeout(() => resolve('第二次解决'), 2000)
    })
}).then((arg) => {
    console.log(arg)
    return new Promise((resolve, reject) => {
        setTimeout(() => reject('第三次拒绝'), 2000)
    })
}).then((arg) => {
    console.log(arg);
}).catch((err)=>{
    console.error(err)
}).finally(function(){
    console.log('Promise已完成')
})

Promise与其它操作的联用

XMLHttpRequest

XMLHttpRequest,简称XHR,俗称Ajax。我们来尝试一下XHR在Promise中的使用。

let xhr = new XMLHttpRequest();
xhr.open('GET', 'https://api.github.com/users/gogoend', true); // 第三个参数,true为异步,false为同步
xhr.send()
xhr.addEventListener('readystatechange', function (e) {
    if (xhr.readyState == 4 && xhr.status == 200) {
        resolve(JSON.parse(xhr.responseText)); // 执行resolve
    }
})

XHR的使用方式就是创建一个XMLHttpRequest对象实例,设置请求目标与请求方式,然后将请求发送到服务器,之后对readystatechange事件进行监听,当xhr.readyState为4(请求完成)且xhr.status(返回状态码)为200的时候,即表示请求已完成,此时即可调用Promise对象中的resolve方法。

以下是完整代码(可在Console中粘贴运行):

var p1 = new Promise((resolve, reject) => {
    setTimeout(() => {
        var anyValue = Math.random();
        if (anyValue > 0.5) {
            resolve('第一次解决');
        } else {
            reject('第一次拒绝')
        }
    })
}).then((arg) => {
    console.log(arg)
    return new Promise((resolve, reject) => {
        setTimeout(() => resolve('第二次解决'), 2000)
    })
}).then((arg) => {
    console.log(arg);
    return new Promise((resolve, reject) => {
        let xhr = new XMLHttpRequest();
        xhr.open('GET', 'https://api.github.com/users/gogoend', true);
        xhr.send()
        xhr.addEventListener('readystatechange', function (e) {
            if (xhr.readyState == 4 && xhr.status == 200) {
                resolve(JSON.parse(xhr.responseText));
            }
        })
    })
}).then((json) => {
    console.log(json);
    return new Promise((resolve, reject) => {
        // 此处直接reject
        setTimeout(() => reject('第三次拒绝'), 4000)
    })
}).catch((err) => {
    console.error(err)
}).finally(function () {
    console.log('Promise已完成')
})

Fetch

Fetch是一个和XMLHttpRequest类似的接口,较新版本浏览器支持该接口,可用于向后端请求数据。

fetch('https://api.github.com/users/gogoend')
.then((res) => {
    console.log(res)
    return res.json()
})
.then((json) => {
    console.log(json);
})

Fetch的最简单调用方式是直接传入一个URL作为参数,这样将默认使用GET方法从这一URL获得相关数据。执行后,可返回一个Promise对象;我们通过.then(res)从中取到包含回应值的Response对象(回调中的参数res);在该对象中,可以通过res.json()得到返回的具体内容,但执行res.json()后得到的仍然是一个Promise对象,最终我们要通过.then(json)从中取到最终的内容(回调中的参数json)。

以下是加入了Fetch的完整代码(可在Console中粘贴运行):

var p1 = new Promise((resolve, reject) => {
    setTimeout(() => {
        var anyValue = Math.random();
        if (anyValue > 0.5) {
            resolve('第一次解决');
        } else {
            reject('第一次拒绝')
        }
    })
}).then((arg) => {
    console.log(arg)
    return new Promise((resolve, reject) => {
        setTimeout(() => resolve('第二次解决'), 2000)
    })
}).then((arg) => {
    console.log(arg);
    return fetch('https://api.github.com/users/gogoend')
}).then((res) => {
    console.log(res)
    return res.json()
}).then((json) => {
    console.log(json);
    return new Promise((resolve, reject) => {
        // 此处直接reject
        setTimeout(() => reject('第三次拒绝'), 4000)
    })
}).catch((err) => {
    console.error(err)
}).finally(function () {
    console.log('Promise已完成')
})

封装Axios

Axios是一个封装好的、用于向后端请求数据的库(类似jQuery.ajax),但我们可以进行更深层次的封装,来简化请求接口的代码。

function GET(url, params) {
    return new Promise((resolve, reject) => {
        axios.get(url, {
            params: params
        }).then(res => {
            resolve(res.data);
        }).catch(err => {
            reject(err.data)
        })
    });
}
function POST(url, params) {
    return new Promise((resolve, reject) => {
        axios.post(url, Qs.stringify(params))  // Qs.stringify 用于将对象转换为URL字符串,来自Qs库
            .then(res => {
                resolve(res.data);
            })
            .catch(err => {
                reject(err.data)
            })
    });
}

异步请求操作几种写法

(下列与Ajax相关的代码使用jQuery.ajax()来编写)
以往我们执行一个与服务器之间异步操作,例如请求数据,我们期望的代码类似是这样的:

function 异步请求函数a() {
    let res = $.ajax("https://api.github.com/users/gogoend"); //异步流程
    return res.responseJSON;
}
function 异步请求函数b() {
    let res = $.ajax("https://api.github.com/users/gogoend/repos"); //异步流程
    return res.responseJSON;
}
function 异步请求函数c() {
    let res = $.ajax("https://api.github.com/repos/gogoend/anikyu"); //异步流程
    return res.responseJSON;
}
function 处理数据(a, b, c) {
    console.log([a, b, c]);
}
function 请求并处理数据() {
    let res1 = 异步请求函数a();
    let res2 = 异步请求函数b();
    let res3 = 异步请求函数c();
    处理数据(res1, res2, res3);
}
请求并处理数据()

但最终我们发现函数的执行并不能达到我们所预料的效果。

  1. 三个异步函数我们并不知道哪一个会首先执行完成,函数的顺序并非我们函数中所写的顺序,例如res3数据量少则最先完成,但res1、res2数据量较大,此时可能还在请求中。
  2. 请求并处理数据()函数实质上是一个同步函数,在调用了前三个异步函数后,不管这些异步函数是否已经执行完成,将立即执行最下方的处理数据()函数,函数的执行过程不会暂停;此时res1、res2、res3尚未执行完成,值为undefined,而处理数据()函数的执行依赖于这三个值。

那我们来用回调函数的方法试试?

function 异步请求函数() {
    $.ajax({
        url: 'https://api.github.com/users/gogoend',
        success(res1) {
            $.ajax({
                url: 'https://api.github.com/users/gogoend/repos',
                success(res2) {
                    $.ajax({
                        url: 'https://api.github.com/repos/gogoend/anikyu',
                        success(res3) {
                            处理数据(res1,res2,res3)
                        }
                    })
                }
            })
        }
    })
}
function 处理数据(a, b, c) {
    console.log([a, b, c]);
}
异步请求函数()

函数终于似乎能够正常运行。但,WTF? 回调地狱就出现了;那如果此时来试着来将回调函数进行封装,形如下列代码:

function 处理数据(a, b, c) {
    console.log([a, b, c]);
}

let reses=[] // 存储所有返回的数据
function callback1(res1){
    reses.push(res1)
    $.ajax({
      url:"https://api.github.com/users/gogoend/repos",
      success:callback2
    })
}
function callback2(res2){
    reses.push(res2)
    $.ajax({
      url:"https://api.github.com/repos/gogoend/anikyu",
      success:callback3
    })
}
function callback3(res3){
    reses.push(res3)
    处理数据(...reses)
}

function 异步请求函数() {
    $.ajax({
      url:"https://api.github.com/users/gogoend",
      success:callback1
    })
}

异步请求函数()

通过这样稍微封装了以后,回调地狱问题似乎解决了,但新的问题来了 —— 函数的执行从一个函数跳到另一个函数,上窜下跳,最终的处理函数没办法写在最开始的函数里,函数可读性似乎变差了。

那我们来试试刚刚学到的Promise?

function 处理数据(a, b, c) {
    console.log([a, b, c]);
}

let reses = [] // 存储所有返回的数据
var promiseInstance = new Promise((resolve, reject) => {
    $.ajax({
      url:"https://api.github.com/users/gogoend",
      success(res){
        resolve(res)
      }
    })
}).then((arg) => {
    reses.push(arg)
    return new Promise((resolve, reject) => {
        $.ajax({
          url:"https://api.github.com/users/gogoend/repos",
          success(res){
            resolve(res)
          }
        })
    })
}).then((arg) => {
    reses.push(arg)
    return new Promise((resolve, reject) => {
        $.ajax({
          url:"https://api.github.com/repos/gogoend/anikyu",
          success(res){
            resolve(res)
          }
        })
    })
}).then((arg) => {
    reses.push(arg)
    处理数据(...reses)
})

顿时条理就变得清晰了很多,但感觉.then()链式调用次数过多,这样的写法似乎还是没有满足我们一开始时对于函数可读性的需求,那有没有什么方式可以满足这个需求?

借个楼:Async、Await

Async和Await或许可以满足你的愿望~

Promise实例的组合

打开浏览器Console,展开Promise类,可以看到这里包含有该类的相关定义:
image
目前,我们已经接触、使用到了Promise类方法中的resolve、reject以及其实例方法中的then、catch、finally,此处Promise类上我们还有以下三个类方法上文未提及:.all().allSettled().race()
根据MDN上相关资料来看,这三个类方法用于将一些Promise进行组合,从而根据组合结果进行一些操作。它们接受一个数组(或许还可以是某种可迭代对象)作为参数,数组内容是Promise的实例,三个方法都返回一个Promise实例(下文会被称为”大Promise实例“),用于存储其所维护的这些Promise实例的执行结果。

以下来对这些方法进行一些了解。

// 此处示例共用下列变量;
// 由于Promise实例化后会立即执行,因此此处用箭头函数进行了嵌套
let p1 = () => new Promise((resolve, reject) => {
    setTimeout(() => {
        var anyValue = Math.random();
        if (anyValue > 0.5) {
            resolve('第一次解决');
        } else {
            reject('第一次拒绝')
        }
    }, 1000)
});
let p2 = () => fetch('https://api.github.com/users/gogoend');
let p3 = () => new Promise((resolve, reject) => {
    let xhr = new XMLHttpRequest();
    xhr.open('GET', 'https://api.github.com/users/gogoend/repos', true);
    xhr.send()
    xhr.addEventListener('readystatechange', function (e) {
        if (xhr.readyState == 4 && xhr.status == 200) {
            resolve(JSON.parse(xhr.responseText));
        }
    })
})

.all()

当该函数维护的所有Promise实例都resolve或reject后,将会进入其返回的大Promise实例中的then或catch流程。
示例代码:

Promise.all([p1(),p2(),p3()]).then((result)=>{
    console.log(result)
}).catch((err)=>{
    console.log(err)
})

执行结果

若进入then 流程:
image
由图中可见,此次执行走了then流程,以数组的形式输出了三个Promise实例所执行的结果。

若进入catch 流程:
image
根据测试,若这三个Promise任意一个实例执行了reject,大Promise实例将直接进入到catch流程,仅输出错误信息。

.race()

race,也就是赛跑的意思,谁跑得快,谁就赢了。也就是说:
当该函数维护的某一个Promise实例首先被resolve或reject后,将会进入大Promise实例中的then或catch流程。最终仅输出首先被执行完的Promise实例的值。

示例代码

Promise.race([p1(),p2(),p3()]).then((result)=>{
    console.log(result)
}).catch((err)=>{
    console.log(err)
})

执行结果

若进入then 流程:
image

若进入catch 流程:
image

实际上这里开启了浏览器开发者工具中的网速限制,因此,网速很慢;所以此处定时器先执行完成。

.allSettled()

其实在我们调用.all()以及.race()的时候,会发现个问题 —— 它们返回的各种结果都存在一些限制:

  1. .race()仅能够返回最先执行完的实例的值(好像也没什么不对,人家设计出来就是这样的)
  2. .all()中的所有实例,如果全都一帆风顺执行了resolve,都挺好,我们将可以拿到所有我们想要的值;但是,若有任何一个实例执行了reject,那么所有执行resolve的实例所得到的结果都将会被放弃,最终仅剩下在catch流程中抛出的原因。

.allSettled()似乎照单全收,不管该函数所维护的Promise执行的是resolve或是reject,最终都会将所有结果全部保留。

所以,貌似不管.allSettled()所维护的实例发生了什么,最后通通走大Promise实例的then流程?

示例代码

Promise.allSettled([p1(),p2(),p3()]).then((result)=>{
    console.log(result)
}).catch((err)=>{
    console.log(err)
})

执行结果

image
返回结果是一个数组,数组中包含所有的执行结果,使用对象进行包裹。

被resolve的Promise实例,status值为"fulfilled",value中包含resolve的结果。

被reject的Promise实例,status值为"rejected",reason中包含被reject的原因。


小提示

  1. 这三个函数维护的Promise每一个实例中应当执行一次resolve或reject

刚刚在使用.allSettled()的时候,由于没进行某些神操作,导致了GitHub的API无法访问。那此时应该进入大Promise实例的then流程(按理来说发生错误应该走catch流程,但貌似.allSettled()只会走then流程)。然而等了半天,似乎没进入这个流程。
image

后来一看,发现之前所定义的 p3 中(使用XMLHttpRequest的Promise实例)仅仅包含了调用resolve的语句,而不含reject语句。因此,大Promise实例一直在等待p3被resolve或被reject。但由于p3没调用reject,网络又出错了而不可能调用resolve,因此流程停在了此处。
对p3稍加更改:

let p3 = () => new Promise((resolve, reject) => {
    let xhr = new XMLHttpRequest();
    xhr.open('GET', 'https://api.github.com/users/gogoend/repos', true);
    xhr.send()
    xhr.addEventListener('readystatechange', function (e) {
        if (xhr.readyState == 4 && xhr.status == 200) {
            resolve(JSON.parse(xhr.responseText));
        } else {
            // 加入 else 来处理网络错误
            reject('网络错误')
        }
    })
})

再一次执行,可以看到它就走下一个流程了。
image

再次经过测试,.all()以及.race()也有这个问题。

因此,在使用这些方法来维护Promise实例的时候(甚至在编写单个Promise实例的时候),里面的实例应当适时执行resolve或者reject,否则接下来大Promise实例后的 then / catch 流程将不能继续。

  1. 留意一下上图Network面板中相关请求的Waterfall

在之前我们的示例中,所有的异步操作真的很”异步“,形如:

p1->p2->p3->p4->p5->p6

假设这是一些异步请求,每一个异步请求都必须等候前一请求完了才能执行,如果前一请求和后一请求不相互依赖,这就很浪费时间了。此时我们留意Waterfall,可以看到每一个异步请求的结束时间都是下一个异步操作的开始时间。

但使用了这三个方法后(假设这里使用.allSettled()),这些异步请求就可以同时有一些同步,形如:

p1->(p2, p3, p4, p5)->p6

我们再看看Waterfall,可以看到这里的异步请求分了三个批次,p1结束后,p2、p3、p4、p5同时开始(其实浏览器可能对同一时段内允许的请求数量有限制);再然后,p2、p3、p4、p5中结束最晚的一个请求完成后,p6开始。

因此借助这几个方法,我们将能够更方便、有效地对异步操作进行组合。

如何手写Promise?

听说这是个面试题,这里留个坑,待我慢慢研究。

未来开新的issue写吧(毕竟走还不太熟,就要会飞?)

哦了

以上大概就是我对Promise的一些十分随便的理解,这东西吧,个人感觉自己会想不起来用,用上的时候有很尴尬,需要现找资料。总而言之,在此留下个记忆吧。


评论