让 JavaScript “睡”一会儿
在许多编程语言中,比如 Python,我们可以用 time.sleep(3)
轻松地让程序暂停 3 秒。但在 JavaScript 中,这事儿没那么简单。如果我们用一个“忙等待”循环来阻塞主线程,整个浏览器页面都会卡死,这是绝对不可接受的。
我们的目标是实现一个非阻塞的 sleep
函数,它能“暂停”一段代码的执行,但不会冻结整个程序。
来看我们的最终实现代码,这也是我们今天探讨的核心:
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* 主业务逻辑:一个异步函数
*/
async function run() {
console.log('B: run 函数开始执行,准备服务第 1 位客人。');
let i = 0;
while (i < 5) {
i += 1;
console.log(`C: [循环 ${i}] 点餐开始。把订单交给厨师,并告诉他做好后通知我。`);
// await 会暂停 run 函数,但 JS 引擎会离开这里去执行别的同步代码
await sleep(3000);
// 3秒后,JS 引擎会回到这里继续执行
console.log(`E: [循环 ${i}] 菜做好了!服务员回来,把菜端给客人。`);
}
console.log(`F: 全部 5 位客人都服务完毕,run 函数彻底执行结束。`);
}
// --- 脚本主线(全局作用域)---
console.log('A: 服务员(JS主线程)开始一天的工作。');
run();
console.log('D: 服务员已将第1位客人的订单交给厨房,现在他立刻回来继续处理主线任务,而不是傻等。主线任务处理完毕。');
初次运行这段代码,其输出顺序可能会让初学者感到困惑:
A -> B -> C (循环1) -> D -> (等待3秒) -> E (循环1) -> C (循环2) -> (等待3秒) -> E (循环2) -> C (循环3) -> (等待3秒) -> E (循环3) ... -> F
为什么 D
会插队到 E
之前?run()
函数难道不是应该执行完才轮到后面的代码吗?要理解这一切,我们需要引入 JavaScript 的核心运行模型。
JS是单线程的,类似服务器餐厅
想象 JavaScript 的世界是一个高效运转的单线程餐厅:
- 服务员 (JavaScript 主线程 / Call Stack): 餐厅里只有一位服务员。他速度极快,但一次只能处理一件事。他手里的“点餐板”就是调用栈 (Call Stack),记录着当前正在执行的任务。
- 后厨 (Web APIs): 一个独立的部门,有很多厨师。他们专门处理耗时的任务,比如网络请求、文件读写,以及我们的主角——
setTimeout
定时器。后厨的工作不占用服务员的时间。 - 出餐口 (Task Queue / Callback Queue): 后厨做好的菜(异步任务完成后的回调函数)会放在这里,等待服务员来取。
- 服务员的工作法则 (Event Loop):
- 清空手头工作:服务员会优先处理完“点餐板”上的所有同步任务。
- 检查出餐口:当“点餐板”空了,服务员就会去看一眼“出餐口”。
- 上新菜:如果出餐口有菜,就取一道菜(一个回调任务),放到自己的“点餐板”上开始服务。
- 循环往复:服务员永不休息,持续重复第 2 和第 3 步。这就是著名的事件循环 (Event Loop)。
async/await
的基石:理解 Promise
上面示例代码中有 async
和await
关键字,这是什么呢,为什么要使用它?
在我们深入 async/await
的魔法之前,必须先了解它的基石——Promise
。async/await
只是一种让 Promise
用起来更舒服的“语法糖”。
1. Promise 是什么?—— 一个对未来的承诺
想象一下,你去快餐店点餐,服务员不会让你在柜台前干等汉堡做好。他会给你一张取餐小票,并说:“我承诺 (Promise),汉堡做好后,凭这张小票来取。”
这张小票就是 Promise
。它代表一个未来才会出结果的异步操作。它有三种状态:
- Pending (进行中): 你刚拿到小票,汉堡还在做。这是初始状态。
- Fulfilled (已成功): 汉堡做好了!你可以凭小票取餐。这个承诺被兑现了。
- Rejected (已失败): 厨房发现没面包了,做不了汉堡。这个承诺被拒绝了。
2. 如何使用 Promise?—— .then()
和 .catch()
拿到小票后,你不会傻站着,而是可以先去玩手机。但你需要知道接下来该做什么:
.then(onFulfilled)
: 你告诉自己,“然后 (then),如果汉堡做好了 (fulfilled),我就去取餐吃掉。”.catch(onRejected)
: 你也做好了最坏的打算,“万一 (catch) 他们告诉我做不了 (rejected),我就去投诉。”
在代码中,这看起来像这样:
// 这是一个模拟“做汉堡”的异步操作,它返回一个 Promise
function makeBurger() {
return new Promise((resolve, reject) => {
console.log('后厨开始做汉堡...');
// 模拟耗时2秒
setTimeout(() => {
if (Math.random() > 0.2) { // 80%的成功率
resolve('香喷喷的汉堡'); // 成功!调用 resolve
} else {
reject('没有面包了!'); // 失败!调用 reject
}
}, 2000);
});
}
// 点餐,并处理后续
makeBurger()
.then(burger => {
console.log('成功拿到:', burger);
})
.catch(error => {
console.error('出错了:', error);
});
console.log('我拿到了取餐小票,先去玩手机了~');
运行这段代码,你会发现 "我拿到了取餐小票..."
会立即打印,2秒后才会打印汉堡的结果。这就是异步!
3. Promise
与 sleep
函数的关系
现在再看我们的 sleep
函数,就豁然开朗了:
function sleep(ms) {
// 1. new Promise: 返回一张“承诺小票”
return new Promise(resolve => {
// 2. setTimeout: 把一个异步任务交给后厨(Web APIs)
// 任务是:ms 毫秒后,调用 resolve()
setTimeout(resolve, ms);
});
}
sleep(3000)
就是在承诺:“我保证在 3000 毫秒后,这个承诺会变为 fulfilled
状态。” 它没有失败(reject
)的情况,是一个只会成功的简单承诺。
4. 从 .then()
到 await
的进化
使用 .then()
链式调用来处理多个异步任务,当逻辑复杂时,容易形成“回调地狱”,代码可读性变差。
// 回调地狱风格
sleep(1000)
.then(() => {
console.log('过了1秒');
return sleep(1000); // 返回新的Promise
})
.then(() => {
console.log('又过了1秒');
return sleep(1000);
})
.then(() => {
console.log('总共过了3秒');
});
async/await
横空出世,就是为了解决这个问题。await
就像一个神奇的按钮,它能帮你自动“暂停”并“等待”那个 Promise
小票兑现,然后自动执行下一行代码,让异步流程看起来像同步一样清晰:
async function doSomething() {
await sleep(1000);
console.log('过了1秒');
await sleep(1000);
console.log('又过了1秒');
await sleep(1000);
console.log('总共过了3秒');
}
Promise
是 JavaScript 处理异步操作的核心对象,它代表一个承诺。async/await
是一种控制 Promise 流程的优雅语法。现在,带着对Promise
的深刻理解,我们再去看之前的“餐厅服务员模型”,一切就都顺理成章了。await
正是在等待那张Promise
小票从“进行中”变为“已成功”。
代码执行全景透视:一步步追踪服务员的足迹
现在,让我们跟着服务员,一步步走完代码的执行流程。
async function run() {
console.log('B: run 函数开始执行,准备服务第 1 位客人。');
let i = 0;
while (i < 5) {
i += 1;
console.log(`C: [循环 ${i}] 点餐开始。把订单交给厨师,并告诉他做好后通知我。`);
// await 会暂停 run 函数,但 JS 引擎会离开这里去执行别的同步代码
await sleep(3000);
// 3秒后,JS 引擎会回到这里继续执行
console.log(`E: [循环 ${i}] 菜做好了!服务员回来,把菜端给客人。`);
}
console.log(`F: 全部 5 位客人都服务完毕,run 函数彻底执行结束。`);
}
// --- 脚本主线(全局作用域)---
console.log('A: 服务员(JS主线程)开始一天的工作。');
run();
console.log('D: 服务员已将第1位客人的订单交给厨房,现在他立刻回来继续处理主线任务,而不是傻等。主线任务处理完毕。');
阶段一:主线任务的快速处理 (T ≈ 0 秒)
console.log('A: ...')
:- 服务员接到第一个任务:打印日志
A
。这是同步任务,他立即完成。 - 控制台输出:
A: 服务员(JS主线程)开始一天的工作。
- 服务员接到第一个任务:打印日志
run()
:- 服务员接到第二个任务:执行
run
函数。他立刻走进run
函数内部。
- 服务员接到第二个任务:执行
console.log('B: ...')
:- 在
run
函数内部,服务员执行第一行代码,打印日志B
。 - 控制台输出:
B: run 函数开始执行,准备服务第 1 位客人。
- 在
while
循环 (第 1 次) &console.log('C: ...')
:- 服务员进入循环,打印日志
C
。 - 控制台输出:
C: [循环 1] 点餐开始。把订单交给厨师...
- 服务员进入循环,打印日志
await sleep(3000)
- 关键转折点!:- 服务员遇到了
await
。他做了两件事: a. 执行sleep(3000)
:这相当于把一张“3秒后提醒我”的订单交给了后厨 (Web APIs)。后厨的计时器开始计时,这不关服务员的事了。 b.await
关键字对服务员说:“run
函数这个任务先暂停,你可以走了!” - 结果:服务员将
run
函数“挂起”,然后立刻抽身离开,回到主线任务中,查看他的“点餐板”上还有没有其他事。
- 服务员遇到了
console.log('D: ...')
:- 服务员发现主线任务中还有最后一行代码没执行!他立即处理它。
- 控制台输出:
D: 服务员已将第1位客人的订单交给厨房...
- 至此,我们解释了为什么
D
会提前打印。await
并没有阻塞一切,它只暂停了它所在的async
函数,并把控制权还给了调用方。
阶段二:等待与唤醒 (T = 0 到 3 秒)
- 此时,所有主线同步代码都已执行完毕。服务员的“点餐板”空了。
- 根据工作法则,他开始不断地轮询检查“出餐口”(Task Queue)。
- 后厨正在为第一个
sleep
订单计时,出餐口空空如也。服务员处于“空闲但警觉”的等待状态。
阶段三:异步任务的回调与循环 (T ≥ 3 秒)
T=3秒时,
setTimeout
完成:- 后厨的计时器响了!厨师把一个“可以继续执行
run
函数了”的通知(即resolve
函数)放到了出餐口 (Task Queue)。
- 后厨的计时器响了!厨师把一个“可以继续执行
事件循环的响应:
- 服务员立刻发现出餐口有新任务!他取回这个任务,回到
run
函数被暂停的地方。
- 服务员立刻发现出餐口有新任务!他取回这个任务,回到
run
函数恢复执行:console.log('E: ...')
: 服务员从await
的下一行继续工作,打印日志E
。- 控制台输出:
E: [循环 1] 菜做好了!...
while
循环 (第 2 次):- 循环继续,服务员打印下一次的日志
C
。 await sleep(3000)
(第 2 次): 历史重演!服务员再次将新订单交给后厨,并再次被await
“踢”出run
函数。
- 循环继续,服务员打印下一次的日志
第二次暂停期间,服务员做什么?
- 他再次回到主线。但主线任务早已全部完成。
- 他再次检查出餐口。后厨刚接到新订单,出餐口也是空的。
- 所以,从 T=3.01 秒到 T=6 秒,服务员再次进入“空闲等待”状态,等待下一个
setTimeout
完成。
这个“执行 -> await
-> 暂停 -> 等待 -> 唤醒 -> 继续执行”的循环会一直持续,直到 while
循环结束。
- 循环结束,
console.log('F: ...')
:- 当 5 次循环全部完成后,
run
函数继续向下执行,打印最后的日志F
。 - 此时,
async
函数run
才算真正执行完毕。
- 当 5 次循环全部完成后,
知识点深化
async
关键字:- 它将一个普通函数标记为异步函数。
async
函数的调用会立即返回一个 Promise 对象,而不会等待函数内部所有代码执行完毕。
await
关键字:- 只能在
async
函数内部使用。 - 它会暂停当前
async
函数的执行,等待其后的表达式(通常是一个 Promise)变为fulfilled
状态。 await
是异步流程控制的语法糖,它让异步代码看起来像同步代码一样直观,但其底层仍然是基于 Promise 和事件循环的。
- 只能在
非阻塞的本质:JavaScript 的单线程通过事件循环机制,实现了“非阻塞”的并发模型。当遇到 I/O 操作或定时器等耗时任务时,主线程会将其交给其他模块(如 Web APIs)处理,自身则继续执行后续代码,从而保证了 UI 的流畅和程序的响应性。
async/await
正是这一模型的上层建筑。执行时机辨析:
- 一个
async
函数外的代码,会在该函数第一次await
并交出控制权后,立即得到执行。 async
函数内部await
之后的代码,必须等到await
的 Promise 完成后,通过事件循环的调度,才能被重新放回主线程执行。
- 一个