异步编程全景与事件循环——彻底搞懂 JS 执行机制

请先看下面这段代码,在心里默默给个执行结果:

javascript 复制代码
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 4 2,恭喜你------你对事件循环已经有了基本认知;如果不是,读完这篇文章,你将成为人肉 JS 执行器,任何异步面试题都不过是一道逻辑题。本文会用最通俗的语言,从回调地狱一路讲到宏任务/微任务,让你彻底看懂 JavaScript 的执行机制。

一、说好的一行一行执行呢?

JavaScript因为要解决DOM冲突被设计成单线程语言,这意味着它一次只能做一件事。理论上,代码就应该按照书写顺序一行一行执行:

javascript 复制代码
let a = '1';
console.log(a); // 1

let b = '2';
console.log(b); // 2

然而,我们经常看到这样的代码:

javascript 复制代码
setTimeout(function() {
  console.log('定时器开始啦');
});

new Promise(function(resolve) {
  console.log('马上执行for循环啦');
  for (var i = 0; i < 10000; i++) {
    i == 99 && resolve();
  }
}).then(function() {
  console.log('执行then函数啦');
});

console.log('代码执行结束');

如果你以为是"定时器开始啦 → 马上执行for循环啦 → 执行then函数啦 → 代码执行结束",那控制台会让你怀疑人生。实际输出是:

text 复制代码
马上执行for循环啦
代码执行结束
执行then函数啦
定时器开始啦

原因就是:JavaScript 有同步任务和异步任务之分,并且有一套精确的调度规则------事件循环

二、同步与异步,就像银行的叫号系统

想象一个只有一个窗口的银行,所有客户都必须排队。这就是 JavaScript 的单线程模型。

但银行不会让一个存钱需要 30 分钟的客户堵住后面所有只取 100 块钱的人。所以,他们会分两类业务:

  • 同步任务:快速处理,在主窗口立即办理。
  • 异步任务:耗时较久,先去旁边填表,填好了再回到等候区排队等叫号。

JavaScript的解决方式也类似:

  • 同步任务直接进入主线程执行栈。
  • 异步任务会被扔进一个"注册中心"(Event Table),绑定好回调函数。一旦异步操作完成(比如定时器到时间了,或者请求返回了),就把对应的回调函数放入一个事件队列(Event Queue)等待执行。

主线程会先把执行栈里的所有同步任务清空,然后去事件队列里取出第一个回调,放进执行栈执行。执行完再去取下一个,如此循环往复------这就是事件循环(Event Loop)

举个现实中的 AJAX 例子:

javascript 复制代码
let data = [];
$.ajax({
  url: 'https://api.example.com',
  data: data,
  success: () => {
    console.log('发送成功!');
  }
});
console.log('代码执行结束');

执行过程:

  1. ajax 进入 Event Table,注册回调函数 success
  2. 主线程继续执行,打印 '代码执行结束'
  3. 某刻请求完成,success 回调被放入事件队列。
  4. 主线程执行栈清空后,读取事件队列,执行 console.log('发送成功!')

因此输出顺序一定是:先 '代码执行结束',后 '发送成功!'

三、setTimeout 的真相:定时器并不准时

我们常用 setTimeout(fn, delay) 来延迟执行,但 delay 很多时候并不准确。

javascript 复制代码
setTimeout(() => {
  console.log('task');
}, 3000);
console.log('执行console');

根据异步规则,上面代码会先输出 '执行console',3 秒后输出 'task',这很容易理解。

但如果中间插入一个很慢的同步操作呢?

javascript 复制代码
setTimeout(() => {
  task();
}, 3000);

// 模拟一个超级慢的函数
slowTask(10000000); // 假设这个函数要跑 10 秒

执行过程变成:

  1. task 进入 Event Table,开始计时 3 秒。
  2. 主线程开始执行 slowTask,耗时 10 秒。
  3. 3 秒到了,task 被放入事件队列,但主线程还在忙 slowTask,只能继续等。
  4. 10 秒后 slowTask 结束,主线程去事件队列取出 task 执行。

最终 task 的实际延迟是 10 秒,而不是 3 秒。这就是 setTimeout 的"不准时"根源:它只保证在指定时间后将回调放入队列,但不保证立刻执行,前面还有多少同步任务或排队的回调,它就要等多久。

