万字详细总结 Promise(期约)及其方法
ES6之前的异步编程
异步行为是 javascript 的基础操作。我们在开发的过程中往往需要执行一个操作并得到一个结果(例如从后端请求数据),但是由于 javascript 是单线程,所以就用异步任务处理这些耗时的操作,避免 javascript 主线程阻塞(捋一捋 JavaScript 事件循环机制这篇文章详细介绍 javascript 异步的来龙去脉)。
但是怎么拿到异步任务的结果呢? 广泛的策略就是给异步任务提供一个回调函数,当异步任务成功时把结果当做参数传给这个回调函数继续处理,该回调函数称为成功回调。那么任务出错时怎么处理呢? 我们也可以定义一个回调函数,把错误理由作为参数传给它,由它负责后续处理,该回调函数称为失败回调。最后若有多个异步任务,并且后一个异步任务的执行需要前一个异步任务的结果的情况就需要嵌套回调,即把后一个异步任务作为前一个异步任务的成功回调函数。基于以上讨论,我们可以得到如下代码:
function myAsync(value, success, failure) {
setTimeout(() => {
try {
if (/*异步任务失败条件*/) {
throw new Error('error');
}
success(value * 2);
} catch (error) {
failure(error)
}
}, 500);
}
const success = (value) => {
myAsync(value, () => console.log(value), () => console.log(value))
}
const failure = (err) => {
console.log(err)
}
myAsync(1, success, failure);
这里的就形成了异步任务的嵌套,不够直观?没关系,我们看下面的例子:
setTimeout(() => {
// 经过一系列操作拿到结果 value
setTimeout(() => {
// 经过一系列操作拿到结果 value
setTimeout(() => {
// 经过一系列操作拿到结果 value
//······
}, 500)
}, 500)
}, 500);
因为在早期只支持定义回调函数来表明异步操作,所以通常需要深度嵌套的函数来解决串联多个异步操作。这就是大名鼎鼎的回调地狱。当嵌套深度过深的时候就会耗费大量的心智去分析代码,显然不利于后期维护。
Promise 期约
由于回调函数的种种问题,于是在 ES6 中基于 Promise/A+ 规范进行完善和支持,Promise 期约诞生了。用它来代替回调函数处理异步任务。
Promise 基本概念及使用
Promise 实例化
Promise 是一个对象,可以通过 new 操作符实例化,实例化时必须要传入执行器函数(它的作用是初始化期约的异步行为和控制期约状态转换),否则会报错。这里还需要注意的是尽管 Promise 是处理异步任务的,但是执行器函数是同步执行的。
console.log(0);
const p = new Promise(() => {
console.log(1);
})
console.log(2);
可以看到 0、1、2 是顺序打印的,足以说明执行器函数是同步执行的(不明白的话一定要看看这篇文章捋一捋 JavaScript 事件循环机制)。
期约状态机
期约不仅是一个对象,还是一个有状态的对象,它一共有三种状态:
- 待定(pending)
- 解决(fulfilled || resolved)
- 失败(rejected)
期约最初一般为待定(pending)状态,表示异步任务正在执行,还不知道成功与否;若异步任务执行成功,状态将变为解决状态;若异步任务执行失败,状态将变为失败状态。并且状态只能由待定变为解决或失败,状态的改变只由异步任务的成功失败决定,任何其他操作都无法影响其状态。当状态改变后,无论你做什么都不会再次改变,并且你任何时候取值状态都是一样的。这个结论很重要!!!
const p = new Promise(() => {
console.log(1);
})
console.log(p);
这里我打印了 Promise 实例 p,由结果可以看到它的状态为待定(pending)。
切换 Promise 状态
从上面的输出可以看到在实例化 Promise 时只传给它了一个执行器函数,所以我们只能在这下手了。
其实执行器函数可以有两个函数参数,这两个函数参数一般写为resolve
和 reject
,调用resolve
函数会把期约的状态有待定变为解决(resolved),调用reject
函数会把期约的状态由待定变为失败(rejected)。并且Promise状态的改变只能依靠这两个函数,如果未调用任何一个,那么期约的状态将会是待定(pending) 这两个函数都是可选的。
文首说过,我们往往会需要异步任务执行成功后的结果,哪怕失败也要拿到失败理由。但是注意结果和失败理由都是属于 Promise 内部私有的,除非 Promie 扔出来(我是这么理解的),否则我们永远也拿不到。而我们可以将结果作为参数传给resolve
扔出来,将失败理由作为参数传给reject
扔出来。
const p1 = new Promise((resolve) => {
resolve(123);//将状态变为解决,并把结果 123 作为参数扔出来
})
const p2 = new Promise((resolve, reject) => {
reject('error!!!');//将状态变为失败,并把失败理由 error!!! 作为参数扔出来
})
const p3 = new Promise((resolve, reject) => {
resolve(123);
reject('fail');
})
const p4 = new Promise((resolve, reject) => {
reject('fail');
resolve(123);
})
setTimeout(() => {
console.log(p1);
console.log(p2);
console.log(p3);
console.log(p4);
}, 0);
由 p3、p4 的打印结果可以看出状态一旦改变,状态就不会再变了。最后两个报错是因为没有处理异步失败时抛出的错误。
Promise 的静态方法
Promise.resolve
期约初始并不一定非得是待定状态,Promise.resolve()方法可以直接实例化一个解决了的期约。
const p1 = Promise.resolve('success');//等价于下面一行代码
const p2 = new Promise((resolve) => resolve('success'));
console.log(p1);
console.log(p2);
这个函数的参数就是异步任务成功的结果。
const p1 = Promise.resolve(1);
const p2 = Promise.resolve({ name: '孤城浪人' });
const p3 = Promise.resolve(true);
const p4 = Promise.resolve(function () { });
const p5 = Promise.resolve([1, 2, 3]);
const p6 = Promise.resolve(new Error('error!!!'));
const p7 = Promise.resolve(new Promise(() => 1));
const p8 = Promise.resolve(new Promise((resolve) => resolve(1)));
const p9 = Promise.resolve(new Promise((resolve, reject) => reject('error!!!')));
const p10 = Promise.resolve(1, 2, 3);
const p11 = Promise.resolve();
console.log(p1);
···
由上面的示例可以得到如下结论:
-
Promise.resolve() 方法可以将所有非 Promise 值转为一个状态为解决(resolved)的期约,且结果作为参数传入的值,即使参数是一个错误对象(注意这可能会导致不符合预期的行为)。如:p1、p2、p3、p4、p5、p6。
-
当传入的参数不止一个时,会自动忽略从第二个参数开始的所有后续参数,如 p10,输出后结果只有第一个参数 1,忽略了 2、3 。
-
Promise.resolve() 还是幂等的,即当传入的参数本身也是一个期约,本方法会原封不动地返回这个期约。如 p7、p8、p9,可以看到本方法返回的期约就是传入的 Promise 实例。
-
如果不传入参数,那么也会返回一个状态为解决(resolved)的期约,只不过结果为 undefined。如 p11。
Promise.reject
这个方法会返回一个状态为失败(rejected) 的期约并抛出一个异步错误。注意 Promise.reject() 不是幂等的。无论你传入的参数是什么,总是返回一个状态为rejected
的期约。拒绝理由就是传入的第一个参数。
const p1 = Promise.reject(1);
const p2 = Promise.reject({ name: '孤城浪人' });
const p3 = Promise.reject(true);
const p4 = Promise.reject(function () { });
const p5 = Promise.reject([1, 2, 3]);
const p6 = Promise.reject(new Error('error!!!'));
const p7 = Promise.reject(new Promise(() => 1));
const p8 = Promise.reject(new Promise((resolve) => resolve(1)));
const p9 = Promise.reject(new Promise((resolve, reject) => reject('error!!!')));
const p10 = Promise.reject(1, 2, 3);
const p11 = Promise.reject();
console.log(p1);
···
由以上示例可以得到如下结论:
-
如果不传入参数,那么也会返回一个状态为解决(resolve)的期约,只不过拒绝理由为 undefined。如 p11。
-
当传入的参数不止一个时,会自动忽略从第二个参数开始的所有后续参数,如 p10,输出后结果只有第一个参数 1,忽略了 2、3 。
注意:抛出的异步错误不能被try/catch
捕获。因为拒绝期约的错误并没有抛到执行同步代码的线程里,而是通过浏览器异步消息队列来处理的。
try {
throw new Error('error')
} catch (err) {
console.log('捕获了错误' + err);
}
try {
Promise.reject(new Error('promise error'));
} catch (err) {
console.log('捕获了promise错误' + err);
}
try/catch 捕获了第一个代码块抛出的错误,却没有捕获到期约抛出的错误。
Promise.all
Promise.all()
方法用于将一组期约,包装成一个新的期约,新的期约只会在每个包含的期约都解决完后才会解决。
特点:
-
所有的参数期约状态都变为解决(resolved),包装期约状态才为解决(resolved),结果为所有期约成功结果组成的数组。如 p5。
-
若期约状态没有为失败(rejected)的,但有待定( pending) 的,包装期约状态为待定(pending),如 p6。
-
若有期约状态为失败(rejected),那么包装期约状态为失败(rejected)。并且第一个状态变为失败的期约的失败理由将会是新期约失败的理由,如 p7、p8。
-
若
Promise.all
接受的是一个空数组,那么就等价与Promise.resolve()
方法,如 p9。 -
若
Promise.all
接受的不是一组期约,那么就会将每一个元素通过Promise.resolve()
转换为期约。
const p0 = new Promise(() => { });
const p1 = new Promise((resolve) => resolve('amazing'));
const p2 = new Promise((resolve) => resolve('success'));
const p3 = new Promise((resolve, reject) => reject('fail'));
const p4 = new Promise((resolve, reject) => reject('rejected'));
const p5 = Promise.all([p1, p2]);
const p6 = Promise.all([p0, p2]);
const p7 = Promise.all([p0, p2, p3]);
const p8 = Promise.all([p0, p2, p3, p4]);
const p9 = Promise.all([]);
const p10 = Promise.all([1, 2]);
setTimeout(() => {
console.log(p5);// Promise {<fulfilled>: ['amazing','success']}
console.log(p6);// Promise {<pending>}
console.log(p7);// Promise {<rejected>: 'fail'}
console.log(p8);// Promise {<rejected>: 'fail'}
console.log(p9);// Promise {<fulfilled>: []}
console.log(p10);// Promise {<fulfilled>: [1,2]}
}, 0);
Promise.any
本方法接受一组期约作为参数,包装成一个新的期约返回。新的期约只有在所有期约都失败后才会结束。
特点:
-
只要参数期约有一个变成解决(resolved)状态,包装期约就会变成解决(resolved)状态,如 p5;
-
只有所有参数期约都变成失败(rejected)状态,包装期约才会变成失败(rejected)状态,如 p6。
-
若没有期约状态为解决(resolved),但有待定(pending)状态的期约,包装期约状态为待定(pending),如 p7。
-
若
Promise.any
接受的是一个空数组,那么就等价与Promise.reject()
方法,如 p8。 -
若
Promise.any
接受的不是一组期约,那么就会将元素通过Promise.resolve()
转换为期约,如 p9。
const p0 = new Promise(() => { });
const p1 = new Promise((resolve) => resolve('amazing'));
const p2 = new Promise((resolve) => resolve('success'));
const p3 = new Promise((resolve, reject) => reject('fail'));
const p4 = new Promise((resolve, reject) => reject('rejected'));
const p5 = Promise.any([p1, p2]);
const p6 = Promise.any([p0, p2]);
const p7 = Promise.any([p0, p3]);
const p8 = Promise.any([]);
const p9 = Promise.any([1, 2]);
setTimeout(() => {
console.log(p5);// Promise {<fulfilled>: 'amazing'}
console.log(p6);// Promise {<fulfilled>: 'success'}
console.log(p7);// Promise {<pending>}
console.log(p8);// Promise {<rejected>: AggregateError: All promises were rejected}
console.log(p9);// Promise {<fulfilled>: 1}
}, 0);
Promise.race
Promise.race()
方法同样是将多个期约,包装成一个新的期约,是一组集合中最先解决或拒绝的期约的镜像。若已有期约落定,但之后又有期约失败,会静默处理。
特点:
-
本方法无论是解决还是拒绝,只要是第一个落定状态的期约,就会包装其解决值或拒绝理由并返回新期约,新期约状态与第一个落定的期约状态一致。
-
若
Promise.race
接受的是一个空数组,那么就等价与new Promise(()=>{})
方法,如 p8。 -
若
Promise.race
接受的不是一组期约,那么就会将元素通过Promise.resolve()
转换为期约,如 p9。
const p0 = Promise.resolve('p0');
const p1 = Promise.reject('p1');
const p2 = new Promise((resolve, reject) => setTimeout(reject('p2'), 50000));
const p5 = Promise.race([p0, p1]);
const p6 = Promise.race([p1, p0]);
const p7 = Promise.race([p1, p2]);
const p8 = Promise.race([]);
const p9 = Promise.race([1, 2]);
setTimeout(() => {
console.log(p5);// Promise {<fulfilled>: 'p0'}
console.log(p6);// Promise {<rejected>: 'p1'}
console.log(p7);// Promise {<rejected>: 'p1'}
console.log(p8);// Promise {<pending>}
console.log(p9);// Promise {<fulfilled>: 1}
}, 0);
Promise.allSettled
Promise.allSettled()
方法接受一个数组作为参数,数组的每个成员都是一个期约,并返回一个新的期约。只有等到参数数组的所有 Promise 对象都发生状态变更(不管是解决(fulfilled)还是拒绝(rejected)),返回的期约才会发生状态变更。
特点:
-
返回的期约状态只有两种可能:所有期约状态都落定后,返回的期约状态为解决(resolved),若还有任意一个期约状态未落定,返回的期约状态为待定(pending),如 p5、p6。
-
若返回的期约状态为解决(resolved),拿到的结果为数组,每个元素都是一个对象,该对象为期约对应异步操作结果,如p7、p8、p9。
// 拒绝,拒绝理由
{status: 'rejected', reason: 'p1' }
// 解决,结果
{status: 'fulfilled', value: 1}
const p0 = new Promise(() => { });
const p1 = Promise.reject('p1');
const p2 = new Promise((resolve, reject) => setTimeout(reject('p2'), 50000));
const p5 = Promise.allSettled([p0, p1]);
const p6 = Promise.allSettled([p1, p0, p2]);
const p7 = Promise.allSettled([p1, p2]);
const p8 = Promise.allSettled([]);
const p9 = Promise.allSettled([1, 2]);
setTimeout(() => {
console.log(p5);// Promise {<pending>}
console.log(p6);// Promise {<pending>}
console.log(p7);// Promise {<fulfilled>: [{status: 'rejected', reason: 'p1' },{ status:'rejected', reason: 'p2' }]
console.log(p8);// Promise {<fulfilled>: []}
console.log(p9);// Promise {<fulfilled>: [{status: 'fulfilled', value: 1},{ status:'fulfilled', value: 2}]}
}, 0);
Promise 实例方法
每一个 Promise 实例都具有一些方法,它们是连接外部同步代码与内部异步代码之间的桥梁。这些方法可以访问异步操作返回的数据,处理期约成功和失败的输出或添加只有期约状态落定时才执行的代码。
Promise.prototype.then
then
方法是定义在原型对象Promise.prototype
上的,这个方法接受两个参数。第一个参数是期约进入resolved
状态执行的回调函数,第二个参数是期约进入rejected
状态执行的回调函数,它们都是可选的。
由于期约状态只能改变一次,所以这两个参数一定是互斥的。并且then
方法会返回一个新的 Promise 实例。
const p1 = new Promise((resolve) => resolve('success'));
const p2 = new Promise((resolve, reject) => reject('fail'));
const p3 = p1.then((res) => {
console.log(res);//success
})
p2.then(null, (err) => {
console.log(err);//fail
})
setTimeout(() => {
console.log(p3 === p1);//false,证明p2是新实例
}, 0);
p1.then('success');//静默忽略
当期约状态变为解决(resolved)就会执行then
方法的第一个参数函数并将结果传给回调函数。该函数的返回值实际上会使用 Promise.resolve
方法包装成新的期约,如果没有提供这个处理程序,就会用该方法包装上一个期约解决之后的值,如 p6,如果没有返回值,会用该方法包装 undefined,如 p3。很多特性都符合静态方法 Promise.resolve() 的特性,如 p4、p5。
const p1 = new Promise((resolve) => resolve('success'));
const p2 = new Promise((resolve, reject) => reject('fail'));
const p3 = p1.then((res) => { });
const p4 = p1.then((res) => '孤城浪人');
const p5 = p2.then(null, (err) => err + '^-^')
const p6 = p1.then();
const p7 = p1.then(() => new Error('error'));
const p8 = p1.then((res) => new Promise((resolve, reject) => reject('失败啦')));
const p9 = p1.then((res) => { throw Error('又失败啦') });
setTimeout(() => {
console.log(p3);// Promise {<fulfilled>: undefined}
console.log(p4);// Promise {<fulfilled>: '孤城浪人'}
console.log(p5);// Promise {<fulfilled>: 'fail^-^'}
console.log(p6);// Promise {<fulfilled>: 'success'}
console.log(p7);// Promise {<fulfilled>: Error: error at http://127.0.0.1:5500/sc.html:119:30}
console.log(p8);// Promise {<rejected>: '失败啦'}
console.log(p9);// Promise {<rejected>: Error: 又失败啦 at http://127.0.0.1:5500/sc.html:117:42}
}, 0);
如果 Promise 实例状态变为失败(reject)就会执行then
方法的第二个参数函数,并把失败理由传入,经过处理后(不是返回一个失败状态的 Promise 实例,非 p8),返回一个成功状态的 Promise 实例,如 p7。原因很简单,上一个 Promise 失败了,但是我已经成功处理了,当然应该返回一个成功的 Promise 实例了。
若在then
方法方法中抛出一个错误,那么会返回一个状态为失败(rejected)的 Promise 实例。如 p9。
链式调用
const p1 = new Promise((resolve) => resolve('success'));
p1.then((res) => res + ' 孤城浪人',(res) => res)
.then((res) => console.log(res));// success 孤城浪人
当前then
方法的参数函数的参数,是上一个then
执行的参数函数的返回值。如上代码,第一个then
方法会执行第一个参数函数,得到参数 success
,处理后返回 success 孤城浪人
并交给下一个then
函数要执行的参数函数。
Promise.prototype.catch
Promise.prototype.catch()
方法是.then(null, rejection)
或.then(undefined, rejection)
的别名,用于给期约添加拒绝处理程序。只接受一个参数即拒绝处理程序。他返回一个新的 Promise 实例。
const p1 = new Promise((resolve) => resolve('success'));
const p2 = new Promise((resolve, reject) => reject('fail'));
const p3 = new Promise((resolve, reject) => { throw new Error('error') });
let p4 = p1.catch((err) => console.log(err));
p2.catch((err) => console.log(err));
p3.catch((err) => console.log(err));
可以看到 p1 的状态为解决(resolved),所以 p1.catch
并不会执行。那么 p4 是什么呢?输出看看,
它的状态和 p1 一样,再看看下面的代码
const p5 = p1.then(null, (err) => console.log(err));// Promise {<fulfilled>: 'success'}
它们输出一样,其实如果一个状态为解决(resolved)的 Promise 实例调用了.catch
方法,就相当于调用了.then(null,() => {···})
方法,但是成功处理程序未定义,那么就会用Promise.resolve
包装上一个期约,就相当于p1.then()
。
-
如果 Promise 状态已经变成
resolved
,再抛出错误是无效的。如 p4 对应的.catch
未执行。 -
Promise 对象的错误具有“冒泡”性质,会一直向后传递,直到被捕获为止。也就是说,错误总是会被下一个
catch
语句捕获。同时catch
只能捕获在它执行之前抛出的错误。如 p5 未能捕获处理在它之后抛出的错误。
const p0 = new Promise((resolve) => resolve('success'));
const p1 = new Promise((resolve, reject) => { throw new Error('error') });
const p2 = new Promise(function (resolve, reject) {
resolve('ok');
throw new Error('test');
});
let p3 = p1.catch((err) => console.log(err + ' p3'));
let p4 = p2.catch((err) => console.log(err + ' p4'));
let p5 = p0.catch((err) => console.log(err)).then((err) => { throw new Error('p0 出错啦,p5捕获处理') });
let p6 = p0.then(() => { throw new Error('p0 出错啦, p6捕获处理') }).catch((res) => console.log(res + '冒泡'))
你也许会以为若异步任务失败被catch
捕获处理后就会停止向后执行,其实不然,程序依旧会继续执行。
const p1 = new Promise((resolve, reject) => { throw new Error('error') });
let p2 = p1.catch((err) => err + ' p2').then((res) => console.log(res)); // Error: error p2
Promise.prototype.finally
finally()
方法用于指定不管 Promise 对象最后状态如何,都会执行的操作。在大多数情况下它都会原样返回父期约。
只有两种情况不会原样返回父期约,finally()
处理程序中显示的抛出了错误或返回一个待定(pending)或失败(rejected)状态的期约,则会方法最终会返回相应的期约(待定或失败),如 p5、p6、p7。
const p1 = new Promise((resolve) => resolve('success'));
const p2 = new Promise((resolve, reject) => reject('fail'));
const p3 = p1.finally(() => { });
const p4 = p2.finally(() => Promise.resolve(12));
const p5 = p2.finally(() => Promise.reject('出错了'));
const p6 = p2.finally(() => new Promise(() => { }));
const p7 = p2.finally(() => new Error('error'));
setTimeout(() => {
console.log(p3);
console.log(p4);
console.log(p5);
console.log(p6);
console.log(p7);
}, 0);
Promise 的缺点
- 无法取消
Promise
,一旦新建它就会立即执行,无法中途取消。 - 如果不设置回调函数,
Promise
内部抛出的错误,不会反应到外部。 - 当处于
pending
状态时,无法得知目前进展到哪一个阶段(刚刚开始还是即将完成)。
非常感谢阮一峰老师ECMAScript 6 入门的帮助。
总结
ES6 的 Promise 提出的目的是为了解决“回调地狱”的问题。它是一个有状态的对象,可以使用 new 操作符实例化,但在实例化时必须要传入执行器函数,该函数是同步执行的。接收两个参数函数,调用第一个参数可以将期约状态变为解决(resolved),也可以将异步任务的结果传给它,这样外界就能拿到该结果。Promise(期约)及其方法详细总结),也可以将失败理由作为参数传给它。如果未调用任何一个参数函数,那么期约的状态就为待定(pending)。
期约的知识比较琐碎,但是很重要。我之前就是感觉半懂不懂的,导致在开发的时候使用总是比较虚,最近决定要好好研究一下,才有了这篇文章。我是孤城浪人,一名正在前端路上摸爬滚打的菜鸟,欢迎你的关注。