一文讲清 JavaScript 异步编程
前言
一直以来,JavaScript 以其出色的异步模型著称。特别在 ES6 之后,JavaScript 正式变成一门完善的编程语言,Promise
使其异步特性也趋近于完善,并且在 ES2017 中新增了基于 Promise
的 async/await
语法糖,使得异步操作变得更加简单。可以说,如果不学习 JavaScript 的异步编程,就根本不算真正接触 JavaScript 这门语言。
那么究竟什么是异步编程,接下来让我开始为你讲解。
异步能干什么?
异步在现代软件开发中非常重要,其核心优势在于能够在不阻塞主线程的情况下执行多个任务。
如果你在过去曾经学习过一些基于同步模型的语言(如 C/C++),你一定会知道,在大多数情况下,你的代码都是同步执行的。也就是说代码的执行顺序严格按照代码的编写顺序,如果你想要在实现同步代码中实现延迟等待,最简单的方式是运行一个不停止的循环来阻塞代码执行。但是即使在这种情况下,如果你不使用多线程这类技术,你仍然无法改变代码执行顺序。
而在 JavaScript 这种具有异步模型的语言中,代码的执行顺序并不严格按照代码的编写顺序,如以下代码:
console.log('A');
setTimeout(() => {
console.log('B');
}, 10);
console.log('C');
这段代码执行后,会输出 A C B
而非 A B C
,这就意味着代码并非同步执行,而是异步执行。
这种特性也使得我们在执行耗时任务(如等待接口返回)的时候不必等待当前任务执行完毕才去执行下一个,从而提高了运行效率。同一段时间内执行多个任务也称为并行。
难道我们一定要使用 setTimeout
或 setInterval
才能进行异步操作吗?当然不是,接下来讲解的 Promise
能够让你更加自由地进行异步操作。
认识 Promise
所谓回调地狱
在 ES6 以前,JavaScript 采用回调函数的方式来处理异步操作:
getImage('path/to/img', (img) => {
/* 对获取的图片进行处理... */
});
这个异步模式其实非常常见,如果你接触过 NodeJS,你就会发现其 API 大多数都是这种形式的(这也是 NodeJS 长久以来被广大开发者诟病的点之一,就连其创始人都曾吐槽过)。在异步操作较少或不需要考虑处理操作先后顺序的情况下,采用这个模式的确是不错的选择。但是如果操作的数量一大,这个模式就容易导致回调地狱的出现。
什么是回调地狱?
回调地狱指的是当需要按顺序执行多个异步操作时,回调函数往往会被大量嵌套,从而导致可读性大大下降的情况。如以下代码:
//// ====== ⛩️ 「登神长阶」 ⛩️ ====== //// getData1('/data1', (data1) => { getData2(data1.next, (data2) => { getData3(data2.next, (data3) => { getData4(data3.next, (data4) => { getData5(data4.next, (data5) => { getData6(data5.next, (data6) => { // ... }); }); }); }); }); });
这种情况是非常糟糕的,尤其是在大型的项目中,这会导致项目可维护性和可读性大大下降。
Promise 被引入 JS
在 ES6 中,Promise(期约) 被正式引入 JavaScript。
Promise 是一种用于处理异步操作的 JavaScript 对象,它表示一个在未来某个时间点一定会落定的操作的结果。
注意到这句话中的 “落定” 一词吗?依照 Promise/A+ 的定义,Promise 共有 3 个状态:
- 待定
pending
:这是期约的初始状态,表示操作未完成前的状态; - 兑现
fulfilled
:表示操作成功,也可以称为resolved
; - 拒绝
rejected
:表示操作失败;
其中,fulfilled
和 rejected
都表示操作完成的状态,合称落定 settled
。
使用 Promise
Promise 允许我们通过链式调用 .then()
的方法来确保异步操作执行的先后次序,用 .catch()
来处理异步操作链上发生的错误,并用 .finally()
来处理异步操作链上的收尾操作。
老样子,还是建议去阅读 MDN 文档:Promise - MDN Docs。
以下我只介绍最最常用的一些 API 和操作。
创建 Promise 对象
我们通常使用 Promise 构造函数来创建一个 Promise 对象:
const p = new Promise((resolve, reject) => {
/* 异步操作,以调用 resolve 或 reject 结束 */
});
Promise 构造函数仅有一个函数类型的参数,即 Promise 执行器。在执行器函数中,两个入参分别对应 兑现 和 拒绝 操作。
resolve
和 reject
也是函数,你可以给它们任何实际的名称。它们的函数签名很简单:它们接受一个任意类型的参数。你可以在执行器的函数体中调用 resolve
或 reject
来返回一个值,表示该 Promise 落定的值。如果调用时 value
和 reason
都为空,Promise 将落定为 undefined
。
resolve(value); // 兑现时调用
reject(reason); // 拒绝时调用
Promise 对象究竟是一个什么东西?
让我们看看这个代码片段:
// 创建一个 100ms 后落定为 123 的期约 p const p = new Promise((resolve) => { setTimeout(() => { resolve(123) }, 100); }); console.log(p); // => [object Promise] setTimeout(() => { console.log(p); // => [object Promise] }, 200); p.then(val => { console.log(val); // => 123 }); const pp = await p; console.log(pp); // => 123
这里我们列举了使用 Promise 的四种情况,让我来逐一解释:
- 第一种情况:
p
显然是一个 Promise 对象,因此输出结果为 Promise 对象没有问题;- 第二种情况: 虽然在 后
p
已经落定为值123
,但是如果不使用await
(后文会做介绍),p
并不会直接转化为落定的值,而是需要我们使用.then()
去获取这个值;- 第三种情况:就是如何通过
.then()
去获取落定的值并对其进行操作;- 第四种情况:使用
await
关键字等待p
落定的值,并将其赋予pp
。注意在此时p
仍然为 Promise 对象,而pp
为p
落定的值;可见 Promise 表示的一种未来将会落定为具体值的对象,并不是其所落定的值。其落定的值就是调用
resolve
或reject
时传入的值。
如果你既不调用 resolve
也不调用 reject
,将会导致 Promise 永远处于待定状态。这个时候如果你使用 await
去等待该 Promise 落定,将会导致等待者永远阻塞!
需要注意的是,调用 reject
将会导致 Promise 的 .catch()
方法被调用,如果没有抛出任何一个错误,.catch(err => {})
接受到的 err
参数将为 undefined
。
使用 then 、 catch 和 finally
使用 Promise 的一种方法是使用 .then()
来指定执行顺序,用 .finally()
来收尾:
const todo = new Promise(/* ... */);
todo
.then(() => /* todo */)
.then(() => /* todo */)
.then(() => /* todo */)
.finally(() => /* todo */);
这样的写法称为链式调用,理解起来也非常简单,就是先干什么 然后 (then) 干什么, 最后 (finally) 干什么。
链式调用?
链式调用是一种非常 nice 的函数调用风格,它允许我们通过编写 “函数链” 的形式来对目标执行一系列操作。实现链式调用的最简单方法是在对象方法中返回
this
,确保方法的返回值为对象本身。
上一个 .then()
返回的值会被传递到下一个 .then()
的回调函数,并作为回调的第一个参数。如果 .then()
返回的是一个 Promise,则下一个 .then()
接受到的值将会是这个 Promise 落定后的值。例如:
todo
.then(() => {
return Promise((resolve) => resolve(20));
})
.then(val => {
console.log(val); // => 20
return 30;
})
.then(val => {
console.log(val); // => 30
});
.catch()
通常被用于处理链上抛出的错误,而无论异步操作拒绝或兑现,最终都会调用 .finally()
执行最后操作:
todo
.then(() => /* todo */) // step.1
.then(() => /* todo */) // step.2
.then(() => /* todo */) // step.3
.catch(err => console.log(err)); // step.4
.finally(() => /* todo */); // step.5
假设链上任意一个点抛出了错误或拒绝,函数链将直接跳到 .catch()
的调用。例如:step.1 发生错误或拒绝,函数链将会略过 step.2 和 step.3,直接跳到 step.4 处理错误,最后执行 step.5 做最后的处理。
立即落定
可以通过 Promise 的静态方法 resolve
或 reject
来创建一个立即落定的期约,立即落定一般用于开启 Promise 链式调用。
Promise.resolve() // 立即兑现
.then(/* ... */);
Promise.reject() // 立即拒绝
.then(/* ... */);
全部兑现
如果你需要创建一个 Promise 来等待其他所有 Promise 落定,你可以使用 Promise.all()
:
const allSettle = Promise.all([promise1, promise2, promise3]);
allSettle
将在三个 Promise 全部兑现的时候兑现,如果其中有任意一个拒绝,它也将会拒绝。
任意兑现
只要一个兑现就兑现,全都拒绝就拒绝。
const anySettle = Promise.any([promise1, promise2, promise3]);
分解执行器
Promise.withResolver()
是一个非常新的特性,其在 2024 年刚刚得到各家主流浏览器的广泛支持。这是一个非常好用的方法,可以让你将执行器的两个参数从执行器中抽离。
let { promise, resolve, reject } = Promise.withResolver();
这句代码等同于以下操作:
let resolve, reject;
let promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
稍后我将会在例子中使用该特性。
那么相信你对 Promise 大概有一个印象了,接下来让我们进行更进一步的学习。
用 Promise 解决回调地狱
我们将 所谓回调地狱 一节中会导致回调地狱的代码进行用 Promise 重写:
// 这里假设每个 getData 都返回对应的 Promise
getData1('data1')
.then(async (data1) => await getData2(data1.next))
.then(async (data2) => await getData3(data2.next))
.then(async (data3) => await getData4(data3.next))
.then(async (data4) => await getData5(data4.next))
.then(async (data5) => await getData6(data5.next))
// ...
.catch(err => console.error(err)); // 最后用 .catch() 处理异常
这样一来,你会发现回调地狱神奇地被消除了,变得格外顺眼,其可维护性自然也就变得很好了。
但是你可能会感到迷惑,这个 async
和 await
是个什么玩意儿?await
的字面意思上看起来像是等待?实际上确实如此。
async 与 await
好吧,现在我问你,同步的英文是什么?答案是:synchronization,缩写为 sync。而异步的英文则是在同步英文的前面加上一个 a ,即 asynchronization,简称 async。
使用 async
在 ES2017 后,我们可以通过 async
关键字将一个函数标记为异步函数:
// 普通函数
async function func1() {
/* 可使用 await */
}
// 箭头函数
const func2 = async () => {
/* 可使用 await */
}
异步函数允许我们使用 await
等待 Promise 落定,而这在普通函数中则不被允许。
如果你使用 TypeScript 并试图在一个普通函数中使用 await,编译器会警告你该函数不是一个异步函数。
使用 await
上面提到了 await
,这个关键字允许我们直接以同步调用的形式调用异步函数,并获取其返回值。听起来有点抽象,来看看这段代码:
// 定义一个异步函数
async function fn() {
return Promise.resolve(666);
}
async function main() {
// 不用 await
const a = fn();
console.log(a); // 输出:[object Promise]
// 使用 await
const b = await fn();
console.log(b); // 输出:666
}
main();
可以看到,在不使用 await
关键字的情况下,输出的值为一个 Promise 对象而不是 666。这是因为 Promise 即使是立即返回,它也必须在微任务才能落定(此处关于微任务的概念将在下文讲解,现在只需要知道微任务在普通代码之后执行即可)。
使用 await
关键字即表示你希望程序等待异步函数落定,并获取其落定的结果。这也就解释了为什么 b
的值为 666
。
例子:编写一个延迟器
让我们实现:先输出 Hello, world
,1s 后输出 I'm still-soda!
。
在学习 Promise 前,你也许会这样实现:
console.log('Hello, world');
setTimeout(() => {
console.log(`I'm still-soda!`);
}, 1000);
但是现在你已经学习了 Promise,你可以基于 Promise 封装一个延时函数 delay
,从而实现延时操作。让我们实现它:
function delay(time) {
const { promise, resolve, reject } = Promise.withResolver();
// 在 time 毫秒后兑现期约
setTimeout(() => resolve(), time);
return promise;
}
在此处,我使用了 Promise.withResolver
特性进行编写, 如果你的浏览器不支持 Promise.withResolver
,则需要改写为:
function delay(time) {
let resolve;
const promise = new Promise((res) => resolve = res);
// 在 time 毫秒后兑现期约
setTimeout(() => resolve(), time);
return promise;
}
接下来让我们使用 delay
函数来实现目标操作:
// 使用 await
console.log('Hello, world');
await delay(1000);
console.log(`I'm still-soda!`);
// 不用 await
console.log('Hello, world');
delay(1000).then(() => {
console.log(`I'm still-soda!`);
});
OK,大功告成!你应该已经意识到了使用 await
在某些情况下比使用 .then()
更加优雅,所以在代码编写中,我们可以适当地使用 await
。这会让你的代码变得更加易读。
JavaScript 异步模型
JavaScript 能够实现异步的核心在于其采用了异步模型。JS 异步模型是通过 事件循环(Event Loop) 和 任务队列(Task Queue) 来实现的。
关键组成部分
执行栈
先来聊聊 栈(Stack) 这个数据结构吧。
栈是一种 先进后出(LIFO,Last In First Out) 的数据结构。
你可以把一个栈想象成一张餐桌,栈中每一条数据就是餐桌上的一个盘子。盘子只能通过堆叠的方式存放,那么最先放上去的盘子就会在最底部(即最先进入的数据在栈底),最后放上去的盘子在最上面(即最后进入的数据在栈顶)。
![]()
如果你要取出一个盘子,就只能在顶部拿一个(即每次拿一条数据都是从栈顶取出)。而这个在顶部的盘子就是最后放上去的,所以我们就称该操作方式为后进先出,也就是先进后出。
执行栈是用来存放当前正在执行的同步代码的地方。当 JavaScript 代码执行的时候,函数会被逐一推入执行栈,执行完毕后会从栈顶弹出。
同步代码会逐行执行,只有当前函数执行完毕后,栈才能弹出,继续执行下一个任务。而异步代码并不会被推入执行栈,而是会被推入任务队列中。
任务队列
队列(Queue) 是一种 先进先出(FIFO,First In First Out) 的数据结构。
顾名思义,可以把队列想象成一条正在排队的队伍,每次只能从队头消费,而最先入队的人越靠前,所以我们称为先进先出。
任务队列也称为事件队列,是异步任务等待执行的地方。异步任务进入任务队列后,会在主线程有空的时候被取出执行。
任务队列中的任务分为 微任务(Microtasks) 和 宏任务(Macrotasks) ,宏任务在微任务后执行。
-
微任务包括:
Promise.then
、MutationObserver
; -
宏任务包括:
setTimeout
、setInterval
、网络请求等 I/O 操作;
任务队列中的任务会在同步代码之后被执行,所以我们可以得出同步代码、微任务、宏任务三者的执行顺序为:
同步代码 --> 微任务 --> 宏任务
而按这个顺序不断执行,就是浏览器的事件循环。
事件循环
事件循环是 JavaScript 异步执行的核心机制,它负责从任务队列中取出任务并交给执行栈执行(记住这句话)。现在有以下代码:
console.log('A');
setTimeout(() => {
console.log('B');
}, 0);
Promise.resolve().then(() => {
console.log('C');
});
console.log('D');
猜猜这段代码会输出什么?
答案是:A D C B
在上文我们提到了同步代码和两大任务之间的先后执行顺序,接下来让我们来从 JS 引擎的视角逐步分析这段代码:
首先是同步任务执行阶段:
- 读取
console.log('A')
:这段代码是同步任务,直接执行; - 读取
setTimeout(...)
:在介绍任务队列的时候我们提到setTimeout
属于宏任务,因此被推入任务队列; - 读取
Promise.resolve().then(...)
:Promise.then
属于微任务,因此被推入任务队列; - 读取
console.log('D')
:这段代码是同步任务,直接执行;
然后进入任务队列执行阶段:
- 执行微任务:
- 读取到
Promise.resolve().then(...)
:开始执行任务。- 读取到
console.log('B')
:这段代码是同步任务,直接执行;
- 读取到
- 读取到
- 执行宏任务:
- 读取到
setTimeout(...)
:开始执行任务- 读取到
console.log('C')
:这段代码是同步任务,直接执行;
- 读取到
- 读取到
接着再经过渲染循环等,一轮事件循环就完成了。
微任务的优先级比宏任务高
这是 JavaScript 事件循环中非常重要的一点。微任务(如 Promise)具有更高的优先级。即使事件循环已经执行了一个宏任务,它会先去执行微任务队列中的任务,直到微任务队列清空为止。因此,在宏任务执行后,事件循环会去处理微任务,而不是直接执行下一个宏任务。
![]()
也就是说,只要微任务队列中还存在未执行的微任务,就不可能去执行宏任务。
实现 async 和 await
在上面我们介绍了 async
和 await
,它们是 ES2017 中新增的用于简化 Promise 操作的语法糖。那么既然是语法糖,我们就一定可以用原生的操作实现它。
实际上这个语法糖是基于 生成器(Generator) 实现的。
什么是生成器?
生成器是 ES6 中新增的一个特性,它允许你定义一个可以暂停执行并在稍后恢复的函数。我们可以通过
function*
来定义一个生成器函数,调用这个函数将会返回一个生成器对象,而这个生成器对象满足迭代器协议。在生成器函数中,我们可以使用
yield
来从当前位置返回一个值,而当下一次调用的时候,程序会从上次返回的地方继续执行,直到遇到下一个yield
或return
。需要注意的是,生成器函数中的return
表示终止生成。举个例子:// 定义生成器函数 function* getGenerator(n) { yield n; yield n + 10; yield n + 20; } // 创建生成器对象 const gen = getGenerator(3); // 通过 .next() 调用生成器 console.log(gen.next()); // => { value: 3, done: false } console.log(gen.next()); // => { value: 13, done: false } console.log(gen.next()); // => { value: 23, done: false } console.log(gen.next()); // => { value: undefined, done: true }
可以看到每次调用生成器对象的
.next()
方法,都会返回一个包含done
和value
的对象,对应的含义为:
done
:迭代是否已经完成;value
:yield
的返回结果,当done
为true
的时候其值为undefined
;
实现思路的其实很简单,说白了就是基于生成器去按顺序地等待 Promise 兑现:
- 用
function asyncFn(fn)
代替async function
,其参数fn
为一个生成器函数,返回值为一个返回 Promise 的函数; fn
中用yield
代替await
,yield
每次暂停返回一个 Promise;- 创建一个函数并返回,这个函数需要完成以下操作:
- 创建一个 Promise,并分离出它的
resolve
方法; - 创建一个生成器;
- 定义一个
waitNext
函数,这个函数用于从生成器中取出一个 Promise 进行等待,并在兑现后执行递归调用自己。这个函数带有一个参数value
,表示上一轮 Promise 兑现的结果,当迭代结束的时候,主函数将被兑现为该值。 - 调用
waitNext
函数; - 返回先前创建的 Promise;
- 创建一个 Promise,并分离出它的
接下来让我们用代码实现它:
function asyncFn(fn) {
return () => {
let resolve, reject;
const promise = new Promise((res, rej) => {
[resolve, reject] = [res, rej];
});
const gen = fn();
const waitNext = (value) => {
const { value: promise, done } = gen.next();
if (done) {
resolve(value);
return;
}
promise
.then((result) => waitNext(result))
.catch((err) => reject(err));
}
waitNext(undefined);
return promise;
}
}
这样,我们就实现了 async/await
语法糖。
对于以下使用 async
和 await
的代码片段:
const getData = async () => {
console.log('A');
await delay(1000);
console.log('B');
await delay(1000);
console.log('C');
}
getData().then(() => console.log('D'));
我们可以写成这种形式:
const getData = asyncFn(function* () {
console.log('A');
yield delay(1000);
console.log('B');
yield delay(1000);
console.log('C');
});
getData().then(() => console.log('D'));
效果完全一致!至此,我们完成了这个语法糖的编写。
结语
异步编程是现代 JavaScript 开发中不可或缺的一部分,它让我们的应用能够更加高效地处理多个任务,提升用户体验。从 Promise 的引入到 async/await 的语法糖,JavaScript 不断进化,为我们提供了更加强大和简洁的异步编程工具。
希望本文能够帮助你深入理解 JavaScript 的异步编程,让你在开发中更加得心应手。无论是处理复杂的异步流程,还是优化应用性能,掌握异步编程都将是你的重要技能之一。