首页 > 编程笔记

JS Promise用法详解(非常全面)

在介绍 Promise 之前,先了解一下传统的、使用回调函数实现异步的方式。

JavaScript 是事件驱动(Event-Driven)的编程模型,也就是说它会通过监听事件的触发,来执行指定的代码。

在浏览器中可以给 HTML 元素添加事件监听器,当用户使用鼠标单击或触发其他事件时,事件监听中的回调函数就会执行,且事件对象会作为参数传递给回调函数。这个过程是异步的,事件监听不会阻塞线程,从而不影响 HTML 的解析,以及页面元素的响应。

在 Node.js 中,文件的读写都是异步的,例如使用 File API 读取文件时,它接收一个回调函数,当文件读取完成之后,会把数据传递给回调函数。这些事件监听回调和文件读取回调都不会立即执行,而是会一直等待用户的行为和数据读取的进度,当完成之后才执行回调函数,同时在等待的过程中也不会影响其他代码的执行。

不过使用回调函数编写的代码非常不容易阅读,如果有多个嵌套的回调,在视觉上就会形成回调地狱(Callback Hell),例如下方的伪代码:
Database.connect(config,(err)=>{
    if(error){ /*处理错误*/ }
    const db=Database.db(dbName);
    db.insert(data,(err,result)=>{
        if(error){/*处理错误*/}
        db.find({},(err,data)=>{
            if(error){/*处理错误*/}
            data.forEach(item=>{
                item.collection.map(col=>{
                    const[id,...rest]=col;
                    return rest;
                })
            })
        })
    })
})
可以看到上方代码非常难以阅读,回调函数嵌套了很多层级,并且有很多缩进。另外在处理异常情况时,error 对象中的错误信息也需要在每个回调中进行处理。

在 ES6 中出现的 Promise 对象解决了这个问题,使用它可以编写更清晰易读的异步代码。Promise 对象改变了回调的传递方式,改为使用平行的方式来处理回调,因而不会形成回调地狱。

这一点需要特别注意,Promise 只是单纯地改变了现有异步操作的处理方式,并不是创建了一个新的异步操作。

JavaScript 自身不能像 Java 等语言一样开启新的线程,以便异步执行里边的代码,这是因为 JavaScript 是单线程的,需要使用 JavaScript 运行环境所提供的、已编写好的异步 API 实现异步操作,例如发送网络请求的 fetch() 方法、Node.js 中文件的读取、数据库的操作等。使用 Promise 不能实现这种异步操作,只是对异步操作进行了包装。

那么 Promise 到底是什么呢?

JavaScript 中的大部分异步操作使用回调函数来处理异步结果,可以从上边的例子中看出来,回调函数的形式难以阅读,并且难以集中处理错误,所以 Promise 就对这种回调函数的形式进行了改良,支持链式调用,让代码从嵌套关系变为平行关系,并提供了统一处理错误的机制。

Promise 本身代表着一项异步操作的执行状态,这个操作随时可能会完成或出错停止,需要通过 Promise 提供的 API 来处理完成后或出错后的处理逻辑。

创建 Promise

一般地,在开发过程中绝大多数情况下会调用已经封装好的异步操作的 Promise 版本,例如使用 fetch() 加载远程服务器数据,在很少的情况下需要自己创建 Promise,除非现有的异步操作只支持 callback 形式,此时需要把它转换为 Promise 形式。

不过,通过自行创建 Promise 可以了解它的底层是怎么运作的。

setTimeout() 函数用于把某个函数延迟若干毫秒后执行,这里利用它模拟耗时的操作,在等待 1s 之后返回数据 5,并使用 Promise 包装这个操作,代码如下:
const p=new Promise((resolve)=>{
    setTimeout(()=>{
        resolve(5);
    },1000);
});
上述代码使用 Promise 构造函数创建了一个 Promise 对象,构造函数接收1个回调函数作为参数,这个回调函数又被称为执行器(Executor),它接收两个参数,分别为 resolve() 和 reject() 函数,示例中只用到了 resolve(),稍后再介绍 reject() 的用法。

