欢迎光临
我们一直在努力

深入理解 JavaScript 事件循环:从一道面试题撕开微任务与宏任务的面纱

ef11377a7d8d2460291619876d2df481.jpg

引言部分:为什么你写的 setTimeout 总是不准时?

先看一道经典面试题,请在不运行的情况下说出输出顺序:

console.log('1');

setTimeout(() => {
console.log('2');
}, 0);

new Promise((resolve) => {
console.log('3');
resolve();
}).then(() => {
console.log('4');
});

console.log('5');

// 你的答案:_______

如果你的答案是 1 3 5 2 4 或者 1 2 3 4 5,那这篇文章就是为你准备的。正确答案是 1 3 5 4 2。

为什么 setTimeout 明明是 0 毫秒,却排在 then 后面?这背后藏着 JavaScript 异步机制最核心的秘密——事件循环中的优先级。

一、从操作系统说起:进程是董事长,线程是经理

要理解 JS 的异步,得先搞清楚操作系统怎么"分活儿"。

打个比方:把操作系统比作一家公司。

  • 进程(Process) 是公司的董事长,操作系统给每个进程分配一个唯一的 PID(Process ID),董事长手里握着公司全部资源——内存、文件句柄、网络端口,他说了算
  • 线程(Thread) 是部门经理,真正干活的人。一个进程可以启动一个主线程,也可以启动多个子线程
  • CPU 执行时间是公司的预算,不能让你一个人霸占——操作系统用时间片轮询的方式,每隔几十毫秒就把 CPU 从进程 A 手里抢过来,分配给进程 B,谁也别想独占

像 C++、Java 这些系统级语言,天生支持多线程。就像一家公司有市场部、研发部、财务部三个部门同时干活:

// C++/Java 可以这样干——三个变量,三个线程并发声明
// 线程1:let a = 1;
// 线程2:let b = 2;
// 线程3:let c = 3;
// 理论上只需2步(并发执行),效率极高

但多线程带来了巨大的心智负担——部门之间要协调、要开会、要抢会议室(线程同步、互斥锁、死锁)。写多线程代码就像同时让三个经理在一张纸上写字还不许涂改——想想就头疼。

二、JS 的设计哲学:只要一个收银员的便利店

33a32abf2a40204d5c605f190ec89ad1.jpg JavaScript 从诞生之初就走了另一条路——单线程。

// JS 只能这样干——老老实实,一步一步来
let a = 1;
let b = 2;
let c = 3;
console.log(a + b + c);

为什么这么"傻"?这不是 Brendan Eich 偷懒,而是一个深思熟虑的设计哲学选择:用简单换可控。

JS 的设计哲学:足够简单。永远不用担心两个操作同时修改同一个变量。

代价当然有——来个耗时计算,整个页面直接卡死:

// 一个耗时同步任务,10秒内页面完全"假死"
let result = 0;
for (let i = 0; i < 10000000000; i++) {
result += i;
}
// 这10秒里,用户点击、滚动、输入,统统没反应——收银员在处理一个"超级顾客"
console.log(result);

那问题来了:现实中我们有一大堆"耗时但不费脑"的活儿——setTimeout 等几秒、fetch 等网络返回、事件监听等用户点击。单线程怎么处理这些?

答案就是——收了订单先放一边,别堵着收银台。

在这里插入图片描述

三、JS 执行机制的完整流程

不管是浏览器里的 <script> 标签,还是 Node.js/Bun 的服务端代码,JS 的执行流程都是同一套:

第一步:操作系统启动一个进程(分配 PID,配发资源)

第二步:进程启动一个主线程(JS 只需要一个经理干活,简单!)

第三步:主线程开始执行代码
├── 遇到同步代码 → 立刻执行,快速跑完
│ (用户需要看到的页面内容在这里渲染)

└── 遇到异步任务(setTimeout、fetch、事件监听)
→ 不执行,跳过!扔到 Event Loop 里去排队
→ 继续往下跑同步代码

第四步:同步代码全部执行完毕,调用栈清空

第五步:Event Loop 把之前排队等着的异步回调,一个一个拿出来执行

用收银员的比喻重讲一遍:

收银员不会自己去后厨做咖啡。他把咖啡订单交给后厨(Web APIs),继续服务下一位顾客(同步代码)。后厨做完了把咖啡放到取餐区(任务队列)。收银员只有在处理完眼前所有顾客后,才去取餐区把做好的端出来。

核心规则:调用栈清空后,Event Loop 才从任务队列中取回调执行。

看一段最简单的代码验证:

console.log('start'); // 同步,立刻执行

