异步编程全景与事件循环——彻底搞懂 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)是每栋房子里的紧急快递------先派送完快递,才轮到下一栋房子。

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

相关推荐
To_OC8 小时前
别再串行写 await 了,Promise.all 才是并行请求的正确打开方式
前端·javascript·promise
To_OC8 小时前
LC 17 电话号码的字母组合:我的回溯算法,就是从这道题开窍的
javascript·算法·leetcode
vipbic8 小时前
中后台越做越乱后,我用插件化把它救回来了
前端·vue.js
Hyyy8 小时前
Computer Use 适合做什么,不适合做什么——一次真实使用后的思考
前端
小和尚同志9 小时前
前端 AI 单元测试思考与落地
前端·人工智能·aigc
invicinble10 小时前
c端系统,其实更像一个信息展示平台
前端
你怎么知道我是队长10 小时前
JavaScript的变量和数据类型介绍
开发语言·javascript·ecmascript
李姆斯11 小时前
管理是否可以被完全量化
前端·产品经理·团队管理
名字还没想好☜11 小时前
Next.js 中间件实战:鉴权、重定向与 A/B 分流
开发语言·前端·javascript·中间件·react·next.js
广州灵眸科技有限公司12 小时前
瑞芯微RV1126B开发板(EASY-EAI-PI2) INI文件操作
java·前端·javascript·网络·人工智能