在介绍 resolve() 的含义之前,先了解一下 Promise 的 3 种状态。
使用 new 创建 Promise 对象之后,执行器中的代码会立即执行,此时 Promise 为 pending 状态;当调用 resolve() 函数之后,会把 Promise 的 pending 状态改为 fulfilled 状态;类似地,reject() 函数会把它从 pending 改为 rejected 状态。

fulfilled 和 rejected 状态统称为settled,可以认为是完成状态(无论是成功还是失败)。

示例中执行器的代码使用了 setTimeout() 把 resolve() 的调用延后了 1s,所以此 Promise 的执行时长大约为 1s。

resolve()函数接收1个参数,用于表示 Promise 的返回值,这样在调用 resolve() 并返回执行结果之后,就可以在后边获取这个结果并执行一些其他操作了。resolve() 可以接收任何类型的值,包括另一个 Promise 对象。

获取返回结果可以使用 Promise 对象暴露出来的 then() 方法,它接收一个回调函数,回调函数的参数即为 Promise 返回的结果。

例如获得 Promise 返回的 5 并打印到命令行,代码如下:
p.then((value)=>console.log(value));  //在1s后打印出结果5.
传给 then() 的回调函数中的 value 即为 Promise 对象中 resolve() 参数的值:5。如果不需要在 then() 的回调中使用返回值,则可以省略参数。

上述的代码也可以直接跟在 Promise 对象后边,代码如下:
new Promise((resolve)=>{/*...*/}).then((value=>{/*...*/}))

在这个例子中,setTimeout() 才是真正的异步操作,它是浏览器或 Node.js 运行环境提供给开发者使用的,而这里的 Promise 只是对 setTimeout() 进行了包装,这样可以用 Promise 的方式执行 setTimeout()。

其他的异步操作,例如旧版的 fetch():XMLHttpRequest,以及 Node.js 中的大部分 API,都可以用这种方式封装为 Promise 版本。

基于 Promise 的这个特点,Promise 执行器中的代码是同步执行的,如果在执行器中编写了同步代码,例如使用超大数字的 for 循环,它同样会阻塞(Block)代码的执行,代码如下:
const p=new Promise((resolve)=>{
    console.log("in promise...");
    for(let i=0;i<10000000000;i++){}
    resolve();
})
console.log("start");
代码输出结果如下:

in promise...
start

可以看到在 for 循环结束之前,最外层的 console.log("start") 不会被执行。

链式调用

如果异步代码需要分多步才能完成任务,且每个任务都互相依赖,则使用普通回调函数的形式需要嵌套多层,而使用 Promise 的链式调用方式可以把嵌套的回调函数改成平行关系。

传递给 Promise 的 then() 方法的回调函数会返回一个全新的 Promise,可以在它的基础上继续调用 Promise 中的方法。如果 then() 中的回调函数有 return 语句,则它的返回值就会作为新的 Promise 执行器中的 resolve() 参数的值,后边可以继续使用 then() 获取这个值并执行其他的操作。

后面的 then() 在获取之前 then() 的返回值时有3种情况:
这里需要提一下,Promise 除了这3种状态之外,还有两种执行结果:已完成(Resolved)和未完成(Unresolved)。
来看一个链式调用的例子,代码如下:
new Promise((resolve)=>{
    setTimeout(()=>{
        resolve(5);
    },1000);
})
    .then((value)=>{  //第1个then
        console.log(value);
        return 10;
    })
    .then((value)=>{  //第2个then
        console.log(value);
        return new Promise((resolve)=>{
            setTimeout(()=>{
                resolve(15);
            },3*1000);
        });
    })
    .then((value)=>{  //第3个then
        console.log(value);
    })
    .then(()=>{  //第4个then
        console.log("done");
    });
代码中首先创建了一个 Promise,1s 后返回 5,之后分别使用了 4 个 then() 进行链式调用:
这样在输出结果时,首先等待 1s 打印出 5,并紧接着打印出 10,再过 3s 打印出 15 并紧接着打印出"done",到这里全部的 Promise 就完成了。

这里需要注意的是,链式调用的每个 then() 返回的都是全新的 Promise 对象,并不是最开始的 Promise。

