本模块基于 MDN-异步JavaScript 以及 New Toys talk about promises
在这个模块,我们将查看异步 JavaScript,异步为什么很重要,以及怎样使用异步来有效处理潜在的阻塞操作,比如从服务器上获取资源。
1. 异步介绍
本文中将解释什么是异步编程,为什么我们需要它,并简要讨论 JavaScript 历史上异步函数是怎样被实现的。
异步编程技术使你的程序可以在执行一个可能长期运行的任务
的同时继续对其他事件做出反应而不必等待任务完成。与此同时,你的程序也将在任务完成后显示结果。
浏览器提供的许多功能(尤其是最有趣的那一部分)可能需要很长的时间来完成,因此需要异步完成,例如:
- 使用
fetch()
发起 HTTP 请求 - 使用
getUserMedia()
访问用户的摄像头和麦克风 - 使用
showOpenFilePicker()
请求用户选择文件以供访问
因此,即使你可能不需要经常实现自己的异步函数,你也很可能需要正确使用它们。
1.1 同步编程(Synchronize)
观察下面的代码:
const name = "Miriam";
const greeting = `Hello, my name is ${name}!`;
console.log(greeting);
// "Hello, my name is Miriam!"
这段代码:
- 声明了一个叫做
name
的字符串常量 - 声明了另一个叫做
greeting
的字符串常量(并使用了name
常量的值) - 将
greeting
常量输出到 JavaScript 控制台中。
我们应该注意的是,实际上浏览器是按照我们书写代码的顺序一行一行地执行程序的。
浏览器会等待代码的解析和工作,在上一行完成后才会执行下一行。这样做是很有必要的,因为每一行新的代码都是建立在前面代码的基础之上的。
这也使得它成为一个同步程序。
事实上,调用函数的时候也是同步的,就像这样:
function makeGreeting(name) {
return `Hello, my name is ${name}!`;
}
const name = "Miriam";
const greeting = makeGreeting(name);
console.log(greeting);
// "Hello, my name is Miriam!"
在这里 makeGreeting()
就是一个同步函数,因为在函数返回之前,调用者必须等待函数完成其工作。
如果这个同步函数非常耗时呢?
如果这个同步函数非常耗时,那么在这个得到这个函数的返回值之前,整个程序会一直阻塞在这里。对于用户来说,你的程序看起来 “卡住了” 。
这就是耗时的同步函数的基本问题。在这里我们想要的是一种方法,以让我们的程序可以:
- 通过调用一个函数来启动一个长期运行的操作
- 让函数开始操作并
立即
返回,这样我们的程序就可以保持对其他事件做出反应的能力 - 当操作最终完成时,
通知
我们操作的结果。
这就是异步函数为我们提供的能力,本模块的其余部分将解释它们是如何在 JavaScript 中实现的。
1.2 事件处理 (Handler)
事件处理程序实际上就是异步编程的一种形式:你提供的函数(事件处理程序 handler)将在事件发生时被调用(而不是立即被调用)。
如果“事件”是“异步操作已经完成”,那么你就可以看到事件如何被用来通知调用者异步函数调用的结果的。
一些早期的异步 API 正是以这种方式来使用事件的。XMLHttpRequest
API 可以让你用 JavaScript 向远程服务器发起 HTTP 请求。由于这样的操作可能需要很长的时间,所以它被设计成异步 API,你可以通过给 XMLHttpRequest
对象附加事件监听器来让程序在请求进展和最终完成时获得通知。
1.3 回调 (Callbacks)
事件处理程序是一种特殊类型的回调函数。而回调函数则是一个被传递到另一个函数中的会在适当的时候
被调用的函数。正如我们刚刚所看到的:回调函数曾经是 JavaScript 中实现异步函数的主要方式。
然而,当回调函数本身需要调用其他同样接受回调函数的函数时,基于回调的代码会变得难以理解。当你需要执行一些分解成一系列异步函数的操作时,这将变得十分常见。例如下面这种情况:
即:异步程序(函数)的回调函数也是一个异步程序。所以:回调函数本身可能也需要回调函数
function doStep1(init) {
return init + 1;
}
function doStep2(init) {
return init + 2;
}
function doStep3(init) {
return init + 3;
}
function doOperation() {
let result = 0;
result = doStep1(result);
result = doStep2(result);
result = doStep3(result);
console.log(`结果:${result}`);
}
doOperation();
现在我们有一个被分成三步的操作,每一步都依赖于上一步。在这个例子中,第一步给输入的数据加 1,第二步加 2,第三步加 3。从输入 0 开始,最终结果是 6(0+1+2+3)。作为同步代码,这很容易理解。但是如果我们用回调来实现这些步骤呢?
function doStep1(init, callback) {
const result = init + 1;
callback(result);
}
function doStep2(init, callback) {
const result = init + 2;
callback(result);
}
function doStep3(init, callback) {
const result = init + 3;
callback(result);
}
function doOperation() {
doStep1(0, (result1) => {
doStep2(result1, (result2) => {
doStep3(result2, (result3) => {
console.log(`结果:${result3}`);
});
});
});
}
doOperation();
因为必须在回调函数中调用回调函数,我们就得到了这个深度嵌套的 doOperation()
函数,这就更难阅读和调试了。在一些地方这被称为“回调地狱”或“厄运金字塔”(因为缩进看起来像一个金字塔的侧面)。
面对这样的嵌套回调,处理错误也会变得非常困难:你必须在“金字塔”的每一级处理错误,而不是在最高一级一次完成错误处理。
由于以上这些原因,大多数现代异步 API 都不使用回调。事实上,JavaScript 中异步编程的基础是 Promise
,这也是我们下一篇文章要讲述的主题。
2. Promise
Promise 是现代 JavaScript 中异步编程的基础,是一个由异步函数
返回的可以向我们指示当前操作所处的状态的对象。
在 Promise 返回给调用者的时候,操作往往还没有完成
,但 Promise 对象可以让我们操作最终完成时对其进行处理(无论成功还是失败)。
上节,我们谈到使用回调实现异步函数的方法。在这种设计中,我们需要在调用异步函数的同时传入回调函数。这个异步函数会立即返回,并在操作完成后调用传入的回调。
满足了立即返回特性,即:返回一个待敲定(to be settled)的返回值(Promise类型)
在基于 Promise 的 API 中,异步函数会启动操作并返回 Promise
对象。然后,你可以将处理函数附加到 Promise 对象上,当操作完成时(成功或失败),这些处理函数将被执行。
即:返回后可以传入handler
比如,我们搞一个函数,这个函数负责请求数据,请求不会立即完成,所以 声明为异步async
函数。异步函数必须
返回一个Promise
对象,如果返回的不是Promise
,则返回结果会被隐式
用Promise包裹。
async function getAllData():Promise<AxiosResponse<Array<Item>>> {
return axios.get(`${serverBaseURL}/getAll`)
}
此时调用时,将返回 Promise
类型变量
const preparedData:Ref<Array<Item>> = ref(new Array());
onMounted(()=>{
getAllData().then((response)=>{
response as AxiosResponse<Array<Item>> // 这里因为可能是any
// 故使用断言
preparedData.value = prepareData(response)
})
})
假如此时console.log
这个变量
与同步函数不同,我们的异步函数在请求仍在进行时
返回,这使我们的程序能够保持响应性。响应显示了 200
(OK)的状态码,意味着我们的请求成功了。
我们这一次将处理程序传递到返回的 Promise 对象的 then()
方法中。
2.1 链式使用 Promise
假如说上述API请求结果的data是text/json
,需要一些API来解析,事实上,JS的某些API 就是异步的,其返回仍然是一个Promise
对象。
getAllData.then((response) => {
const jsonPromise = MyAsyncFunction(response);
MyAsyncFunction.then((json) => {
console.log(json[0].name);
});
});
我们好像说过,在回调中调用另一个回调会出现多层嵌套的情况?我们是不是还说过,这种“回调地狱”使我们的代码难以理解?这不是也一样吗,只不过变成了用 then()
调用而已?
当然如此。但 Promise 的优雅
之处在于 then()
本身也会返回一个 Promise,这个 Promise 将指示 then()
中调用的异步函数的完成状态。这意味着我们可以(当然也应该)把上面的代码改写成这样:
getAllData.then((response) => {
return MyAsyncFunction(response)
})
.then((json) => {
console.log(json[0].name);
});
在进入下一步之前,还有一件事要补充:我们需要在尝试读取请求之前检查服务器是否接受并处理了该请求。我们将通过检查响应中的状态码来做到这一点,如果状态码不是“OK”,就抛出一个错误:
getAllData.then((response) => {
if(response.status > 299){
throw new Error("出错了哦")
}
return MyAsyncFunction(response)
})
.then((json) => {
console.log(json[0].name);
});
实际上,then
可以接受两个handler
,一个是成功时的handler,另一个是失败时的handler。
MyAsyncFunction.then(successHandler,errorHandler)
2.2 错误捕获
这给我们带来了最后一个问题:我们如何处理错误? 网络 API 可能因为很多原因抛出错误(例如,没有网络连接或 URL 本身存在问题),我们也会在服务器返回错误消息时抛出一个错误。
Promise
对象提供了一个 catch()
方法来支持错误处理。这很像 then()
:你调用它并传入一个处理函数。然后,当异步操作成功时,传递给 then()
的处理函数被调用,而当异步操作失败时,传递给 catch()
的处理函数被调用。
.catch(handler) 等价于 .then(undefined, handler)
如果将 catch()
添加到 Promise 链的末尾,它就可以在任何异步函数失败时被调用。于是,我们就可以将一个操作实现为几个连续的异步函数调用,并在一个地方处理所有错误。
getAllData.then((response) => {
if(response.status > 299){
throw new Error("出错了哦")
}
return MyAsyncFunction(response)
})
.then((json) => {
console.log(json[0].name);
})
.then(...)
.then(...)
...
.catch((error)=>{
console.log(error)
})
2.3 Promise 状态
首先,Promise 有三种状态:
- 待定(pending):初始状态,既没有被兑现,也没有被拒绝。这是调用
fetch()
返回 Promise 时的状态,此时请求还在进行中。 - 已兑现(fulfilled):意味着操作成功完成。当 Promise 完成时,它的
then()
处理函数被调用。 - 已拒绝(rejected):意味着操作失败。当一个 Promise 失败时,它的
catch()
处理函数被调用。
一个Promise对象的状态由其PromiseState
成员决定,上述为一个funfilled
的Promise。
注意,这里的“成功”或“失败”的含义取决于所使用的 API:例如,fetch()
认为服务器返回一个错误(如404 Not Found)时请求成功,但如果网络错误阻止请求被发送,则认为请求失败。
有时我们用 已敲定(settled) 这个词来同时表示 已兑现(fulfilled) 和 已拒绝(rejected) 两种情况。
实际上,promise之后的状态是否为fulfilled 还是 rejected 取决于是否有异常产生
2.4 合并使用多个 Promise
2.4.1 Promise.all
当你的操作由几个异步函数组成,而且你需要在开始下一个函数之前完成之前每一个函数时,你需要的就是 Promise 链。 但是在其他
的一些情况下,你可能需要合并多个异步函数的调用,Promise
API 为解决这一问题提供了帮助。
即:你的异步操作可以同时进行
有时你需要所有的 Promise 都得到实现,但它们并不相互依赖。在这种情况下,将它们一起启动然后在它们全部被兑现后得到通知会更有效率。这里需要 Promise.all()
方法。它接收一个 Promise 数组,并返回一个单一的 Promise。
在所有传入的 Promise 都被兑现时兑现;在任意一个 Promise 被拒绝时拒绝。
返回一个Promise:
- 已兑现(already fulfilled),如果传入的 iterable 为空。
- 异步兑现(asynchronously fulfilled),如果给定的 iterable 中所有的 promise 都已兑现。兑现值是一个
数组
,其元素顺序与传入的 promise 一致,而非按照兑现的时间顺序排列。如果传入的 iterable 是一个非空但不包含待定的(pending)promise,则返回的 promise 依然是异步兑现,而非同步兑现。 - 异步拒绝(asynchronously rejected),如果给定的 iterable 中的任意 promise 被拒绝。拒绝原因是第一个拒绝的 promise 的拒绝原因。
async function create(value){
return new Promise((fulfill,reject)=>{
setTimeout(()=>{console.log(value," fulfilled");fulfill(value)},value*1000)
})
}
const promise = Promise.all([create(1),create(2),create(3),create(4)])
promise.then((values)=>{
console.log("所有Promise都fulfill了哦")
console.log(values);
})
这个API可以在批量发送AJAX请求时使用。
2.4.2 Promise.any
有时,你可能需要等待一组 Promise 中的某一个 Promise 的执行,而不关心是哪一个。
在这种情况下,你需要 Promise.any()
。这就像 Promise.all()
,不过在 Promise 数组中的任何一个被兑现时它就会被兑现,如果所有的 Promise 都被拒绝,它也会被拒绝。
至少有一个fulfill就行了。Fulfillment Value 为第一个兑现的值
2.5 async & await
2.5.1 async
async
关键字为你提供了一种更简单的方法来处理基于异步 Promise 的代码。在一个函数的开头添加 async
,就可以使其成为一个异步函数。
async 函数是使用async关键字声明的函数。async 函数是 AsyncFunction 构造函数的实例,并且其中允许使用 await 关键字。async 和 await 关键字让我们可以用一种更简洁的方式写出基于 Promise 的异步行为,而无需刻意地链式调用 promise。
async expression ;
async function name(param0) {
statements
}
函数总是返回一个Promise,async 函数一定会返回一个 promise 对象。如果一个 async 函数的返回值看起来不是 promise,那么它将会被隐式地包装在一个 promise 中。
async function foo() {
return 1;
}
// 等价于
function foo() {
return Promise.resolve(1);
}
**Note:**async 修饰的函数会被立即执行,因此aync表达式可以用IIFE(Immediately Invoked Function Expression)。
2.5.2 await
在异步函数中,你可以在调用一个返回 Promise 的函数之前使用 await
关键字。这使得代码在该点上等待
(就像程序的断点),直到 Promise 被 fulfill ,这时 Promise 的响应被当作返回值,或者被拒绝的响应被作为错误抛出。
async function a(){
promise = new Promise((resolve)=>{
setTimeout(()=>{console.log("OvO");resolve(123)},4000)
})
return promise
}
async function b(){
const result = await a()
console.log(result); //123
// 注意,因为是同步等待获取的,所以就不需要Promise了,JS自动将 Fulfillment Value 或者 Error 作为返回值
// 就好像 a 是一个同步函数
}
b()
在异步代码中,选择性的将一些内容 修改 为同步执行
请记住,就像一个 Promise 链一样,await
强制异步操作以串联的方式完成。如果下一个操作的结果取决于上一个操作的结果,这是必要的,但如果不是这样,像 Promise.all()
这样的操作会有更好的性能。
2.6 小结
Promise 是现代 JavaScript 异步编程的基础。它避免了深度嵌套回调,使表达和理解异步操作序列变得更加容易,并且它们还支持一种类似于同步编程中 try...catch
语句的错误处理方式。
async
和 await
关键字使得从一系列连续的异步函数调用中建立一个操作变得更加容易,避免了创建显式 Promise 链,并允许你像编写同步代码那样编写异步代码。
Promise 在所有现代浏览器的最新版本中都可以使用;唯一会出现支持问题的地方是 Opera Mini 和 IE11 及更早的版本。
Note: IE 已死
2.7 resolve a promise
我们都知道Promise的三个基本状态:pending
,fulfilled
,rejected
。
而且为了方便,通常将一个fulfilled
或rejected
状态的Promise称为已敲定settled
的。
- 当你
fulfill
一个pending
状态的Promise时,其回调往往需要一个Fulfillment Value
。
SuccessHandler(fulfillmentValue)
- 当你
Reject
一个pending
状态的Promise时,其回调往往是一个reason
,reason解释了为什么promise没有fulfill
FailureHandler(Error)
但请注意,resolve
没有在上述三个状态中,多数人对Resolve的认识是:resolve
一个promise 往往不会改变其基本状态,即:resolve 后 promise状态不变。
但实际上,这种理解并不全面。fulfill 和 resolve 并不是同一个概念。
当你 resolve 一个 promise 时,你的行为将决定 promise 接下来的状态。
如果你用一个具体值(Object 对象等)来resolve 一个Promise,则相当于 你用这个值fulfill 这个 Promise。
此时这个值就相当于 Fulfillment Value。实际上,当你显式声明异步函数时,但是并没有显式返回一个Promise,此时,该函数将隐式resolve并返回Promise,并且用返回的值作为 Fulfillment Value
async function tianqing(){
return 1 // a none promise return
}
console.log(tianqing());
实际上,它的返回值为:
即:上述代码等价于
async function tianqing(){
return Promise.resolve(1)
}
如果你用一个 Promise 去 Resolve 另一个Promise,
假设 用 PromiseA
去 Resolve PromiseB
const promiseA = new Promise((resolve,reject)=>{...})
const promiseB = Promise.resolve(promiseA)
则此时promiseB
的状态完全取决于promiseA
的行为
resolve promise B to promise A.
const promiseA = new Promise((fulfill,reject)=>{
setTimeout(()=>{fulfill(123)},2000)
// 等待2s调用 fulfill
})
const promiseB = Promise.resolve(promiseA).then((fulfillmentValue)=>{
console.log("promiseB is fulfilled with value: ",fulfillmentValue);
})
console.log(promiseA);
console.log(promiseB);
结果:
reject
同理:
- 如果另一个Promise被Fulfill (e.g.promiseA),则你的Promise(e.g.promiseB)也会被Fulfill。并且fulfillment值相同。
- 如果另一个Promise被Reject,则你的Promise也会被Reject,并且Reject原因相同
- 如果另一个Promise处于Pending状态,则你的Promise也将处于Pending状态
你的Promise的状态将最终取决于另一个Promise,并且之后无论你再如何操作你的Promise,都无法对其产生影响。
即:状态同步。
Note : 这里的内容参考 Js New Toys-Promise ,我在这里使用了
被字句
,但实际上原文的说法为 Fulfill / Reject itself
实际上,链式调用.then(...).then(...)
也是同理。
2.8 逻辑判断对异步的影响
省流提示:逻辑判断永远表现同步特征
async function asy(){
if(window.confirm("yes or no")){
return new Promise((resolve)=>{
setTimeout(()=>{resolve()}),2000
})
}
return Promise.resolve("resolved")
}
console.log(asy());
上述异步函数只会返回一个Promise。
end