异步 JavaScript
为什么要考虑异步问题?
有些计算机程序,例如我们常用 Python 写的功能脚本输入计算密集型,这意味着这些程序会持续不断地运行,不会暂停,直到计算出结果。
不过,大多数现实的计算机程序是异步的,这意味着,程序必须暂停计算,等到数据到达或某个事件触发。
浏览器中的 JavaScript 程序是典型的事件驱动型程序,即程序会等待用户进行操作,比如单击或填写表单,然后程序才会真正执行。这种异步编程在 JavaScript 中是非常常见的。
JavaScript 中有三种重要的语言特性,使得编写异步代码变得更加容易。
- ES6 新增的
Promise
对象 - ES2017 新增的关键字
async
和await
- ES2018 新增的异步迭代器
for/await
JavaScript 虽然提供了编写异步代码的强大特性,但其核心语言特性中却没有一个是异步的。
回调函数
在最基本的层面上,JavaScript 异步编程是通过回调实现的。
定时器
一种最简单的异步操作就是在一定时间后运行某些代码。
setTimeout(func, 600); // 600 毫秒后运行 func 函数
事件
客户端 JavaScript 编程几乎都是事件驱动的,等待用户的操作,然后响应用户的动作。事件驱动的程序在特定上下文中为特定时间注册回调函数,浏览器在指定的事件发生时调用这些函数。这些回调函数叫做事件处理程序或事件监听器,是通过 addEventListener()
注册的。
let okay = document.querySelector('#confirm');
okay.addEventListener('click', applyComfirm);
网络事件
JavaScript 中一个常见的异步操作来源是网络请求。浏览器中运行的 JavaScript 可以通过类似下面的代码从 web 服务器获取数据。
function getCurrentVersionNumber(versionCallback){
// 向后端 api 发送一个 HTTP 请求
let request = new XMLHttpRequest();
request.open("GET", "http://www.example.com/api/version");
request.send();
// 注册一个在响应到达时调用的回调函数
request.onload = function(){
if(request.status === 200){
// 请求成功,调用回调函数
let currentVersion = parseFloat(request.responseText);
versionCallback(null, currentVersion);
}else{
// 请求出错,通过回调报告错误
versionCallback(this.response.status, null);
}
}
// 注册另一个在网络出错时调用的回调函数
request.onerror = request.ontimeout = function(e){
versionCallback(e.type, null);
}
}
Promise
什么是 Promise?
Promise 是一个对象,表示异步操作的结果。
用例子去解释更加清楚。自从核心 JavaScript 语言支持 Promise 后,浏览器也开始实现基于 Promise 的 API。想象一下有这样一个浏览器函数叫 getJSON()
,它把 HTTP 响应体解析为 JSON 格式并返回一个 Promise
对象,基本使用如下:
getJSON(URL).then(jsonData => {
// 这是一个回调函数,它会在解析得到 JSON 值之后异步调用,并接受 JSON 值作为参数
});
getJSON()
函数向指定的 URL 发送一个异步 HTTP 请求,然后在请求结果待定期间返回一个 Promise
对象,这个 Promise
对象有一个实例方法叫 then()
。
回调函数传给了 Promise
的 then()
方法,当 HTTP 请求的响应到达时,响应体会被解析为 JSON 格式,解析后的值会被传给作为 then()
的参数的函数。
可以把 then()
理解为客户端 JavaScript 中注册事件处理程序的 addEventListener()
方法。如果多次调用一个期约对象的 then()
方法,则指定的每个函数都会在计算完成后被调用。
.then()
方法是 Promise
独有的特性。以动词开头来命名返回 Promise
的函数以及使用 Promise
结果的函数也是一种惯例。遵循这个惯例可以增加代码的可读性。
function displayUserProfile(profile) {
/* 省略实现细节 */
}
getJSON("/api/user/profile").then(displayUserProfile);
使用 Promise 处理异常
涉及网络的操作通常会有多种失败的原因。健壮的代码必须处理各种无法避免的错误。
对于 Promise
来说,可以通过 then()
方法的第二个参数实现错误处理:
getJSON("/api/user/profile")
.then(displayUserProfile, handleProfileError);
这里展开说一下同步计算与Promise
的一些区别:
- 同步计算在正常结束后会向调用者返回计算结果。
- 而基于
Promise
的异步计算在正常结束后,会把计算返回的结果传给then()
的第一个参数。 - 同步计算出错会抛出一个异常,该异常会沿着调用栈向上一直传播到一个处理它的
catch
子句。 - 异步运算在运行时,它的调用者已经不在栈里,因此如果出现错误,没办法向调用者抛出异常。
- 为此,基于
Promise
的异步计算把异常传给then()
的第二个参数的函数。
但,实际开发中,很少看到给 then()
传两个函数的情况,取而代之的是另一种异常处理写法。
getJSON("/api/user/profile")
.then(displayUserProfile)
.catch(handleProfileError);
这个 catch()
方法只是对调用 then()
时以 null
作为第一个参数,以指定错误处理函数作为第二个参数的一种简写方式。
Promise 相关术语
想象一下,调用一个 Promise
的 then()
方法时传入了两个回调函数。
- 如果第一个回调被调用,我们就说
Promise
被兑现fulfill
。 - 如果第二个回调被调用,我们就说
Promise
被拒绝reject
。 - 如果既未被兑现,也未被拒绝,那么它就是待定
pending
。
除此之外,Promise 也可能被解决 resolve
。
理解 Promise
Promise 是一种用于处理异步操作的 JavaScript 对象。它可以让我们更优雅地处理需要一段时间才能完成的任务,比如从服务器获取数据或处理文件读写。
Promise 有三种状态:
- 等待中(Pending):这是初始状态,表示操作尚未完成,也没有确定结果。
- 已完成(Fulfilled):操作成功完成,Promise 得到了期望的结果。
- 已失败(Rejected):操作失败,Promise 得到一个错误原因。
Promise 的优势在于,它让我们可以用一种更清晰的方式来管理异步任务,避免嵌套的回调函数带来的复杂性。我们可以使用 then
方法来处理操作成功的结果,用 catch
方法来处理操作失败的情况。
举个例子,如果我们向服务器请求数据,Promise 可以帮我们在数据成功返回时执行某些操作,在请求失败时处理错误,而不是把代码写成一层层的回调函数。
实现简易的 Promise
引例
let promise1 = new Promise((resolve, reject) => {
setTimeout(() => {
let random = Math.random();
console.log("random", random);
if (random > 0.5) {
resolve(1);
} else {
reject(2);
}
}, 1000);
});
promise1.then(
(data) => {
console.log("resolve", data);
},
(e) => {
console.log("reject", e);
}
);
执行的结果如下:
分析这段代码的执行:
- 创建
Promise
对象:promise1
是一个新的Promise
对象。 - 设置定时器: 使用
setTimeout
设置一个 1 秒(1000 毫秒)的定时器。 - 生成随机数: 定时器到期后,生成一个 0 到 1 之间的随机数,并打印该随机数。
- 判断随机数:
- 如果随机数大于 0.5,调用
resolve(1)
,表示Promise
成功完成,并传递值 1。Promise
从pending
状态变为resolved
状态。 - 如果随机数小于或等于 0.5,调用
reject(2)
,表示Promise
失败,并传递错误信息 2。Promise
从pending
状态变为rejected
状态。
- 如果随机数大于 0.5,调用
- 处理
Promise
结果:- 如果
Promise
状态为 resolved(成功),执行then
方法的第一个参数注册的回调函数。 - 如果
Promise
状态为 rejected(失败),执行then
方法的第二个参数注册的回调函数。
- 如果
结构定义
构造函数
Promise
是一个构造函数,该函数的参数是一个函数,我们叫他执行器函数。- 执行器函数接收两个参数,
resolve
与reject
,他们也都是函数:resolve(data)
用于将Promise
状态设置为fulfilled
(成功),并传递结果 data。reject(error)
用于将Promise
状态设置为rejected
(失败),并传递错误信息 error。
状态
- 初始状态为
pending
(待定)。 - 状态只能从
pending
转变为fulfilled
或rejected
,且一旦改变,无法再次改变。
方法
.then(onFulfilled, onRejected)
:- 处理
Promise
成功状态和失败状态的回调函数。 - 返回一个新的
Promise
,所以支持链式调用。
- 处理
.catch(onRejected)
:- 处理
Promise
失败的回调函数,相当于.then(null, onRejected)
。
- 处理
.finally(onFinally)
:- 无论
Promise
成功还是失败,都会执行的回调函数。
- 无论
代码实现
实现要点:
Promise
构造函数会同步执行执行器函数。.then
方法中的第一个参数函数会在执行器函数的resolve
执行后被调用.then
方法中的第二个参数函数会在执行器函数的reject
执行后被调用
极简实现
class SimplePromsie {
constructor(executor) {
this.state = "pending";
this.value = undefined;
this.error = undefined;
this.onResolveCallbacks = [];
this.onRejectCallbacks = [];
const resolve = (value) => {
if (this.state === "pending") {
this.state = "fulfilled";
this.value = value;
// 执行回调函数
this.onResolveCallbacks.forEach((callback) =>
callback(this.value)
);
}
};
const reject = (error) => {
if (this.state === "pending") {
this.state = "rejected";
this.error = error;
// 执行回调函数
this.onRejectCallbacks.forEach((callback) => {
callback(this.error);
});
}
};
try {
executor(resolve, reject); // 创建实例时立即执行
} catch (error) {
reject(error);
}
}
// 注册处理成功和处理失败的回调函数
then(onFulfilled, onRejected) {
if (this.state === "fulfilled") {
onFulfilled(this.value);
} else if (this.state === "rejected") {
onRejected(this.error);
} else if (this.state === "pending") {
this.onResolveCallbacks.push(onFulfilled);
this.onRejectCallbacks.push(onRejected);
}
return this;
}
}
使用这个自己定义的 SimplePromise
实现和引例中相同的功能:
let simplePromise = new SimplePromsie((resolve, reject) => {
setTimeout(() => {
let random = Math.random();
console.log("random", random);
if (random > 0.5) {
resolve(1);
} else {
reject(2);
}
}, 1000);
});
simplePromise.then(
(data) => {
console.log("resolve", data);
},
(e) => {
console.log("reject", e);
}
);
执行代码得到结果:
参考:
Promise链
TIP
Promise 有一个最重要的优点,就是以线性 then()
方法调用链的形式表达一连串异步操作,而无需把每个操作都嵌套在前一个操作的内部。
这里以函数 fetch()
为例,传给它一个 URL 链接,它会返回一个 Promise
对象,这个 Promise
会在 HTTP 响应开始到达且 HTTP 状态和头部可用时兑现。
fetch("api/user/profile")
.then(response => {
// 在 Promise 解决时,可以访问 HTTP 状态和头部
if (response.ok
&& response.headers.get("Content-Type" === "application/json")) {
// 现在还没有得到响应体,在这里可以做什么?
}
});
在 fetch
返回的 Promise 兑现时,then()
方法中的函数会被调用。
在收到响应后,解析响应体的幼稚方式:
fetch("api/user/profile")
.then(Response => {
Response.json() // 获取 JSON 格式的响应体,返回一个 Promise
.then(profile => {
// 在响应体到达时会自动解析为 JSON 格式并传入函数
displayUserProfile(profile);
})
});
之所以说这是一种幼稚方式,是因为我们像嵌套回调一样嵌套了它们,这违背了 Promise
的初衷。应该像以下代码一样写成一串 Promise
链。
fetch("api/user/profile")
.then(Response => {
return Response.json(); // 手动返回一个 Promise
})
.then(profile => {
displayUserProfile(profile);
});
我们可以将上面的代码抽象为:
fetch(theURL) // 任务1,返回期约1
.then(callback1) // 任务2,返回期约2
.then(callback2) // 任务3,返回期约3
下面我们逐步剖析这个代码:
- 第一行,调用
fetch
并传入一个 URL ,这个方法会向该 URL 发送一个 HTTP GET 请求并返回一个Promise
,我们称这个 HTTP 请求为任务一、称这个Promise
为期约一。 - 第二行,调用期约一的
then()
方法,传入callback1
函数,这个函数会在期约一被兑现时调用。这个then()
方法会把callback1
保存在某个地方,并返回一个新期约,我们称其为期约二,并说任务二在callback1
被调用时开始。 - 第三行,调用期约二的
then()
方法,传入callback2
函数,将callback2
保存,并返回期约三。 - 当这个表达式一开始执行,前三步同步发生,然后在第一步创建的 HTTP 请求通过互联网发出时有一个异步暂停。
- 终于,HTTP 请求到达,
fetch()
调用的异步逻辑将 HTTP 状态和头部包装到一个Response
对象中,并将这个对象作为值兑现期约1。 - 期约 1 兑现后,它的值会传给
callback1
,此时任务 2 开始。 - 假设任务 2 正常结束,即成功解析并生成了一个 JSON 对象,这个对象就被用于兑现期约 2.
- 兑现期约 2 的值在传入
callback2
函数时变成了任务 3 的输入。
解决期约 Resolve
Promise 链中提到了三个任务以及三个期约,但实际上这里有第四个 Promise
对象。
fetch("api/user/profile") // 任务1,期约1
.then(Response => { // 任务2,期约2
return Response.json(); // 手动返回一个 Promise
})
.then(profile => { // 任务3,期约3
displayUserProfile(profile);
});
上面这个示例中,任务 2 调用 .json
方法解析响应体并返回它的值,但由于响应体可能并未到达,因此这一方法返回的是 Promise
对象。也就是第四个 Promise
对象。
我们再重写一次抓取 URL 的代码,这一次使用一种非常冗余的方法,使回调和期约更加明显:
function c1(response){ // 回调1
let p4 = response.json();
return p4; // 期约4
}
function c2(profile){ // 回调2
displayUserProfile(profile);
}
let p1 = fetch("/api/user/profile"); // 期约1,任务1
let p2 = p1.then(c1); // 期约2,任务2
let p3 = p2.then(c2); // 期约3,任务3
为了让期约链有效工作,任务 2 的输出必须成为任务 3 的输入。
在该示例中,任务 3 的输入是从 URL 抓取到的响应体后又解析生成的 JSON 对象。
但是,正如刚刚所说,回调 c1 的返回值不是 JSON 对象,而是表示该 JSON 对象的期约 p4。
在 p1 兑现后,c1 被调用,任务 2 开始;而当 p2 兑现时,c2 被调用,任务 3 开始。
这句话有些难以理解,拆分来看,c1 被调用代表任务 2 开始,c1 返回后,任务 2 却并没有结束。这是由于任务 2 是异步的。
我们再分析一下期约 2 和任务 2 :当把回调函数 c1 传给方法 p1.then()
的时候,then()
返回期约 p2,安排好在将来某个时刻(收到响应头的时候)执行 c1。到了这个时候,c1 执行,c1 返回返回值 v,如果 v 是非期约值,p2 就以 v 这个值得到了兑现(fulfill
),而如果 v 是期约值,正如上面的例子中,此时 p2 就会得到解决(resolve
) 而未兑现(fulfill
)。此时, p2 要等到 v 落定了才能落定。
在这个例子中,当 c1 返回 p4 的时候,p2 得到解决,但解决不等同于兑现,因此任务 3 还不会开始。