这个示例可以看到使用 then() 链式调用的操作跟嵌套多个回调函数的操作是一样的,只是形式上有很大区别,这里的 then() 的调用是平行的,且通过返回值的形式把值传递给下一个 then(),这种流程就清晰了很多。

再看一个比较实际的例子,假设某个应用需要请求远程服务器上的博客列表 JSON 数据,地址为"/api/posts",这时可以使用浏览器内置的 fetch() 方法,它接收一个 URL 作为参数,在请求结束后返回一个 Promise 对象,用于获取请求返回的响应数据,代码如下:
fetch("/api/posts")
    .then((res)=>res.json())
    .then((posts)=>{
        console.log(posts);
    });
代码首先使用了 fetch() 发送请求,当请求返回时(时间不确定),第1个 then() 中的回调函数会获得响应数据,此时它是一个响应对象,需要调用它的 json() 方法才能把原始数据转换为 JavaScript 对象,res.json() 会返回一个新的 Promise,当它完成时,会执行第2个 then(),打印出解析后的文章列表对象,这时整个任务就执行完成了。

注意这个/api/posts并非真实的地址,如果想成功运行代码则可以把地址改为公开的 JSON API 示例:

https://jsonplaceholder.typicode.com/posts

此代码只能在浏览器中执行,因为 fetch() 是浏览器内置的 API。Node.js 下可以安装 isomorphic-fetch 库支持fetch API。

处理异常

上边的例子都没有处理异常情况,本节来看一下当 Promise 中的代码抛出异常时,该怎么处理。

如果使用的是 Promise 构造函数创建的自定义 Promise 对象,则首先有可能在执行器中抛出异常,例如下方示例,为了演示,在 setTimeout 中有意编写了会抛出异常的代码,代码如下:
new Promise((resolve)=>{
    setTimeout(()=>{
        new Array(NaN);
        resolve(5);
    },1000);
}).then((value)=>{
    console.log(value);
});
给 Array 构造函数传递 NaN 会抛出 RangeError 异常,因为它不是有效的数字,不能作为数组的长度。这种在 setTimeout() 内部出现的异常是无法在 Promise 外边使用 try...catch 进行捕获的,只能在 setTimeout() 内部进行捕获。在有异常抛出之后,resolve() 方法就得不到执行了,进而后边的 then() 也无法执行,但是正常的逻辑应该是在 Promise 抛出异常后,能够在后边的 then() 中去处理。

为了达到这个目的,可以使用执行器的第2个参数,即 reject() 方法,它可以把 Promise 的状态改为 rejected,提示 Promise 运行失败,并通过 reject() 的参数传递自定义的失败原因,例如 error 对象或者错误提示字符串。这时可以在 setTimeout() 中捕获异常并在 catch 语句块中调用 reject() 函数。

例如,在上方示例的执行器函数中加上 reject 参数,并把 setTimeout() 中的代码改为下例所示,代码如下:
try{
    new Array(NaN);
    resolve(5);
}catch{
    reject("指定数组长度时必须是有效数字");
}
这时再运行代码会提示:未处理的Promise异常:指定数组长度时必须是有效数字,未处理的异常是因为此 Promise 的异常还没进行捕获并处理。

这时可以使用 then() 中回调函数的第2个参数处理错误,值为 reject() 中所定义的错误原因,代码如下:
.then(
    (value)=>{
        console.log(value);
    },
    (error)=>{
        console.log(error);
    }
);
当Promise出现异常时,then()就会执行第2个回调函数,这里打印出了之前传递的原因:"指定数组长度时必须是有效数字",而且控制台也不提示未处理的Promise异常了。

不过,这样使用 then() 中第2个回调函数处理错误的形式会使代码变得不易阅读,所以 Promise 提供了 catch() 方法专门用于处理异常,它接收1个回调函数作为参数,回调函数的结构与 then() 的第2个参数一样,相当于 then(null ,errorHandler)。

由于 new Promise() 和 then() 等返回的都是 Promise 对象,所以都可以调用 catch()。

例如使用 catch() 捕获异常,代码如下:
.then(
    (value)=>{
        console.log(value);
    }
)
.catch((error)=>{
    console.log(error);
});
代码的输出结果与使用 then() 处理异常的结果一样。

