在现代 Web 开发中,异步编程是不可或缺的一部分。本文将结合 Promises 和 async/await 的概念,帮助您深入理解 JavaScript 的异步控制流。

什么是异步编程?

在 JavaScript 中,单线程模型限制了它一次只能执行一项任务。如果一个任务需要很长时间才能完成,比如从服务器获取数据,那么这会阻塞其他任务的运行。为了解决这个问题,JavaScript 提供了异步机制,使得可以在等待长时间任务完成的同时继续执行其他代码。

image-20241130164746004

在异步程序中,Promises可以把我们从浪费时间中解脱,让我们运行其他代码

Promises 的基础概念

Promise 是什么?

Promise 是一种用于管理异步操作的对象。它的状态有三种:

  1. Pending(进行中):初始状态,操作尚未完成。
  2. Fulfilled(已完成):操作成功完成,并返回一个值。
  3. Rejected(已拒绝):操作失败,并返回一个原因。

通过 Promise,我们可以更优雅地处理异步任务,避免回调函数层层嵌套的问题(即”回调地狱”)。

使用 Promise

使用 .then.catch

Promise 提供了 .then.catch 方法来处理成功或失败的结果:

1
2
3
4
5
6
7
myPromise
.then(result => {
console.log(result); // 输出 "操作成功"
})
.catch(error => {
console.error(error);
});
  • .then
    • 当promise是已完成状态,调用.then后的回调函数
  • .catch
    • 当promise是已拒绝状态,调用.catch后的回调函数

Promises链

image-20241130164926242

.then返回一个promise所以我们可以继续不断地.then

使用多层promises

image-20241130165044501

image-20241130165052200

Promise 的控制流

Promise 提供了一些工具来处理多个异步操作:

Promise.all

Promise.all 接受一个 Promise 数组,只有当所有 Promise 都 Fulfilled 时,它才会返回一个包含所有结果的数组。如果有一个 Promise Rejected,它会立即返回错误:

1
2
3
4
5
6
7
8
9
10
11
const promise1 = Promise.resolve(10);
const promise2 = Promise.resolve(20);
const promise3 = Promise.resolve(30);

Promise.all([promise1, promise2, promise3])
.then(results => {
console.log(results); // 输出 [10, 20, 30]
})
.catch(error => {
console.error(error);
});

Promise.race

Promise.race 也是接受一个 Promise 数组,但它会返回第一个改变状态的 Promise 的结果:

1
2
3
4
5
6
7
const promise1 = new Promise(resolve => setTimeout(() => resolve('快'), 100));
const promise2 = new Promise(resolve => setTimeout(() => resolve('慢'), 200));

Promise.race([promise1, promise2])
.then(result => {
console.log(result); // 输出 "快"
});

Promise.any

Promise.any 返回第一个 Fulfilled 的 Promise。如果所有 Promise 都 Rejected,它会抛出一个 AggregateError:

1
2
3
4
5
6
7
8
9
10
11
const promise1 = Promise.reject('失败1');
const promise2 = Promise.resolve('成功');
const promise3 = Promise.reject('失败2');

Promise.any([promise1, promise2, promise3])
.then(result => {
console.log(result); // 输出 "成功"
})
.catch(error => {
console.error(error);
});

Async/Await 简化异步代码

虽然 Promises 很强大,但当我们需要处理复杂的异步流程时,链式调用可能会变得难以维护。为了解决这个问题,ES2017 引入了 async/await,让异步代码看起来像同步代码。

异步的方法

image-20241130165154731

计算完成前将控制返回给调用者的函数

同步控制流

image-20241130165220592

其中所有操作按顺序一个接一个地执行。handleClick只有在前面两个操作都完成的情况下才会执行

异步控制流

image-20241130165233751

image-20241130165449502

在这段代码中,handle click 可以get之前运行,或者在我们get的时候运行,或者是在setStories。这意味着程序的执行不需要等到一个任务完全完成才能开始下一个任务。

handle click 不需要等待获取故事或设置故事的操作完成,而是可以在这两个操作中的任何一个任务正在等待时立即执行。这种做法避免了等待,从而加快了整体执行速度。

基本用法

使用 await

await 用于等待一个 Promise 完成并返回其结果:

1
2
3
4
const a = slowNumber(9);
const b = slowNumber(10);

console.log(await a + await b)

如果a需要花费3s完成,b需要5s, 那么需要8s才会成功打印

值得注意的是,我们程序的最外层不是一个异步函数,因此它不能使用 await。然而,在程序结束前,它会等待解决所有的 Promise,然后再退出。

image-20241130170849898

这里的“最外层”指的是没有包含在任何异步函数中的代码,例如你在顶层代码中直接调用的部分。在这种情况下,main() 是一个异步函数,它内部使用了 await,但是如果你把 await 放在 main() 函数外面(即在最外层的代码中),你就不能直接使用 await,因为最外层的代码是同步的。