setTimeout(fn, 0) 的含义也清楚了:不是立即执行 ,而是把 fn 放在队列的最后,等所有同步任务完成后立刻执行。不过根据 HTML 标准,0 毫秒最低实际上是 4ms。

四、宏任务与微任务:事件循环的"VIP 插队机制"

上面我们只区分了同步和异步,但异步任务内部还有"三六九等"。

4.1 两大类异步任务

  • 宏任务(Macro Task) :整体 script 代码、setTimeoutsetInterval、I/O、UI 渲染等。
  • 微任务(Micro Task)Promise.thenMutationObserver,以及 Node.js 的 process.nextTick

事件循环的精确规则是:

  1. 执行一个宏任务(首次就是整个 script)。
  2. 宏任务执行完后,立即清空所有微任务
  3. 重新渲染 UI(如果需要)。
  4. 再取下一个宏任务,回到步骤 1。

关键:每个宏任务之后,所有微任务会一次性清空,且微任务可以插队,不会让其他宏任务先行。

4.2 一个经典的例子

回到开篇那段代码,详细拆解一下:

javascript 复制代码
console.log('1');

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

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

console.log('5');

第一轮(当前 script 宏任务):

  • console.log('1') → 输出 1
  • setTimeout → 回调放入宏任务队列,记作 setTimeout1
  • new Promise 内的函数立即执行:输出 3resolve().then 回调放入微任务队列,记作 then1
  • console.log('5') → 输出 5

现在,当前宏任务执行完毕。微任务队列里有 then1,执行它输出 4

第一轮结束,输出 1 3 5 4

第二轮(下一个宏任务): 取出 setTimeout1 执行,输出 2。没有微任务。循环结束。

最终输出:1 3 5 4 2

五、综合实战:一道面试大题,三步拆穿

下面是一道经典的大厂面试题,我们用刚学到的规则一步步剖析:

javascript 复制代码
console.log('1');

setTimeout(function() {
  console.log('2');
  process.nextTick(function() {
    console.log('3');
  });
  new Promise(function(resolve) {
    console.log('4');
    resolve();
  }).then(function() {
    console.log('5');
  });
});

process.nextTick(function() {
  console.log('6');
});

new Promise(function(resolve) {
  console.log('7');
  resolve();
}).then(function() {
  console.log('8');
});

setTimeout(function() {
  console.log('9');
  process.nextTick(function() {
    console.log('10');
  });
  new Promise(function(resolve) {
    console.log('11');
    resolve();
  }).then(function() {
    console.log('12');
  });
});

第一轮事件循环(当前 script 宏任务)

  1. console.log('1') → 输出 1
  2. 遇到第一个 setTimeout,回调进入宏任务队列(记作 time1)。
  3. 遇到 process.nextTick,回调进入微任务队列(记作 pro1)。
  4. 遇到 new Promise:执行构造函数输出 7.then 回调进入微任务队列(记作 then1)。
  5. 遇到第二个 setTimeout,回调进入宏任务队列(记作 time2)。

第一轮宏任务结束,此时的队列状态:

宏任务队列 微任务队列
time1 pro1
time2 then1

清空微任务队列:

  • 执行 pro1 → 输出 6
  • 执行 then1 → 输出 8

第一轮输出:1, 7, 6, 8

第二轮事件循环(宏任务 time1)