这里应该注意到 catch() 放到了 then() 的后边,但是 then() 中的代码没有执行,反而 catch() 先执行了,这也是使用 catch() 的另一个好处,如果 Promise 中抛出了异常,则这个异常会传播(Propagate)到离它最近的一个 catch() 中,中间所有的 then() 都不会执行,而当 catch() 捕获异常之后,它返回的又是一个全新的 Promise,后续又可以使用 then() 处理 catch() 中的返回值。

例如上方示例把 catch() 和 then() 的顺序换个位置,并在 catch() 中返回 10,最后 then() 就能打印出 10 了,代码如下:
.catch((error)=>{
    console.log(error);
    return 10;
})
.then((value)=>{
    console.log(value);
});
输出结果如下:

指定数组长度时必须是有效数字
10

可以看到后边 then() 中回调函数的参数是 catch() 的返回值。

基于这个特性,可以在 Promise 的调用链中间使用 catch() 处理特殊的错误,并在最后使用一个 catch() 统一处理其他错误,例如使用 fetch() 加载远程服务器数据时,有可能出现网络错误、请求错误(404、500)等,所以可以根据情况处理这些错误,对于其他错误在最后的 catch() 中统一处理,代码如下:
fetch("https://jsonplaceholder.typicode.com/posts")
    .then((res)=>{
        const status=res.status;
        if(status>=400){
            throw status;
        }
        return res.json();
    })
    .catch((error)=>{
        if(error===404){
            console.log("未请求到数据");
            return[];
        }
        throw error;
    })
    .then((posts)=>{
        console.log(posts);
    })
    .catch((error)=>{
        console.log(error);
    });
代码中使用 fetch() 请求博客列表数据,当返回响应对象时,这里首先通过它的 status 属性获取 HTTP 响应码,如果是大于 400 的响应码,则直接把它们作为异常抛出,并针对 404 这种异常进行特殊处理。

在第1个 catch() 中先判断异常是不是 404,如果是则打印"未请求到数据",并返回空的博客列表数组,如果是其他情况,则再次把异常抛出。之后在下一个 then() 中,打印出博客列表数组,这里如果请求成功则会打印出有数据的数组。如果是 404 状态,则会打印出空数组,其他异常情况则会跳过这个 then() 而运行到最后一个 catch() 中,它简单地打印了 error 参数的值。

该代码如果正常执行会打印出博客列表数组[{...}, {...}, {...}],如果把 const status=res.status 改为 const status=404 测试一下,则它会打印出如下结果:

未请求到数据
[]

这是因为第1个 then() 抛出了 404 异常,第1个 catch() 捕获住了该异常并返回了空的数组,第2个 then() 打印出了空数组的值,最后一个 catch() 由于没有异常所以没有执行。

如果改为 500,则会打印出 500,因为第1个 then() 中抛出了 500,而第1个 catch() 中又继续把 500 抛出,传播到了最后的 catch() 中并打印了出来,中间第2个 then() 不会执行。

利用 Promise 异常传播特性和 catch() 方法,可以有针对性地处理单个异常或者多个异常,无论是从哪里抛出来的,而使用传统回调函数的方式只能在每一层分别处理异常。

最后,Promise 对象中还有 finally() 方法,如同 try...catch...finally 中的 finally,可以放在最后执行一些清理和收尾操作,finally() 只要 Promise 的状态为 settled,即无论是 fulfilled 还是 rejected 都会执行,一般放到调用链的最后边。

执行多个Promise

如果有多个 Promise 需要同时执行,例如同时发起多个网络请求、执行多个动画、批量数据库操作等,则根据所要求的返回结果的不同,Promise 提供了4种方式执行多个 Promise,分别是:
接下来分别看一下它们的作用和区别。

1) Promise.all()

接收一个可迭代的对象(例如数组)作为参数,每个元素为要执行的 Promise。

Promise.all() 会返回一个新的 Promise,如果参数中所有的 Promise 都变为 fulfilled,这个新的 Promise 就会变为 resolved,它会把所有的结果按元素的顺序放到数组中并返回。参数数组中的元素也可以是普通的 JavaScript 数据类型,这样它的值会原样返回结果数组中。

