Async / Await:用同步的方式写异步,真香!

async/await 是 ES2017 引入的强大特性,使得处理异步操作变得更加简单和直观。使用 async 声明的函数总是返回一个 Promise,而 await 关键字则像是它的指挥棒,允许你暂停函数的执行,直到 Promise 被解决或拒绝。这种方式让异步代码看起来更像同步代码,从而提高了可读性和可维护性。尤其在处理链式异步操作和错误捕获时,async/await 显示出其独特的优势。

前置知识

如果对生成器函数和 yield 不太熟悉,可以先看看下面这篇,了解基础后再看 Async / Await 会更顺~

一、Async / Await 的本质:老熟人的默契配合

你可能觉得 Async / Await 是 JavaScript 里的 “新黑科技”,但其实它的底层是三个老熟人在搭班子干活。说穿了,就是把咱们早就眼熟的技术组合得更顺手了。

1.1 三大核心组件

  • Generator(生成器):提供暂停 - 恢复的能力,就像给函数装了个暂停键
  • Promise:处理异步操作的 “标准接口”,负责管理异步结果
  • 自动执行器:默默工作的调度员,悄悄驱动生成器跑完全程

1.2 从代码看转换逻辑

咱们写的 Async 函数,其实会被引擎偷偷转换成类似生成器的结构。比如这样一段代码:

1
2
3
4
5
// 咱们写的优雅代码
async function fetchData() {
const data = await api.get('/data');
return process(data);
}

引擎背地里会把它转成差不多这样(简化版):

1
2
3
4
5
6
7
8
// 引擎实际处理的样子
function fetchData() {
// spawn就是那个自动执行器,负责驱动生成器
return spawn(function* () {
const data = yield api.get('/data'); // 用yield代替await
return process(data);
});
}

这里有个小细节: 每个 Async 函数都被转换成一个生成器函数,由自动执行器接管执行!

咱们不用手动调用next(),全是执行器在后台搞定,这也是Async/Await比直接用Generator方便的地方~

二、自动执行器:幕后的引擎

自动执行器是 Async / Await 能自动跑起来的关键,我试着简化了它的核心代码,大概长这样:

2.1 核心逻辑(简化版)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
function spawn(generatorFunc) {
// 返回一个Promise,这也是async函数总返回Promise的原因
return new Promise((resolve, reject) => {
const generator = generatorFunc(); // 创建生成器实例

// 步进函数:驱动生成器一步步执行
function step(nextFn) {
try {
const { value, done } = nextFn(); // 执行到下一个yield

if (done) {
// 生成器跑完了,把结果传给Promise
return resolve(value);
}

// 把yield后的结果包装成Promise(不管是不是Promise)
Promise.resolve(value).then(
// 成功了就把结果传给下一次next(),继续执行
(v) => step(() => generator.next(v)),
// 失败了就把错误抛回生成器,让try/catch接住
(e) => step(() => generator.throw(e)),
);
} catch (e) {
// 捕获生成器内部的错误
reject(e);
}
}

// 启动生成器
step(() => generator.next());
});
}

2.2 执行过程图解

我画了个流程图帮大家理解,其实就是执行器在中间当 “裁判”,协调生成器和异步操作:

简单说就是:执行器启动生成器后,每次遇到 yield(也就是咱们写的 await)就停下来等异步结果,拿到结果再叫醒生成器继续跑,直到结束。全程不用手动干预,比直接用 Generator 省太多事了~

三、await 到底做了什么?四步看懂它的 “小动作”

每次写await的时候,引擎其实在背后干了四件事,我拆开来给大家说说:

  1. 暂停当前函数:就像按了暂停键,当前的变量、执行位置都被 “冻” 起来
  2. 包装异步结果:不管 await 后面是 Promise 还是普通值(比如 await 42),都会被转成 Promise。普通值会被 Promise.resolve() 包一层,确保统一用 Promise 处理
  3. 注册回调:把 await 后面的代码(比如拿到 data 后处理的逻辑)打包成一个微任务,注册到事件循环里
  4. 让出主线程:当前函数暂停后,主线程会去执行其他任务(比如渲染、处理其他事件),等 Promise 有结果了,再回头执行刚才打包的微任务

这里有个性能小细节: await不会阻塞主线程!它只是把后续代码挂起(包装成微任务),让主线程先忙别的。这也是为什么用await的时候,页面不会卡 —— 因为它会主动 “让道”。

四、错误处理:try / catch 居然能管到异步操作?

这是我觉得 Async/Await 最方便的一点:用同步代码里的 try/catch 就能搞定异步错误,不用像回调那样嵌套多层 error 处理。

4.1 同步式的错误处理

1
2
3
4
5
6
7
8
9
10
11
async function fetchUser() {
try {
const user = await fetch('/user'); // 可能失败
const posts = await fetch(`/posts/${user.id}`); // 依赖上一步结果,也可能失败
return { user, posts };
} catch (error) {
// 不管哪一步失败,都会跑到这里
console.error('请求失败:', error);
return { user: null, posts: [] };
}
}

