JS 图解:Promises
回调
承诺
结论
参考
这是我写的第二篇JS 图解文章。第一篇是关于事件循环的
ES6(ECMAScript 2015)引入了一个名为Promise的新功能。市面上有很多优秀的文章和书籍解释了 Promise 的工作原理。在本文中,我们将尝试以简单易懂的方式描述 Promise 的工作原理,但不会深入探讨太多细节。
在开始解释什么是 Promise 以及它如何工作之前,我们需要先了解一下它存在的原因,以便正确理解它。换句话说,我们必须确定这个新功能试图解决的问题。
回调
Promises 与异步密不可分。在 Promises 出现之前,开发人员可以使用回调函数编写异步代码。回调函数是一个函数,它作为参数传递给另一个函数,以便后者在未来某个时间点调用它。
让我们看看下面的代码
ajaxCall("http://url-to-api", myCallback); | |
function myCallback(response) { | |
// Handle response | |
} |
ajaxCall("http://url-to-api", myCallback); | |
function myCallback(response) { | |
// Handle response | |
} |
我们调用ajaxCall
一个函数,第一个参数是 URL 路径,第二个参数是回调函数。该ajaxCall
函数会向提供的 URL 发起请求,并在响应准备好后调用回调函数。与此同时,程序继续执行(ajaxCall
不会阻塞执行)。这是一段异步代码。
效果很好!但是可能会出现一些问题,例如以下问题(Kyle Simpson,2015,你不知道的 JS:异步与性能,42):
- 回调函数永远不会被调用
- 回调函数调用过早
- 回调函数调用得太晚
- 回调函数被调用多次
ajaxCall
如果调用函数( )是我们无法修复甚至调试的外部工具,那么这些问题可能更难解决。
回调的一个严重问题似乎是,它们将程序执行的控制权交给了调用函数,这种状态称为控制反转(IoC)。
下图展示了基于回调的异步任务的程序流程。假设我们调用一个第三方异步函数,并将回调作为其参数之一传递。红色区域表示我们无法控制这些区域的程序流程。我们无法访问第三方实用程序,因此图的右侧部分显示为红色。图左侧的红色部分表示,在第三方实用程序调用我们提供的回调函数之前,我们无法控制程序。
但是,除了 IoC 问题之外,还有其他因素使得使用回调编写异步代码变得困难。它被称为回调地狱,它描述了多个嵌套回调的状态,如下面的代码片段所示。
function root(url) { | |
ajax(url, resp1 => { | |
ajax(resp1, resp2 => { | |
ajax(resp2, resp3 => { | |
ajax(resp3, resp4 => { | |
ajax(resp4, resp5 => { | |
// To be continued... | |
}); | |
}); | |
}); | |
}); | |
}); | |
} |
function root(url) { | |
ajax(url, resp1 => { | |
ajax(resp1, resp2 => { | |
ajax(resp2, resp3 => { | |
ajax(resp3, resp4 => { | |
ajax(resp4, resp5 => { | |
// To be continued... | |
}); | |
}); | |
}); | |
}); | |
}); | |
} |
我们可以看到,多个嵌套回调使得我们的代码难以阅读且难以调试。
因此,总结一下,使用回调出现的主要问题是:
- 失去对程序执行的控制(控制反转)
- 代码难以阅读,尤其是在使用多个嵌套回调时
承诺
现在让我们看看什么是 Promises 以及它们如何帮助我们克服回调的问题。
根据 MDN
Promise 对象表示异步操作的最终完成(或失败)及其结果值。
和
Promise 是创建承诺时不一定知道的值的代理
这里的新内容是,异步方法可以被调用并立即返回一些内容,这与回调不同,在回调中你必须传递一个回调函数并希望异步函数在将来的某个时间调用它。
但它返回的是什么?
这是一个承诺,在未来的某个时候您将获得实际价值。
现在,您可以使用此承诺作为未来值的占位符来继续执行。
让我们看一下构造函数
const p = new Promise((resolve, reject) => { | |
setTimeout(() => { | |
resolve(); // Or reject | |
}, 100); | |
}); |
const p = new Promise((resolve, reject) => { | |
setTimeout(() => { | |
resolve(); // Or reject | |
}, 100); | |
}); |
我们用该语句创建一个 Promise ,并传递一个名为executor 的new Promise()
函数。executor 在创建 Promise 时会立即被调用,并将两个函数分别作为前两个参数传入,即resolve和rejection函数。executor 通常启动异步操作(本例中为该函数)。setTimeout()
当异步任务成功完成其工作时,会调用resolve函数。这样我们就说这个 Promise 已经被resolve 了。我们通常会将异步任务的结果作为第一个参数传递给 resolve 函数,当然,这也是可选的。
同样,如果异步任务未能执行其分配的任务,则会调用拒绝函数,并将错误消息作为第一个参数传递,现在我们说承诺已被拒绝。
下图展示了 Promise 的工作方式。我们看到,即使使用了第三方工具,我们仍然可以控制程序流程,因为我们会立即返回一个 Promise,一个可以代替实际 Future 值的占位符。
根据 Promises/A+ 规范
承诺必须处于以下三种状态之一:待定、已实现或已拒绝
当承诺处于待处理状态时,它可以转换为已完成(已解决)或已拒绝状态。
这里非常重要的是,如果一个 Promise 的状态是 fulfilled 或 rejection 之一,它就不能改变它的 state 和 value。这被称为不可变身份,它可以保护我们免受不必要的状态更改的影响,从而避免代码中出现无法发现的错误。
重新获得控制权
正如我们之前看到的,当我们使用回调时,我们依赖于另一段代码(通常由第三方编写),以触发我们的回调函数并继续执行程序。
有了 Promise,我们无需依赖任何人就能继续执行程序。我们手中握着一个承诺,承诺在未来的某个时刻会得到一个实际值。现在,我们可以将这个 Promise 作为实际值的占位符,并像在同步编程中一样继续执行程序。
可读的异步代码
与回调相比,Promise 使我们的代码更具可读性(还记得回调地狱吗?)。查看以下代码片段:
function callAjax(url) { | |
return new Promise((resolve, reject) => { | |
ajax(url, (resp) => { | |
resolve(resp); | |
}, | |
err => { | |
reject(err); | |
}); | |
}); | |
} | |
callAjax("https://url.com") | |
.then((resp1) => { | |
return callAjax(resp1); | |
}) | |
.then((resp2) => { | |
return callAjax(resp2); | |
}) | |
.then((resp3) => { | |
return callAjax(resp3); | |
}) | |
.then((resp4) => { | |
return callAjax(resp4); | |
}); |
function callAjax(url) { | |
return new Promise((resolve, reject) => { | |
ajax(url, (resp) => { | |
resolve(resp); | |
}, | |
err => { | |
reject(err); | |
}); | |
}); | |
} | |
callAjax("https://url.com") | |
.then((resp1) => { | |
return callAjax(resp1); | |
}) | |
.then((resp2) => { | |
return callAjax(resp2); | |
}) | |
.then((resp3) => { | |
return callAjax(resp3); | |
}) | |
.then((resp4) => { | |
return callAjax(resp4); | |
}); |
我们可以按顺序链接多个承诺,使我们的代码看起来像同步代码,避免将多个回调嵌套在一起。
承诺 API
该Promise
对象公开了一组静态方法,可供调用以执行特定任务。我们将尽可能地通过一些简单的示例来简要介绍每个方法。
Promise.reject(原因)
Promise.reject()
创建一个立即被拒绝的承诺,它是以下代码的简写:
const p1 = new Promise((resolve, reject) => { | |
reject("Error"); | |
}); |
const p1 = new Promise((resolve, reject) => { | |
reject("Error"); | |
}); |
下一个代码片段显示,Promise.reject()
使用传统构造的承诺(new Promise()
)返回相同的被拒绝的承诺,该承诺因相同的原因而立即被拒绝。
new Promise((resolve, reject) => { | |
reject("Error"); | |
}).catch(err => { | |
console.log(err); // Logs "Error" | |
}); | |
Promise.reject("Error").catch(err => { | |
console.log(err); // Logs "Error" | |
}); |
new Promise((resolve, reject) => { | |
reject("Error"); | |
}).catch(err => { | |
console.log(err); // Logs "Error" | |
}); | |
Promise.reject("Error").catch(err => { | |
console.log(err); // Logs "Error" | |
}); |
Promise.resolve(值)
Promise.resolve()
使用给定值创建一个立即解析的 Promise。它是以下代码的简写:
const p1 = new Promise((resolve, reject) => { | |
resolve(1); | |
}); |
const p1 = new Promise((resolve, reject) => { | |
resolve(1); | |
}); |
new
将使用关键字构造并立即通过值解决的承诺1
与使用相同值构造的承诺进行比较Promise.resolve()
,我们发现它们都返回相同的结果。
new Promise((resolve, reject) => { | |
resolve(1); | |
}).then(resp => { | |
console.log(resp); // Logs 1 | |
}); | |
Promise.resolve(1).then(resp => { | |
console.log(resp); // Logs 1 | |
}); |
new Promise((resolve, reject) => { | |
resolve(1); | |
}).then(resp => { | |
console.log(resp); // Logs 1 | |
}); | |
Promise.resolve(1).then(resp => { | |
console.log(resp); // Logs 1 | |
}); |
然后
根据 Promises/A+ 规范
thenable
then
是定义方法的对象或函数
让我们在下面的代码片段中看看thenable 的thenable
具体实现。我们声明一个对象,它有一个then
方法,该方法会立即调用第二个函数,并将"Rejected"
值作为参数。正如我们所见,我们可以传递两个函数来调用对象then
的方法thenable
,其中第二个函数会以"Rejected"
值作为第一个参数进行调用,就像 Promise 一样。
const thenable = { | |
then: (resolve, reject) => { | |
reject("Rejected"); | |
} | |
}; | |
thenable.then(resp => { | |
console.log(resp); // Not executed | |
}, err => { | |
console.log(err); // Logs "Rejected" | |
}); |
const thenable = { | |
then: (resolve, reject) => { | |
reject("Rejected"); | |
} | |
}; | |
thenable.then(resp => { | |
console.log(resp); // Not executed | |
}, err => { | |
console.log(err); // Logs "Rejected" | |
}); |
但是如果我们想像catch
使用承诺一样使用该方法该怎么办?
thenable.catch(err => { | |
console.log(err); // "TypeError: thenable.catch is not a function | |
}); |
thenable.catch(err => { | |
console.log(err); // "TypeError: thenable.catch is not a function | |
}); |
哎呀!出现错误,提示该thenable
对象没有catch
可用的方法!这很正常,因为情况就是这样。我们声明了一个只有一个方法的普通对象,then
它恰好在某种程度上符合 Promise API 的行为。
无论如何,这并不意味着暴露方法的对象
then
就是承诺对象。
但如何才能Promise.resolve()
帮助解决这种情况呢?
Promise.resolve()
可以接受一个thenable作为参数,然后返回一个 Promise 对象。让我们将我们的thenable
对象视为一个 Promise 对象。
Promise.resolve(thenable).catch(err => { | |
console.log(err); // Logs "Rejected" | |
}); |
Promise.resolve(thenable).catch(err => { | |
console.log(err); // Logs "Rejected" | |
}); |
Promise.resolve()
可以用作将对象转换为承诺的工具。
Promise.all(可迭代)
Promise.all()
等待提供的可迭代对象中的所有承诺得到解决,然后按照在可迭代对象中指定的顺序返回已解决的承诺的值数组。
在下面的示例中,我们声明了 3 个 Promise,分别为 和p1
,它们都会在特定时间后被解析。我们特意在 之前解析,以表明返回的解析值顺序是 Promise 在传递给 的数组中声明的顺序,而不是这些 Promise 被解析的顺序。p2
p3
p2
p1
Promise.all()
const p1 = new Promise((resolve, reject) => { | |
setTimeout(() => { | |
resolve(1); | |
}, 200); | |
}); | |
const p2 = new Promise((resolve, reject) => { | |
setTimeout(() => { | |
resolve(2); | |
}, 100); | |
}); | |
const p3 = new Promise((resolve, reject) => { | |
setTimeout(() => { | |
resolve(3); | |
}, 300); | |
}); | |
Promise.all([p1, p2, p3]).then((resp) => { | |
console.log(resp); // Logs [1,2,3] | |
}, (err) => { | |
console.log(err); // Not executed | |
}); |
const p1 = new Promise((resolve, reject) => { | |
setTimeout(() => { | |
resolve(1); | |
}, 200); | |
}); | |
const p2 = new Promise((resolve, reject) => { | |
setTimeout(() => { | |
resolve(2); | |
}, 100); | |
}); | |
const p3 = new Promise((resolve, reject) => { | |
setTimeout(() => { | |
resolve(3); | |
}, 300); | |
}); | |
Promise.all([p1, p2, p3]).then((resp) => { | |
console.log(resp); // Logs [1,2,3] | |
}, (err) => { | |
console.log(err); // Not executed | |
}); |
在下面的插图中,绿色圆圈表示特定承诺已被解决,红色圆圈表示特定承诺已被拒绝。
但是如果一个或多个 Promise 被拒绝会发生什么? 返回的 Promise 将Promise.all()
被拒绝,其值是迭代器中包含的 Promise 中第一个被拒绝的 Promise 的值。
即使多个承诺被拒绝,最终结果也是被拒绝的承诺,其值与第一个被拒绝的承诺相同,而不是拒绝消息的数组。
const p1 = new Promise((resolve, reject) => { | |
setTimeout(() => { | |
reject("Error p1"); | |
}, 200); | |
}); | |
const p2 = new Promise((resolve, reject) => { | |
setTimeout(() => { | |
resolve(2); | |
}, 100); | |
}); | |
const p3 = new Promise((resolve, reject) => { | |
setTimeout(() => { | |
resolve(3); | |
}, 300); | |
}); | |
Promise.all([p1, p2, p3]).then((resp) => { | |
console.log(resp); // Not executed | |
}, (err) => { | |
console.log(err); // Logs "Error p1" | |
}); |
const p1 = new Promise((resolve, reject) => { | |
setTimeout(() => { | |
reject("Error p1"); | |
}, 200); | |
}); | |
const p2 = new Promise((resolve, reject) => { | |
setTimeout(() => { | |
resolve(2); | |
}, 100); | |
}); | |
const p3 = new Promise((resolve, reject) => { | |
setTimeout(() => { | |
resolve(3); | |
}, 300); | |
}); | |
Promise.all([p1, p2, p3]).then((resp) => { | |
console.log(resp); // Not executed | |
}, (err) => { | |
console.log(err); // Logs "Error p1" | |
}); |
Promise.allSettled(可迭代)
Promise.allSettled()
其行为类似于Promise.all()
等待所有承诺都兑现。区别在于结果。
const p1 = new Promise((resolve, reject) => { | |
setTimeout(() => { | |
resolve(1); | |
}, 200); | |
}); | |
const p2 = new Promise((resolve, reject) => { | |
setTimeout(() => { | |
reject("Error p2"); | |
}, 100); | |
}); | |
const p3 = new Promise((resolve, reject) => { | |
setTimeout(() => { | |
resolve(3); | |
}, 300); | |
}); | |
Promise.allSettled([p1, p2, p3]).then((resp) => { | |
console.log(resp); | |
}); | |
// Outcome | |
[{ | |
status: "fulfilled", | |
value: 1 | |
}, { | |
reason: "Error p2", | |
status: "rejected" | |
}, { | |
status: "fulfilled", | |
value: 3 | |
}] |
const p1 = new Promise((resolve, reject) => { | |
setTimeout(() => { | |
resolve(1); | |
}, 200); | |
}); | |
const p2 = new Promise((resolve, reject) => { | |
setTimeout(() => { | |
reject("Error p2"); | |
}, 100); | |
}); | |
const p3 = new Promise((resolve, reject) => { | |
setTimeout(() => { | |
resolve(3); | |
}, 300); | |
}); | |
Promise.allSettled([p1, p2, p3]).then((resp) => { | |
console.log(resp); | |
}); | |
// Outcome | |
[{ | |
status: "fulfilled", | |
value: 1 | |
}, { | |
reason: "Error p2", | |
status: "rejected" | |
}, { | |
status: "fulfilled", | |
value: 3 | |
}] |
正如您在上面的代码片段中看到的,通过一个对象数组来解决返回的承诺,Promise.allSettled()
该对象数组描述了已传递的承诺的状态。
Promise.race(可迭代)
Promise.race()
等待第一个承诺被解决或拒绝,并分别解决或拒绝Promise.race()
该承诺所返回的承诺及其价值。
在下面的例子中,p2
承诺在p1
被拒绝之前就已解决。
const p1 = new Promise((resolve, reject) => { | |
setTimeout(() => { | |
reject("Error p1"); | |
}, 200); | |
}); | |
const p2 = new Promise((resolve, reject) => { | |
setTimeout(() => { | |
resolve(2); | |
}, 100); | |
}); | |
Promise.race([p1, p2]).then((resp) => { | |
console.log(resp); // Logs 2 | |
}, (err) => { | |
console.log(err); // Not executed | |
}); |
const p1 = new Promise((resolve, reject) => { | |
setTimeout(() => { | |
reject("Error p1"); | |
}, 200); | |
}); | |
const p2 = new Promise((resolve, reject) => { | |
setTimeout(() => { | |
resolve(2); | |
}, 100); | |
}); | |
Promise.race([p1, p2]).then((resp) => { | |
console.log(resp); // Logs 2 | |
}, (err) => { | |
console.log(err); // Not executed | |
}); |
如果我们改变延迟,并将其设置p1
为在解决之前 100 毫秒被拒绝,则p2
最终的承诺将被拒绝并显示相应的消息,如下图所示。
const p1 = new Promise((resolve, reject) => { | |
setTimeout(() => { | |
reject("Error p1"); | |
}, 100); | |
}); | |
const p2 = new Promise((resolve, reject) => { | |
setTimeout(() => { | |
resolve(2); | |
}, 200); | |
}); | |
Promise.race([p1, p2]).then((resp) => { | |
console.log(resp); // Not executed | |
}, (err) => { | |
console.log(err); // Logs "Error p1" | |
}); |
const p1 = new Promise((resolve, reject) => { | |
setTimeout(() => { | |
reject("Error p1"); | |
}, 100); | |
}); | |
const p2 = new Promise((resolve, reject) => { | |
setTimeout(() => { | |
resolve(2); | |
}, 200); | |
}); | |
Promise.race([p1, p2]).then((resp) => { | |
console.log(resp); // Not executed | |
}, (err) => { | |
console.log(err); // Logs "Error p1" | |
}); |
Promise.prototype 方法
现在,我们将了解 Promise 原型对象暴露的一些方法。我们之前已经提到过其中一些,现在,我们将更详细地介绍每一个方法。
Promise.prototype.then()
在前面的例子中我们已经then()
多次使用过。then()
它用于处理 Promise 的已解决状态。它接受一个解析处理函数作为其第一个参数,一个拒绝处理函数作为其第二个参数,并返回一个 Promise。
const p1 = Promise.resolve(1); | |
p1.then(res => { | |
console.log(res); // Logs 1 | |
}, err => { | |
console.log(err); // Not executed | |
}); |
const p1 = Promise.resolve(1); | |
p1.then(res => { | |
console.log(res); // Logs 1 | |
}, err => { | |
console.log(err); // Not executed | |
}); |
接下来的两个插图展示了呼叫的操作方式then()
。
如果已解决的承诺的调用的解析处理程序then()
不是函数,则不会引发任何错误,相反,返回的承诺then()
会带有先前状态的解析值。
在以下代码片段中,p1
已解析并返回值1
。then()
不带参数调用将返回一个处于已解析状态的新 Promise p1
。then()
使用undefined
解析处理程序和有效的拒绝处理程序调用也将执行相同的操作。最后,then()
使用有效的解析处理程序调用将返回 Promise 的值。
const p1 = Promise.resolve(1); | |
p1.then() | |
.then(undefined, err => { | |
console.log(err); // Not executed | |
}) | |
.then(res => { | |
console.log(res); // Logs 1 | |
}); |
const p1 = Promise.resolve(1); | |
p1.then() | |
.then(undefined, err => { | |
console.log(err); // Not executed | |
}) | |
.then(res => { | |
console.log(res); // Logs 1 | |
}); |
then()
如果我们向被拒绝的承诺的调用传递无效的拒绝处理程序,也会发生同样的情况。
让我们看下面的插图,它展示了使用 解决或拒绝承诺的流程then()
,假设p1
是一个具有价值的已解决承诺1
,而p2
是一个具有原因的被拒绝承诺"Error"
。
const p1 = Promise.resolve(1); | |
const p2 = Promise.reject("Error"); |
const p1 = Promise.resolve(1); | |
const p2 = Promise.reject("Error"); |
我们看到,如果我们不传递任何参数或者我们将非函数对象作为参数传递给then()
,则返回的承诺将保持状态(resolved / rejected
)和初始状态的值而不会引发任何错误。
但是如果我们传递一个不返回任何内容的函数会发生什么?下图显示,在这种情况下,返回的 Promise 会根据其undefined
值被解决或拒绝。
Promise.prototype.catch()
catch()
当我们只想处理被拒绝的情况时,我们会调用。catch()
它接受一个拒绝处理程序作为参数,并返回另一个 Promise,以便可以链式调用。它与调用 相同then()
,只是提供一个undefined
或null
解析处理程序作为第一个参数。让我们看下面的代码片段。
const p1 = Promise.reject("Error"); | |
p1.then(undefined, err => { | |
console.log(err); // Logs "Error" | |
}); | |
p1.catch(err => { | |
console.log(err); // Logs "Error" | |
}); |
const p1 = Promise.reject("Error"); | |
p1.then(undefined, err => { | |
console.log(err); // Logs "Error" | |
}); | |
p1.catch(err => { | |
console.log(err); // Logs "Error" | |
}); |
在下图中,我们可以看到它的catch()
运作方式。注意第二个流程,我们在then()
函数的解析处理程序中抛出了一个错误,但它从未被捕获。发生这种情况是因为这是一个异步操作,即使我们在块内执行此流程,这个错误也不会被捕获try...catch
。
另一方面,最后一张图显示了相同的情况,但catch()
在流程末尾有一个附加部分,实际上可以捕获错误。
Promise.prototype.finally()
finally()
当我们不关心承诺是否已被解决或拒绝时可以使用,只要承诺已经解决即可。finally()
接受一个函数作为其第一个参数并返回另一个承诺。
const p1 = Promise.resolve(1); | |
p1.finally(() => { | |
console.log("Promise settled"); // Logs "Promise settled" | |
}); |
const p1 = Promise.resolve(1); | |
p1.finally(() => { | |
console.log("Promise settled"); // Logs "Promise settled" | |
}); |
通过调用返回的承诺finally()
将使用初始承诺的解析值进行解析。
const p1 = Promise.resolve(1); | |
p1.finally(() => { | |
console.log("Promise settled"); // Logs "Promise settled" | |
}).then(res => { | |
console.log(res); // Logs 1 | |
}); |
const p1 = Promise.resolve(1); | |
p1.finally(() => { | |
console.log("Promise settled"); // Logs "Promise settled" | |
}).then(res => { | |
console.log(res); // Logs 1 | |
}); |
结论
Promises 是一个很宽泛的话题,一篇文章无法完全涵盖。我尝试通过一些简单的示例来帮助读者了解 Promises 在 JavaScript 中的工作原理。
如果您发现任何错误或遗漏,请随时指出!我为撰写这篇文章付出了很多努力,也学到了很多关于 Promise 的知识。希望您喜欢它😁