Promise.all() 用法的代码如下:
const promise1=new Promise(resolve=>setTimeout(resolve,300,1));
const promise2=new Promise(resolve=>setTimeout(resolve,100,2));
const promise3=3;
Promise.all([promise1,promise2,promise3]).then(values=>{console.log(values)});
promise1 会在 300ms 后返回1,这里使用 setTimeout() 中的第3个参数来给 resolve() 函数传递参数;promise2 会在 100ms 后返回 2;promise3 是基本类型数据,会立即返回。

虽然这3个 promise 的执行顺序是 promise3、promise2、promise1,但是因为 Promise.all() 的返回值是按数组中元素的顺序返回的,即 promise1、promise2、promise3,所以上述代码的输出结果为 [1,2,3]。

如果有任意一个 Promise 发生错误或状态变为 rejected,则后续的 Promise 会停止执行,Promise.all() 返回的 Promise 会变为 rejected,并且 catch 语句中的错误信息,为第1个出错的 Promise 的原因。

例如把 promise1 改为 rejected:
const promise1=new Promise( (resolve, reject)=>setTimeout(reject, 300, "失败") )
这时如果运行代码会抛出未捕获的 Promise 异常,在 Promise.all() 后边使用 catch() 可以捕获该异常,代码如下:
Promise.all([promise1,promise2,promise3])
    .then((values)=>{console.log(values)})
    .catch((error)=>{console.log(error)});
输出结果为"失败"。

2) Promise.allSettled()

与 Promise.all() 类似,只是无论 Promise 是 fulfilled 还是 rejected(Settled)都会返回结果数组中,fulfilled 会把结果放入数组中,rejected 会把原因放入数组中,且不会影响其他 Promise 的执行。

Promise.allSettled() 适合需要知道每个 Promise 的执行情况的场景,例如把上一小节最后的 Promise.all 改为使用 Promise.allSettled(),代码如下:
Promise.allSettled([promise1,promise2,promise3]).then((values)=>{
    console.log(values);
});
它返回的数组如下:
[
    {status:'rejected',reason:'失败'},
    {status:'fulfilled',value:2},
    {status:'fulfilled',value:3}
]
数组中的每个元素都是一个对象,status 表示 Promise 的最终状态,value 为正常执行的 Promise 的结果,reason 为发生异常的 Promise 的原因。

3) Promise.any()

与 Promise.all() 不同的是,参数数组中的 Promise 只要有1种状态变为 fulfilled,就会把该 Promise 的结果返回。Promise.any() 返回的 Promise 中只有单一的结果。

如果所有的 Promise 的状态都为 rejected,则 Promise.any() 会抛出 AggregateError,代码如下:
const promise1=new Promise(resolve=>setTimeout(resolve,300,1));
const promise2=new Promise(resolve=>setTimeout(resolve,100,2));
const promise3=3;
Promise.any([promise1,promise2,promise3]).then(value=>{console.log(value)});
//3
需要注意,在本文截稿前,只有 Chrome 85 和 Node.js15.0 以上版本支持 Promise.any(),它是 ES2021 发布的新特性。

4) Promise.race()

相当于是 any() 版的 allSettled(),参数数组中的 Promise 只要有1个 Promise 状态变为 fulfilled 或 rejected,就会返回它的结果或异常原因。

5) 顺序执行

如果想执行一系列互相依赖的 Promise,并使用最后一个 Promise 的返回值,一般的写法则会在每个 Promise 后的 then() 中执行下一个 Promise,代码如下:
const promise1=new Promise((resolve)=>setTimeout(resolve,300,3));
const promise2=new Promise((resolve)=>setTimeout(resolve,200,2));
const promise3=new Promise((resolve)=>setTimeout(resolve,400,1));
promise1.then(()=>promise2).then(()=>promise3).then((value)=>{
    console.log(value);//1
});
代码中每个 then() 中的回调函数只是简单地返回了下一个要执行的 Promise,实际的场景可能有其他业务逻辑代码。不过,《JS async和await关键字的用法》一文中介绍的 async/await 关键字可以更直观地实现顺序执行。

推荐阅读