Skip to content

让 JavaScript “睡”一会儿

在许多编程语言中,比如 Python,我们可以用 time.sleep(3) 轻松地让程序暂停 3 秒。但在 JavaScript 中,这事儿没那么简单。如果我们用一个“忙等待”循环来阻塞主线程,整个浏览器页面都会卡死,这是绝对不可接受的。

我们的目标是实现一个非阻塞sleep 函数,它能“暂停”一段代码的执行,但不会冻结整个程序。

来看我们的最终实现代码,这也是我们今天探讨的核心:

javascript
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):
    1. 清空手头工作:服务员会优先处理完“点餐板”上的所有同步任务。
    2. 检查出餐口:当“点餐板”空了,服务员就会去看一眼“出餐口”。
    3. 上新菜:如果出餐口有菜,就取一道菜(一个回调任务),放到自己的“点餐板”上开始服务。
    4. 循环往复:服务员永不休息,持续重复第 2 和第 3 步。这就是著名的事件循环 (Event Loop)

async/await 的基石:理解 Promise

上面示例代码中有 asyncawait 关键字,这是什么呢,为什么要使用它?

在我们深入 async/await 的魔法之前,必须先了解它的基石——Promiseasync/await 只是一种让 Promise 用起来更舒服的“语法糖”。

1. Promise 是什么?—— 一个对未来的承诺

想象一下,你去快餐店点餐,服务员不会让你在柜台前干等汉堡做好。他会给你一张取餐小票,并说:“我承诺 (Promise),汉堡做好后,凭这张小票来取。”

这张小票就是 Promise。它代表一个未来才会出结果的异步操作。它有三种状态:

  • Pending (进行中): 你刚拿到小票,汉堡还在做。这是初始状态。
  • Fulfilled (已成功): 汉堡做好了!你可以凭小票取餐。这个承诺被兑现了。
  • Rejected (已失败): 厨房发现没面包了,做不了汉堡。这个承诺被拒绝了。

2. 如何使用 Promise?—— .then().catch()

拿到小票后,你不会傻站着,而是可以先去玩手机。但你需要知道接下来该做什么:

  • .then(onFulfilled): 你告诉自己,“然后 (then),如果汉堡做好了 (fulfilled),我就去取餐吃掉。”
  • .catch(onRejected): 你也做好了最坏的打算,“万一 (catch) 他们告诉我做不了 (rejected),我就去投诉。”

在代码中,这看起来像这样:

javascript
// 这是一个模拟“做汉堡”的异步操作,它返回一个 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. Promisesleep 函数的关系

现在再看我们的 sleep 函数,就豁然开朗了:

javascript
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() 链式调用来处理多个异步任务,当逻辑复杂时,容易形成“回调地狱”,代码可读性变差。

javascript
// 回调地狱风格
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 小票兑现,然后自动执行下一行代码,让异步流程看起来像同步一样清晰:

javascript
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 秒)

  1. console.log('A: ...'):

    • 服务员接到第一个任务:打印日志 A。这是同步任务,他立即完成。
    • 控制台输出: A: 服务员(JS主线程)开始一天的工作。
  2. run():

    • 服务员接到第二个任务:执行 run 函数。他立刻走进 run 函数内部。
  3. console.log('B: ...'):

    • run 函数内部,服务员执行第一行代码,打印日志 B
    • 控制台输出: B: run 函数开始执行,准备服务第 1 位客人。
  4. while 循环 (第 1 次) & console.log('C: ...'):

    • 服务员进入循环,打印日志 C
    • 控制台输出: C: [循环 1] 点餐开始。把订单交给厨师...
  5. await sleep(3000) - 关键转折点!:

    • 服务员遇到了 await。他做了两件事: a. 执行 sleep(3000):这相当于把一张“3秒后提醒我”的订单交给了后厨 (Web APIs)。后厨的计时器开始计时,这不关服务员的事了。 b. await 关键字对服务员说:“run 函数这个任务先暂停,你可以走了!
    • 结果:服务员将 run 函数“挂起”,然后立刻抽身离开,回到主线任务中,查看他的“点餐板”上还有没有其他事。
  6. console.log('D: ...'):

    • 服务员发现主线任务中还有最后一行代码没执行!他立即处理它。
    • 控制台输出: D: 服务员已将第1位客人的订单交给厨房...
    • 至此,我们解释了为什么 D 会提前打印await 并没有阻塞一切,它只暂停了它所在的 async 函数,并把控制权还给了调用方。

阶段二:等待与唤醒 (T = 0 到 3 秒)

  • 此时,所有主线同步代码都已执行完毕。服务员的“点餐板”空了。
  • 根据工作法则,他开始不断地轮询检查“出餐口”(Task Queue)
  • 后厨正在为第一个 sleep 订单计时,出餐口空空如也。服务员处于“空闲但警觉”的等待状态。

阶段三:异步任务的回调与循环 (T ≥ 3 秒)

  1. T=3秒时,setTimeout 完成:

    • 后厨的计时器响了!厨师把一个“可以继续执行 run 函数了”的通知(即 resolve 函数)放到了出餐口 (Task Queue)
  2. 事件循环的响应:

    • 服务员立刻发现出餐口有新任务!他取回这个任务,回到 run 函数被暂停的地方。
  3. run 函数恢复执行:

    • console.log('E: ...'): 服务员从 await 的下一行继续工作,打印日志 E
    • 控制台输出: E: [循环 1] 菜做好了!...
  4. while 循环 (第 2 次):

    • 循环继续,服务员打印下一次的日志 C
    • await sleep(3000) (第 2 次): 历史重演!服务员再次将新订单交给后厨,并再次被 await “踢”出 run 函数。
  5. 第二次暂停期间,服务员做什么?

    • 他再次回到主线。但主线任务早已全部完成。
    • 他再次检查出餐口。后厨刚接到新订单,出餐口也是空的。
    • 所以,从 T=3.01 秒到 T=6 秒,服务员再次进入“空闲等待”状态,等待下一个 setTimeout 完成。

这个“执行 -> await -> 暂停 -> 等待 -> 唤醒 -> 继续执行”的循环会一直持续,直到 while 循环结束。

  1. 循环结束,console.log('F: ...'):
    • 当 5 次循环全部完成后,run 函数继续向下执行,打印最后的日志 F
    • 此时,async 函数 run 才算真正执行完毕。

知识点深化

  1. async 关键字

    • 它将一个普通函数标记为异步函数
    • async 函数的调用会立即返回一个 Promise 对象,而不会等待函数内部所有代码执行完毕。
  2. await 关键字

    • 只能在 async 函数内部使用。
    • 它会暂停当前 async 函数的执行,等待其后的表达式(通常是一个 Promise)变为 fulfilled 状态。
    • await 是异步流程控制的语法糖,它让异步代码看起来像同步代码一样直观,但其底层仍然是基于 Promise 和事件循环的。
  3. 非阻塞的本质:JavaScript 的单线程通过事件循环机制,实现了“非阻塞”的并发模型。当遇到 I/O 操作或定时器等耗时任务时,主线程会将其交给其他模块(如 Web APIs)处理,自身则继续执行后续代码,从而保证了 UI 的流畅和程序的响应性。async/await 正是这一模型的上层建筑。

  4. 执行时机辨析

    • 一个 async 函数外的代码,会在该函数第一次 await 并交出控制权后,立即得到执行
    • async 函数内部 await 之后的代码,必须等到 await 的 Promise 完成后,通过事件循环的调度,才能被重新放回主线程执行。