setTimeout(() => {
console.log('timer'); // 异步回调,1秒后才进队列
}, 1000);

console.log('end'); // 同步,立刻执行

执行流程一步一步拆:

  • console.log('start') → 进调用栈 → 输出 start → 出栈
  • setTimeout(…) → 进调用栈 → 把回调丢给浏览器的定时器线程 → 出栈(setTimeout 本身只花了几微秒,真正的等待是浏览器线程在干)
  • console.log('end') → 进调用栈 → 输出 end → 出栈
  • 调用栈空了。1 秒后,浏览器定时器线程把回调塞进任务队列
  • Event Loop 发现栈空了、队列有活 → 回调进栈 → 输出 timer
  • 输出:start → end → timer

    这就是很多人初学 JS 时觉得"反直觉"的地方——明明写在中间,却最后输出。但理解了 Event Loop 的"先跳过、回头再取"机制,反直觉就变成了直觉。

    JS 有哪些异步任务?

    总结一下,JS 中常见的异步任务包括:

    类别举例说明
    定时器 setTimeout、setInterval 到时间了把回调塞进队列
    网络请求 fetch、XMLHttpRequest 请求回来了把回调塞进队列
    事件监听 click、scroll、keydown 用户触发了把回调塞进队列
    I/O 操作 文件读写(Node.js) 读写完了把回调塞进队列

    它们的模式都一样:主线程只管"发起",真正的等待由底层去扛,扛完了把回调丢回来。

    四、微任务与宏任务:同一个取餐区,两条队伍

    4.1 两条队伍的区别

    上面的故事其实只讲了一半。真正的深度在于:任务队列不止一条。

    实际上有两支队伍:

    类型常见成员比喻
    宏任务 (Macro Task) setTimeout、setInterval、I/O 回调、UI 渲染 普通顾客,按号排队
    微任务 (Micro Task) Promise.then/catch/finally、MutationObserver、queueMicrotask VIP 顾客,随到随优先

    规则升级为:

    每执行完一个宏任务,都必须把微任务队列清空,才能执行下一个宏任务。

    而且——清空微任务的过程中,新加入的微任务也会在本轮被清掉(直到队列为空)。

    4.2 面试题的真相

    现在回头看引言那道题:

    console.log('1'); // ① 同步,立刻输出

    setTimeout(() => { // ② 注册宏任务A
    console.log('2');
    }, 0);

    new Promise((resolve) => {
    console.log('3'); // ③ executor 是同步的!立刻输出
    resolve(); // ④ 把 then 回调注册进微任务队列
    }).then(() => {
    console.log('4'); // ⑤ 这是微任务a
    });

    console.log('5'); // ⑥ 同步,立刻输出

    // ========== 本轮宏任务(script整体)结束 ==========
    // 微任务队列:[微任务a]
    // → 执行微任务a:输出 4
    // 微任务队列清空 ✓
    // ========== 取出下一个宏任务 ==========
    // → 执行宏任务A:输出 2

    最终输出:1 3 5 4 2

    关键知识点:new Promise 的 executor 函数是同步执行的,只有 .then() 里的回调才是异步微任务。

    很多人就栽在这一步——以为 Promise 整个都是异步的,其实 Promise 是一个容器,executor 只是同步"造容器",容器的内容(异步结果)触发的回调(then/catch)才走微任务通道。

    五、深入 Promise:一个"承诺"容器的里里外外

    5.1 Promise 是什么?

    Promise 是 ES6 引入的,用于控制异步任务执行流程的最佳机制。它不是用来"创造异步"的,而是用来管理异步的。

    打个比方:Promise 是一个**“耗时性任务的容器”**。它像你在餐厅点餐后拿到的小票——票本身是同步拿到手的(executor 立即执行),但菜做好没有,你得等(异步)。

    实例化一个 Promise 需要传入一个函数——executor(执行器):

    const p = new Promise((resolve, reject) => {
    // 这个函数叫 executor,它会立即执行!
    console.log('承诺已立下');

    // 里面放你的耗时异步任务
    setTimeout(() => {
    // resolve(666); // 成功:手动调用 resolve,数据传给 then
    reject('网络出错'); // 失败:手动调用 reject,错误原因传给 catch
    }, 2000);
    });

    // Promise 原型链上挂着三个关键方法:
    console.log(p.__proto__); // then, catch, finally 都在上面

    p
    .then((data) => {
    console.log(data); // resolve(666) 的值到这里
    console.log('成功了');
    })
    .catch((err) => {
    console.log(err); // reject('网络出错') 的值到这里
    console.log('失败了');
    })
    .finally(() => {
    console.log('不管成败,我都会执行'); // finally 总是执行
    });

    核心要点:

    new Promise(executor)


    executor 立即执行(同步)

    ├── resolve(result) ──→ .then(callback) 被调用(微任务)

    └── reject(reason) ──→ .catch(callback) 被调用(微任务)

    └──→ .finally() 兜底执行(微任务)

    注意:resolve 和 reject 都需要你在 executor 里面手动调用。引擎不会自动知道你啥时候成功——你得在耗时任务完成的那一刻自己喊一声"好了"。

    5.2 Promise 是一个状态机

    Promise 本质上是一个状态机,只有三种状态:

    pending (等待中——小票拿到了,菜还没好)
    / \\
    ▼ ▼
    fulfilled rejected
    (已兑现) (已拒绝)

    状态一旦改变就凝固了——不可逆,不可二次修改。就像你在餐厅的承诺小票:菜要么端上来(fulfilled),要么告诉你卖完了(rejected),不存在"上了一半又说没货"。

    5.3 手写一个 Promise

    理解 Promise 最好的方式,就是自己造一个:

    class MyPromise {
    constructor(executor) {
    this.state = 'pending'; // 当前状态
    this.value = undefined; // 成功的值(从 resolve 来)
    this.reason = undefined; // 失败的原因(从 reject 来)
    this.onFulfilledCallbacks = []; // 收集 .then() 的回调
    this.onRejectedCallbacks = []; // 收集 .catch() 的回调

    const resolve = (value) => {
    if (this.state !== 'pending') return; // 状态凝固,不可逆
    this.state = 'fulfilled';
    this.value = value;
    // 触发所有提前注册的 then 回调(适用于异步场景)
    this.onFulfilledCallbacks.forEach(fn => fn());
    };

    const reject = (reason) => {
    if (this.state !== 'pending') return; // 状态凝固
    this.state = 'rejected';
    this.reason = reason;
    this.onRejectedCallbacks.forEach(fn => fn());
    };

    try {
    executor(resolve, reject); // executor 立即执行!
    } catch (err) {
    reject(err); // executor 里抛异常 → 自动 reject
    }
    }

    then(onFulfilled, onRejected) {
    // 核心:then 必须返回一个新 Promise,链式调用全靠这个
    return new MyPromise((resolve, reject) => {
    const handleFulfilled = () => {
    queueMicrotask(() => { // 关键!包装成微任务
    try {
    const result = onFulfilled(this.value);
    if (result instanceof MyPromise) {
    result.then(resolve, reject); // 返回 Promise 则"穿透"
    } else {
    resolve(result); // 返回值包装为新 Promise 的成功值
    }
    } catch (err) {
    reject(err);
    }
    });
    };

    if (this.state === 'fulfilled') {
    handleFulfilled(); // 已经解决了,直接加入微任务队列
    } else if (this.state === 'pending') {
    this.onFulfilledCallbacks.push(handleFulfilled); // 还没解决,先存着
    }
    });
    }
    }

    这份简化实现揭示了 Promise 的四个核心秘密:

  • executor 立即执行——new Promise(fn) 里的 fn 是同步跑的,不是异步
  • 状态凝固——if (this.state !== 'pending') return 保证 resolve/reject 只有第一次生效
  • 链式调用靠 then 返回新 Promise——每个 .then() 都在造一个新容器
  • then 里的回调是微任务——queueMicrotask 保证了它比 setTimeout 先跑
  • 5.4 Promise 静态方法的本质

    理解了基本结构,那些 Promise.all、Promise.race 不过是"批量操作的语法糖":

    // Promise.all:所有承诺都兑现,才算成功(有一个失败就整体失败)
    // 本质:维护一个计数器,每个 then 里 counter++,
    // counter === 总数时 resolve(results)

    // Promise.race:谁先跑完听谁的(别的不管)
    // 本质:给每个 promise 都绑定同样的 resolve/reject,
    // 第一个触发的就能改状态,后面的因为状态凝固而失效

    // Promise.allSettled:等所有人跑完,不管成败
    // 本质:Promise.all 的"不抛弃不放弃"版,
    // result.status 区分 'fulfilled' / 'rejected',永远 resolve

    // Promise.any:任意一个成功就成功,全失败了才报错
    // 本质:race 的"只要成功不要失败"

    六、实战:用 Promise 控制执行流程

    6.1 场景:先拿用户列表,再拿每个用户详情

    你的笔记里提到了一个非常经典的异步控制问题:

    A. fetch users API 获取所有用户 B. fetch 每一个用户的详细信息

    这个场景的核心痛点:B 必须在 A 完成后才能开始。用 Promise 来解决:

    // 第一步:拿到所有用户
    fetch('/api/users')
    .then(response => response.json())
    .then(users => {
    // 第二步:每个用户都要查详情,又是一个并发 Promise
    const detailPromises = users.map(user =>
    fetch(`/api/users/${user.id}`).then(res => res.json())
    );
    return Promise.all(detailPromises); // 等所有详情都拿到
    })
    .then(allUserDetails => {
    console.log('所有用户详情到手:', allUserDetails);
    })
    .catch(err => {
    console.log('某一步出错了:', err);
    });

    这就是 Promise 的核心价值——控制异步任务的执行流程,让以前"回调地狱"的代码变得像流水线一样清晰。

    6.2 fetch 的底层就是 Promise

    fetch 是浏览器内置的 HTTP 请求 API,它的返回值就是 Promise:

    console.log('start');

    fetch('https://api.github.com/users/xz878787', {
    method: 'POST',
    })
    .then((data) => {
    console.log(data); // 这里拿到 Response 对象
    })
    .catch((err) => {
    console.log('网络挂了:', err); // 只有网络级别的错误才会进这里
    });

    console.log('end');

    // 输出:start → end → Response对象

    fetch 有一个坑需要注意:catch 只捕获网络错误(断网、DNS 解析失败)。HTTP 状态码 404、500 不会触发 catch,需要自己判断 response.ok。

    6.3 用 Promise 手写 sleep 函数

    JS 系统本身不支持 sleep()(其他语言像 Python 有 time.sleep()),但我们可以用 Promise 轻易封装一个:

    function sleep(ms) {
    const p = new Promise((resolve, reject) => {
    setTimeout(() => {
    resolve(); // 时间到了,标记为"完成"
    }, ms);
    });
    return p; // 把承诺返回出去,让调用方可以 .then()
    }

    // 使用:2秒后才执行后续代码
    sleep(2000).then(() => {
    console.log('2秒后执行');
    });

    配合 async/await 会让它更像原生的 sleep:

    async function main() {
    console.log('开始');
    await sleep(2000); // 线程不会阻塞,Event Loop 照常运转
    console.log('2秒后');
    }

    关键在于:sleep 用 setTimeout + Promise 的组合,实现了看起来是同步的暂停,实际上是异步的不阻塞——这就是 Promise 容器的妙用。

    七、async/await:Generator 的语法糖衣

    7.1 从回调地狱到同步写法

    async/await 是 ES2017 引入的,是目前写异步代码最舒服的方式:

    async function fetchUser() {
    const response = await fetch('/api/user');
    const user = await response.json();
    return user;
    }

    这背后其实是 Generator + Promise 的语法糖。用 Generator 来模拟:

    function* fetchUserGen() {
    const response = yield fetch('/api/user');
    const user = yield response.json();
    return user;
    }

    // 需要一个"自动执行器"来递归推进 Generator
    function runGenerator(gen) {
    const g = gen();
    function step(nextValue) {
    const result = g.next(nextValue);
    if (result.done) return Promise.resolve(result.value);
    return Promise.resolve(result.value).then(
    value => step(value), // 成功 → 推进到下一个 yield
    err => g.throw(err) // 失败 → 抛出异常
    );
    }
    return step();
    }

    await 的本质就是 yield——暂停当前函数,把控制权还给事件循环,等 Promise resolve 之后再恢复。区别是 async/await 内置了执行器,不需要手动写 runGenerator。

    7.2 await 的微任务时机

    await 后面的代码实际上被拆成了微任务。看个例子:

    console.log('script start');

    async function async1() {
    console.log('async1 start');
    await async2();
    console.log('async1 end'); // ← 这行等于 .then(() => { ... })
    }

    async function async2() {
    console.log('async2');
    }

    async1();

    new Promise((resolve) => {
    console.log('promise executor');
    resolve();
    }).then(() => {
    console.log('promise then');
    });

    console.log('script end');

    输出:

    script start
    async1 start
    async2
    promise executor
    script end
    async1 end
    promise then

    一步一步拆:

  • async1() 调用 → 输出 async1 start
  • 遇到 await async2(),先执行 async2() 内部的同步代码,输出 async2
  • await 把 async1 剩余部分包装为微任务 A
  • new Promise 的 executor 同步执行,输出 promise executor
  • resolve() 注册 .then 为微任务 B
  • 输出 script end
  • 本轮宏任务结束,微任务队列:[A, B] → 按注册顺序清空 → async1 end → promise then
  • 规律:await foo() 时,foo() 内部的同步代码是立刻执行的,只有 foo() 后面的代码变成了微任务。

    八、终极面试题:所有知识点串起来

    以下代码综合考察了 Event Loop、微任务/宏任务、Promise、async/await。在看答案之前,自己试着推一遍:

    console.log('1');

    setTimeout(() => {
    console.log('2');
    Promise.resolve().then(() => {
    console.log('3');
    });
    }, 0);

    new Promise((resolve) => {
    console.log('4');
    resolve();
    }).then(() => {
    console.log('5');
    setTimeout(() => {
    console.log('6');
    }, 0);
    });

    console.log('7');

    async function asyncFn() {
    console.log('8');
    await Promise.resolve();
    console.log('9');
    }

    asyncFn();

    答案是:1 4 7 8 5 9 2 3 6

    用一张表完整拆解:

    步骤发生了什么宏任务队列微任务队列输出
    1 同步 console.log('1') 1
    2 注册 setTimeout(宏任务 T2) [T2]
    3 Promise executor 同步执行 → 输出 4 [T2] 4
    4 resolve() → 注册 .then()(微任务 M5) [T2] [M5]
    5 同步 console.log('7') [T2] [M5] 7
    6 asyncFn() 同步部分 → 输出 8 [T2] [M5] 8
    7 await Promise.resolve() → 挂起,注册恢复点 M9 [T2] [M5, M9]
    8 本轮宏任务结束,清空微任务
    9 执行 M5 → 输出 5;注册新的 setTimeout(宏任务 T6) [T2, T6] [M9] 5
    10 执行 M9 → 输出 9 [T2, T6] 清空 ✓ 9
    11 下一轮:执行宏任务 T2 → 输出 2 [T6] 2
    12 T2 里注册微任务 M3 [T6] [M3]
    13 T2 结束,清空微任务 → 执行 M3,输出 3 [T6] 清空 ✓ 3
    14 再下一轮:执行宏任务 T6 → 输出 6 清空 ✓ 6

    核心规律,记住这三条就不会再错:

  • 同步代码永远是第一优先级(包括 Promise executor 和 await 前面的代码)
  • 每轮宏任务结束后,微任务队列必须一次性清空(包括清空过程中新加入的微任务)
  • await 后面的代码 = .then() 里的代码,都走微任务通道
  • 九、追根溯源:为什么要有微任务?

    最后反推一个根本问题:浏览器为什么要在宏任务之外,设计一个能"插队"的微任务?

    答案在于用户感知到的响应速度。

    假设没有微任务,Promise 回调和 setTimeout 一个待遇——统统进宏任务队列排队。那么这段代码:

    button.addEventListener('click', () => {
    Promise.resolve().then(() => {
    updateUI(); // 更新界面
    });
    });

    用户点完按钮后,updateUI 要等到下一个宏任务——中间可能被另一个 setTimeout 或 I/O 回调插队。用户就会感觉到"怎么点了没反应,过了一下才变"。

    有了微任务,updateUI 在当前宏任务结束后立刻执行,赶在浏览器重绘之前就把数据更新好了。用户感觉就是——秒响应。

    打个比方:宏任务是前台对外开放的办事窗口,微任务是内部员工专用的快速通道。 引擎需要保证自己的"善后工作"(Promise 链、DOM 观察)不被外部回调打断,所以给自己留了这条通道。

    十、总结

    JavaScript 的异步机制,本质是一套单线程下的任务调度艺术:

    进程 → 主线程 → 同步代码先跑完 → Event Loop 取异步回调

    ┌────────────┴────────────┐
    ▼ ▼
    微任务队列 宏任务队列
    (VIP,本轮清空) (普通,下轮执行)

  • 进程是董事长,分配资源;线程是经理,干活执行。JS 只要一个经理——单线程,足够简单
  • 同步代码快速跑完(用户要看的页面先渲染),异步任务跳过,等同步完了再从 Event Loop 取出来
  • Promise 是耗时性任务的容器,executor 同步执行,then/catch/finally 的回调走微任务
  • 微任务优先级高于宏任务,每轮宏任务结束后必须清空微任务
  • async/await 是 Generator + Promise 的语法糖,await 后面的代码就是微任务
  • 这套机制不是 bug,是设计哲学——在简单性(单线程)和响应性(微任务优先)之间找到最佳平衡

  • 赞(0)
    未经允许不得转载:171主机测评 » 深入理解 JavaScript 事件循环:从一道面试题撕开微任务与宏任务的面纱
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址