4.2 背后的错误传递

为什么 try / catch 能抓到异步错误?秘密在自动执行器的这段代码里:

1
2
3
4
5
// 自动执行器处理Promise的部分
Promise.resolve(value).then(
(v) => step(() => generator.next(v)), // 成功就传结果继续执行
(e) => step(() => generator.throw(e)), // 失败就把错误抛回生成器!
);

当 await 后面的 Promise 失败时,执行器会调用generator.throw(e),把错误 “扔回” 生成器函数内部。这时候生成器里的 try / catch 就会像捕获同步错误一样,把这个异步错误接住。

之前用回调的时候,每次异步操作都要单独写 error 处理,现在一个 try / catch 全搞定,代码清爽多了~

五、性能优化:我在项目里掉过的性能坑

分享几个我实际开发中遇到的问题,都是关于 Async / Await 性能的,新手很容易踩坑:

5.1 坑一:没必要的顺序执行

1
2
3
4
5
6
// 反面例子:两个请求本来可以同时跑,却写成了顺序执行
async function slowFetch() {
const a = await fetchA();
const b = await fetchB(); // 等A完了才开始B
return [a, b];
}

这两个请求如果没依赖关系(比如 A 不影响 B 的参数),完全可以同时启动,我后来改成这样:

1
2
3
4
5
6
7
8
9
10
11
12
// 优化后:同时启动两个请求,总耗时是最慢那个的时间
async function fastFetch() {
// 先同时发起请求,拿到两个Promise
const promiseA = fetchA();
const promiseB = fetchB();

// 再等它们结果
const a = await promiseA;
const b = await promiseB;

return [a, b];
}

5.2 坑二:多余的 await 包装

有时候会下意识地在 return 前面加 await,但其实没必要:

1
2
3
4
// 多余的await
async function getData() {
return await fetchData(); // fetchData本身已经返回Promise
}

因为 Async 函数会自动把返回值包成 Promise,这里的 await 纯属多此一举,直接 return 就行:

1
2
3
4
// 更简洁高效
async function getData() {
return fetchData(); // 等价于上面的写法,但少一层Promise包装
}

5.3 多个异步操作:用 Promise.all() 处理并行

如果需要等多个异步操作都完成,Promise.all()配合 await 是绝配,下面的写法在实际项目中会经常用到哦~

1
2
3
4
5
6
async function fetchAll() {
// 同时启动,等所有请求完成
const [user, posts] = await Promise.all([fetch('/user'), fetch('/posts')]);

return { user, posts };
}

不过要注意: Promise.all()是 “一损俱损”,只要有一个请求失败,整个就会报错,这时候可以用Promise.allSettled()处理需要全部结果的场景(哪怕部分失败)。

5.4 高级技巧:模块里的顶级 await

现在很多打包工具(比如 Webpack、Vite)已经支持模块顶层的 await 了,不用再包在 Async 函数里:

1
2
3
4
5
6
7
8
// 直接在模块顶层用await
const config = await loadConfig(); // 加载配置
export const settings = process(config);

// 其实引擎会把模块转成类似这样
loadConfig().then((config) => {
export const settings = process(config);
});

我在做一个工具库的时候用过这个,用来加载动态配置,比以前用 IIFE(立即执行函数)清爽多了。

六、Async / Await 实战场景:这些地方用起来超顺手

6.1 异步初始化(比如数据库连接)

1
2
3
4
5
6
7
8
9
10
class Database {
// 静态方法做异步初始化
static async init() {
const connection = await createConnection(); // 建立连接(异步)
return new Database(connection);
}
}

// 使用的时候直接await
const db = await Database.init();

这种场景如果用回调,很容易写成嵌套的 init 回调,用 Async / Await 就清晰多了。

6.2 有依赖关系的顺序请求

比如先拿用户 ID,再用 ID 查订单:

1
2
3
4
5
6
7
async function purchase(itemId) {
const user = await getUser(); // 先查用户
const item = await getItem(itemId); // 再查商品
await validatePurchase(user, item); // 验证能否购买(异步)
const receipt = await createReceipt(user, item); // 生成订单
return receipt;
}

步骤再复杂,用顺序 await 写出来也像同步代码一样好懂。

6.3 带重试的请求

处理可能偶尔失败的接口时,用 Async / Await 写重试逻辑很直观:

1
2
3
4
5
6
7
8
9
async function fetchWithRetry(url, retries = 3) {
try {
return await fetch(url);
} catch (error) {
if (retries <= 0) throw error; // 重试次数用完
await delay(1000); // 等1秒再重试
return fetchWithRetry(url, retries - 1); // 递归重试
}
}

我在对接一个不稳定的第三方接口时,就用这个逻辑做了重试,成功率提高了不少。