执行 time1

  • console.log('2') → 输出 2
  • 遇到 process.nextTick,回调进入微任务队列(记作 pro2
  • new Promise:输出 4.then 回调进入微任务队列(记作 then2

time1 执行完毕,队列状态:

宏任务队列 微任务队列
time2 pro2
then2

清空微任务:

  • 执行 pro2 → 输出 3
  • 执行 then2 → 输出 5

第二轮输出:2, 4, 3, 5

第三轮事件循环(宏任务 time2)

执行 time2

  • console.log('9') → 输出 9
  • process.nextTick 回调进入微任务队列(pro3
  • new Promise:输出 11.then 回调进入微任务队列(then3

清空微任务:

  • 执行 pro3 → 输出 10
  • 执行 then3 → 输出 12

第三轮输出:9, 11, 10, 12

完整输出

text 复制代码
1, 7, 6, 8, 2, 4, 3, 5, 9, 11, 10, 12

如果你能独立推到这一步,事件循环对你已无秘密。

六、async/await 的时序------语法糖背后的微任务

async/await 是建立在 Promise 之上的语法糖,它也遵循微任务规则。看一个例子:

javascript 复制代码
async function foo() {
  console.log('a');
  await bar();
  console.log('b');
}

async function bar() {
  console.log('c');
}

console.log('d');
foo();
console.log('e');

拆解:

  1. console.log('d') → 输出 d
  2. 调用 foo()
    • 输出 a
    • await bar()bar() 同步执行,输出 c,并返回一个 resolved 的 Promise
    • await 把下面的 console.log('b') 包装成微任务,放入队列
  3. 主线程继续执行 console.log('e') → 输出 e
  4. 同步代码完毕,清空微任务:输出 b

最终输出:d, a, c, e, b

记住:await 右边的表达式是同步执行的,下面的代码被当作微任务延迟执行。

七、日常开发中的事件循环应用

1. Vue 的 nextTick

Vue 更新 DOM 是异步的。当你修改响应式数据后,Vue 会把 DOM 更新任务推入微任务队列,所以此时直接读取 DOM 还是旧的。

javascript 复制代码
count.value = 10;
console.log(document.getElementById('counter').textContent); // 旧值

await nextTick();
console.log(document.getElementById('counter').textContent); // 新值

nextTick 内部就是利用了微任务(Promise 或 MutationObserver),保证你在队列清空后拿到最新 DOM。

2. React 的批量更新

React 18 之前的版本会将多个 setState 合并为一个更新,背后的调度也利用了事件循环和微任务,让渲染在合适的时机执行。

3. 分片执行,防止页面卡死

如果一段同步计算要循环 10 万次,主线程会被彻底堵塞。可以将每一小段计算用 setTimeout 切分为多个宏任务:

javascript 复制代码
function batchProcess(items, index = 0) {
  if (index >= items.length) return;
  heavyWork(items[index]);
  setTimeout(() => batchProcess(items, index + 1), 0);
}

这样每次只处理一项,然后让出主线程去响应用户交互,页面就不会卡死。

八、总结

JavaScript因为单线程,使得它必须依靠事件轮询来处理异步。理解事件循环,只需要牢牢把握两条铁律:

  1. 同步任务优先,异步任务靠边站。
  2. 一个宏任务后,所有微任务清空,再取下一个宏任务。

宏任务(setTimeoutsetInterval、script 代码)是一栋栋房子,微任务(Promise.thennextTick)是每栋房子里的紧急快递------先派送完快递,才轮到下一栋房子。

当你能把一道异步面试题的输出顺序像讲推理故事一样讲述出来,你就真正掌握了这门语言的核心执行机制。希望本文能帮你打通这"最后一公里",让异步不再神秘。

相关推荐
用户059540174461 小时前
AI Agent记忆测试踩坑实录:Mock骗了我一周,Mem0+pytest一招破局
前端·css
用户1733598075371 小时前
纯前端 PDF 数字签名实战:Vue 3 + pdf-lib 在浏览器里完成签名嵌入
前端·javascript
IT_陈寒2 小时前
SpringBoot自动配置的坑,我爬了三天才出来
前端·人工智能·后端
Avan_菜菜9 小时前
AI 能写代码了,为什么我反而开始要求它先写文档?
前端·github·ai编程
JieE21212 小时前
LeetCode 226. 翻转二叉树|JS 递归超详细拆解,二叉树入门经典题
javascript·算法
JieE21212 小时前
LeetCode 104. 二叉树的最大深度|递归思路超详细拆解
javascript·算法
爱勇宝13 小时前
鸿蒙生态的下半场:开发者不只要能开发,还要能赚钱
android·前端·程序员
IT_陈寒16 小时前
SpringBoot这个自动配置坑我跳了三次
前端·人工智能·后端
kyriewen16 小时前
我用 AI 一周写完了整个项目,上线第一天就崩了——这是我踩过最贵的 5 个坑
前端·javascript·ai编程