如果你尝试在最外层直接使用 await,你会遇到错误,因为最外层的代码通常是同步执行的,而 await 只能在异步函数内使用。你可以通过将最外层的代码包装在一个异步函数中来避免这种问题

定义一个 async 函数

async 函数会自动返回一个 Promise:

1
2
3
4
const myFunction = async () => {
console.log(await a + await b);
};
myFunction();

image-20241130170613655

异步函数

  • 在计算完成之前将控制权返回给调用者的函数

  • 可以作为回调函数 () => {} 实现

  • 或者使用 async 关键字

    • 适用于函数、箭头函数、类方法等
1
2
3
4
async function slowNumber(x) {
sleep(1000);
return x;
}

asyncawait 可以用来重写我们使用 Promises 时的任何操作。asyncawait 正变得比传统的 .then() Promises 写法更常见。

然而,在 web.lab 工作坊中,你写的大部分代码仍然会使用 .then() 写法。

image-20241130171206329

捕获错误

可以使用 try/catch 块来捕获异步操作的错误:

1
2
3
4
5
6
7
8
async function processData() {
try {
const data = await fetch('/api/data');
console.log(data);
} catch (error) {
console.error('获取数据失败', error);
}
}

串行与并行

串行执行

使用 await 时,任务默认是串行执行的:

1
2
3
4
5
async function fetchAll() {
const result1 = await fetch('/api/1');
const result2 = await fetch('/api/2');
console.log(result1, result2);
}

并行执行

为了提升性能,可以用 Promise.all 并行执行:

1
2
3
4
5
6
7
async function fetchAll() {
const [result1, result2] = await Promise.all([
fetch('/api/1'),
fetch('/api/2')
]);
console.log(result1, result2);
}

为什么需要异步

从 JavaScript 的 事件循环(Event Loop) 角度来看,异步编程是为了避免阻塞主线程,确保程序能够高效地执行。

JavaScript 的单线程模型

JavaScript 是单线程的,这意味着它在任何给定时刻只能执行一个任务。如果某个任务需要花费大量时间,比如读取文件、网络请求、定时器等,主线程就会被阻塞,直到任务完成。这种阻塞的执行方式会导致界面卡顿,用户体验差。

image-20241130171448354

事件循环和异步

为了克服这个问题,JavaScript 使用了 事件循环(Event Loop)异步回调 来处理长时间运行的操作。

  1. 调用栈(Call Stack):JavaScript 在执行代码时,会将函数调用压入调用栈中。栈是按顺序执行的,当前栈顶的函数会执行完成后,才会执行栈中的下一个函数。
  2. 任务队列(Task Queue):当有异步操作(如 setTimeout、网络请求、文件读取等)时,这些操作会先交给浏览器(或 Node.js)处理,处理完后,它们的回调函数会被放入任务队列中。
  3. 事件循环(Event Loop):事件循环的任务是不断检查调用栈是否为空。如果调用栈为空,事件循环会从任务队列中取出一个回调函数,将其推入调用栈中执行。这就是为什么异步操作能够在不阻塞主线程的情况下执行的原因。

image-20241130171435526

为什么需要异步?

  1. 非阻塞性:异步操作不会阻塞主线程。例如,执行一个 setTimeout 或发送一个 AJAX 请求时,JavaScript 不会等它完成后才继续执行其他代码。相反,JavaScript 会把这些操作交给浏览器去处理,同时继续执行后面的代码。当这些操作完成后,回调函数会被放入任务队列,等待事件循环把它们推入调用栈中执行。
  2. 提高性能和用户体验:异步操作可以让 JavaScript 处理更多的任务而不被长时间的操作所阻塞。例如,如果我们在做一个 AJAX 请求来获取数据,使用异步操作可以让我们在等待数据返回的过程中继续响应用户输入,而不会让界面冻结。
  3. 解决回调地狱:使用异步编程时,我们常常使用回调函数来处理异步任务(例如 .then()callback)。但是,当异步操作嵌套过多时,代码会变得非常难以阅读和维护。async/await 就是为了解决这个问题,让异步代码更像同步代码,易于理解和调试。

示例

1
2
3
4
5
6
7
8
console.log("Start");

// 异步操作
setTimeout(() => {
console.log("Async Task");
}, 1000);

console.log("End");

在这段代码中:

  • console.log("Start")console.log("End") 会立刻打印。
  • setTimeout 是一个异步操作,虽然它有一个 1 秒的延时,但不会阻塞主线程。它会把回调函数 console.log("Async Task") 放入任务队列中,等主线程空闲后执行。

输出结果是:

1
2
3
Start
End
Async Task

什么时候需要异步

image-20241130171519979

后台任务
你可以运行后台任务,而不会阻止用户与前端交互。

例如:

  • 获取数据(例如加载 Facebook 上的新帖子)
  • 下载/上传
  • 播放音乐或视频(在 YouTube 或 Spotify 上播放音乐或视频时,仍然可以点击其他内容)
  • 在服务器上运行一些大规模计算
  • 以及更多!