6.4 超时控制(防止请求卡太久)

结合Promise.race()实现超时控制:

1
2
3
4
5
6
7
8
9
10
async function fetchWithTimeout(url, timeout = 5000) {
const fetchPromise = fetch(url);
// 超时Promise
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('请求超时')), timeout),
);

// 谁先完成就用谁的结果
return await Promise.race([fetchPromise, timeoutPromise]);
}

这个在做支付回调的时候特别有用,防止因为网络问题让用户一直等。

七、踩过的坑:这些细节要注意

7.1 箭头函数的 this 陷阱

用 Async 箭头函数当对象方法时,this会丢:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 有问题:this指向不对
const obj = {
value: 42,
print: async () => {
console.log(this.value); // undefined! 因为箭头函数的this是定义时的上下文
},
};

// 正确写法:用传统函数
const obj = {
value: 42,
async print() {
console.log(this.value); // 42,this指向obj
},
};

我在写一个类的方法时犯过这个错,调试了半天才发现是箭头函数的锅。

7.2 控制并发数量

如果并行请求太多(比如一次发 20 个接口),可能会触发浏览器的并发限制,这时候需要控制并发数:

1
2
3
4
5
6
7
async function processBatch(items) {
const promises = items.map(processItem); // 所有任务

// 控制最多同时跑5个
const results = await throttlePromises(promises, 5);
return results;
}

这里的throttlePromises是一个工具函数,原理是把任务分成多批,一批批执行(每批 5 个),避免一次性发起太多请求。

7.3 可取消的异步任务(结合 AbortSignal)

有时候需要中途取消异步操作(比如用户离开页面),可以用AbortSignal

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
async function longRunningTask(abortSignal) {
while (!abortSignal.aborted) {
await doWork();// 每次做一点工作

// 每次循环检查退出信号
if (abortSignal.aborted) {
await cleanup(); // 做清理工作
return;
}
}
}

const controller = new AbortController();
const signal = controller.signal;

// 启动长时间运行的任务
longRunningTask(signal);

function cancelTask {
controller.abort(); // 中止任务
}

// 设置一个定时器,以便在 3 秒后中止任务
setTimeout(() => {
cancelTask();
}, 3000);

我在做一个文件上传组件时用过这个,用户点取消按钮时,就通过AbortSignal终止上传。

八、常见问题解答(我当初学的时候也纠结过)

Q1: Async 函数会阻塞主线程吗?

不会! Async 函数遇到 await 时会暂停并释放主线程,JavaScript 的单线程模型通过事件循环实现异步执行。

1
2
3
4
5
async function test() {
console.log('开始');
await delay(1000); // 假设delay是个等待1秒的Promise
console.log('结束');
}

执行到 await 时,函数会暂停,主线程可以去处理其他任务(比如点击事件、渲染),等 1 秒后才回头执行 console.log(‘结束’),所以不会卡页面。

Q2: 可以 await 一个非 Promise 值吗?

可以! 引擎会自动用 Promise.resolve() 包装非 Promise 值:

1
2
3
4
async function getNumber() {
const num = await 42; // 合法!等价于 await Promise.resolve(42)
return num;
}

不过实际开发中很少这么用,一般 await 后面都是异步操作返回的 Promise。

Q3: 为什么 Async 函数不管 return 什么,最后都返回 Promise?

因为它本质是异步操作的包装器。哪怕你 return 一个原始值,引擎也会用 Promise.resolve() 包一层:

1
2
3
async function answer() {
return 42; // 等价于 return Promise.resolve(42)
}

所以调用 Async 函数时,必须用await或者.then()才能拿到结果。

Q4: 用 Promise.all() 的时候,如果有一个请求失败怎么办?

之前已经提到过了,Promise.all() 会 “快速失败”—— 只要有一个 Promise 被拒绝,整个 Promise.all() 就会立刻失败,进入 catch。如果需要等所有请求完成(不管成功失败),可以用Promise.allSettled()

1
2
3
4
5
6
async function fetchAll() {
const results = await Promise.allSettled([fetchA(), fetchB()]);

// 过滤出成功的结果
const successData = results.filter((r) => r.status === 'fulfilled').map((r) => r.value);
}

Q5: 为什么说 Async / Await 比回调好?

我总结了几个实际开发中的感受:

  • 代码不嵌套,扁平结构更易读(告别 “回调地狱”)
  • 错误处理统一用 try / catch,不用每层回调都写 error 处理
  • 逻辑顺序和代码执行顺序一致,不用跳来跳去看代码
  • 调试更方便,错误堆栈更完整(回调的堆栈经常被异步操作打断)

其实 Async/Await 不算什么高深的技术,就是把 Generator、Promise 这些老东西包装得更好用了。但正是这种 “语法糖”,让我们写异步代码时能少掉很多头发~ 如果你也有过用 Async/Await 踩坑的经历,欢迎在评论区分享呀!