JavaScript 事件循环:单线程异步编程的核心机制

一、JavaScript 单线程特性

JavaScript 采用单线程执行模式,这是由其设计初衷决定的:

  1. 避免 DOM 操作冲突:JavaScript 可以操作 DOM 结构,如果多个线程同时修改同一个 DOM,会导致渲染不一致和数据竞争问题。
  2. 简化编程模型:多线程需要引入锁机制来协调资源访问,单线程无需考虑锁机制,降低了编程复杂度,减少了设备性能开销。

二、js任务分类

JavaScript 中的任务分为两大类:同步任务异步任务

2.1 同步任务

同步任务在计算机性能足够时几乎不耗时 ,它在主线程上立即执行 ,前一个任务不完成,后一个任务不会开始。例如变量赋值、算术运算、console.log() 等都是同步任务。

2.2 异步任务

异步任务是指需要等待特定条件触发后才能执行的任务,它们会被存放到任务队列中,等待主线程执行完同步任务时再执行。异步任务按耗时长短又分为两大类:

宏任务

异步任务中耗时相对较长的任务,会被存放到宏任务队列中,主要有以下几种:

  • script(script标签也属于一个宏任务)
  • setTimeout() / setInterval()
  • I/O 操作(文件读写、网络请求)
  • UI 渲染

微任务

异步任务中耗时相对较短的任务,会被存放到微任务队列中,微任务相较于宏任务优先执行。主要有以下几种:

  • Promise.then() / Promise.catch() / Promise.finally()
  • process.nextTick()(Node.js)
  • MutationObserver

以下面这段代码为例,可以直观看到同步与异步的划分:

javascript 复制代码
let a = 1
console.log(a);                 // 同步:立即输出 1

new Promise((resolve) => {
  a = 2
  console.log(a);               // 同步:Promise 构造函数内部同步执行,输出 2
  resolve()
}).then(() => {
  a = 3
  console.log(a);               // 异步微任务:等待同步代码全部执行完再执行,输出 3
})

setTimeout(() => {
  a = 4
  console.log(a);               // 异步宏任务:1 秒后才执行,输出 4
}, 1000)

执行顺序分析:

  1. a = 1console.log(a) 是同步任务,立即输出 1
  2. new Promise(...) 也是同步任务,a 变为 2,输出 2new Promise(...).then() 是微任务,进入微任务队列
  3. setTimeout(...) 进入宏任务队列,等待 1 秒
  4. 同步代码执行完毕,此时开始执行异步任务,从队列中取出微任务:.then() 执行,a 变为 3,输出 3
  5. 1 秒后,取出宏任务:setTimeout 执行,a 变为 4,输出 4

三、事件循环机制

事件循环(Event Loop)是 JavaScript 协调同步任务和异步任务的核心机制。JS 引擎执行代码的完整流程如下:

  1. 先执行同步任务(script标签属于宏任务),这个过程中,遇到异步就存入对应的队列中
  2. 去微任务队列中查找微任务,并将微任务全部取出来执行
  3. 有需要的情况下,就渲染页面(只发生在微任务清空后)
  4. 去宏任务队列中查找宏任务,并将宏任务取出一个来执行,此时宏任务内部可能产生新的宏任务或微任务 (也是下一次循环的开始)

关键要点:每执行完一个宏任务,都会清空微任务队列,再进行下一轮循环。微任务的优先级高于宏任务。

代码示例:

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

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

setTimeout(() => {
  console.log(5);
}, 1000);
console.log(6);

分析:

  1. console.log(1) ------ 同步,输出 1
  2. 创建 Promise,同步执行 ------ console.log(2) 输出 2,将 .then(...) 推入微任务队列
  3. setTimeout(..., 1000) ------ 将宏任务 A(1 秒后执行)加入宏任务队列
  4. console.log(6) ------ 同步,输出 6
  5. 「同步代码执行完毕」------ 开始清空微任务队列
  6. 执行 .then() ------ 输出 3setTimeout(..., 0) 将宏任务 B 加入宏任务队列
  7. 「微任务队列已空」------ 等待宏任务
  8. 宏任务 B(0 毫秒)计时器先到 ------ 输出 4
  9. 宏任务 A(1000 毫秒)后到 ------ 输出 5

从这个例子可以清楚看出:同步代码先跑完 → 微任务清空 → 宏任务按时间顺序依次执行。即使 setTimeout(..., 0) 写在了 .then() 里面,也要等外层宏任务完了、微任务完了,才会轮到它。

注意: 所有 setTimeout 共用同一个计时器体系,先进队列的不一定先执行,执行顺序取决于各自的延迟时间长短。例如上面的例子中,当定时器延迟分别为 0ms 和 1000ms 时,0ms 的宏任务先执行,1000ms 的后执行。

进阶示例:宏任务内嵌套微任务

当宏任务内部又产生了新的微任务和宏任务,事件循环会如何处理?

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

setTimeout(() => {
  console.log(2);
  new Promise((resolve) => {
    console.log(3);
    resolve()
  }).then(() => {
    console.log(4);
  })
  setTimeout(() => {
    console.log(5);
  }, 0)
}, 1000)

console.log(6);

逐步骤分析:

  1. console.log(1) ------ 同步,输出 1
  2. setTimeout(..., 1000) ------ 宏任务 A 加入队列
  3. console.log(6) ------ 同步,输出 6
  4. 「同步代码执行完毕,微任务队列为空」------ 等待宏任务
  5. 1 秒后,取出宏任务 A 执行:
    • console.log(2) ------ 输出 2
    • 创建 Promise,同步执行 ------ console.log(3) 输出 3resolve().then() 加入微任务队列
    • setTimeout(..., 0) ------ 宏任务 B 加入队列
  6. 「宏任务 A 执行完毕」------ 立即清空微任务队列
  7. .then() 执行 ------ 输出 4
  8. 「微任务队列已空」------ 取出下一个宏任务 B
  9. 宏任务 B 执行 ------ 输出 5

注意第 5、6、7 步:在宏任务 A 内部,console.log(3) 是同步代码、setTimeout(..., 0) 是新宏任务、.then() 是微任务。按照事件循环规则,宏任务 A 结束后,必须先清空微任务(执行 .then() 输出 4),才会去执行下一个宏任务 B(输出 5)。所以 4 出现在 5 之前,而不是 2, 3, 5, 4

四、async/await 原理

async/await 是 Promise 的语法糖,让异步代码写起来像同步代码,但底层依然遵循事件循环规则。

核心规则

  • 函数前加 async,等同于该函数返回一个 Promise 对象
  • await 后面的表达式看作同步,然后将 await 之后的代码推入微任务队列

使用async/await 可以代替.then().then()的方式执行代码

javascript 复制代码
function A() {
  return new Promise(() => {
    setTimeout((resolve) => {
      console.log('a');
      resolve()
    }, 1000);
  })
}

function B() {
  console.log('b');
}

async function fn() {
  await A()   // 等待 A() 的 Promise resolve
  B()         // await 之后的代码,等价于 .then(() => B())
}
fn()

fn() 内部,await A() 会暂停 fn 的执行,等 A() 返回的 Promise 在 1 秒后 resolve,再继续执行 B()。这个效果等价于 A().then(() => B())

进阶示例:

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

async function async1() {
  await async2()
  console.log('async1 end');    // 微任务
}
async function async2() {
  console.log('async2 end');
}
async1()

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

new Promise((resolve, reject) => {
  console.log('promise');
  resolve()
})
  .then(() => {
    console.log('then1');
  })
  .then(() => {
    console.log('then2');
  });

console.log('script end');

逐步骤分析:

  1. console.log('script start') ------ 同步,输出 script start
  2. 调用 async1(),执行到 await async2() ,看作同步
  3. async2() 同步执行 ------ console.log('async2 end') 输出 async2 end
  4. awaitasync1 中后续代码(console.log('async1 end'))推入微任务队列
  5. setTimeout(..., 0) ------ 宏任务加入队列
  6. new Promise(...) 同步执行 ------ console.log('promise') 输出 promiseresolve() 将第一个 .then() 推入微任务队列
  7. console.log('script end') ------ 同步,输出 script end
  8. 「同步代码执行完毕」------ 清空微任务队列:
    • 取出第一个微任务:console.log('async1 end') 输出 async1 end
    • 取出第二个微任务:console.log('then1') 输出 then1,并将第二个 .then() 推入微任务队列
    • 取出第三个微任务:console.log('then2') 输出 then2
  9. 「微任务队列已空」------ 取出宏任务
  10. setTimeout 回调执行 ------ 输出 setTimeout

输出结果: script start, async2 end, promise, script end, async1 end, then1, then2, setTimeout

关键点:

  1. await 之后的代码已经变成了微任务,所以 script end 会在 async1 end 之前输出。
  2. 多个微任务在同一次清空中按入队顺序依次执行(async1 endthen1then2)。
  3. 无论微任务有多少,都必须全部清空后才会执行宏任务 setTimeout
相关推荐
小小龙学IT1 小时前
Midscene.js:AI驱动的跨平台UI自动化革命
javascript·人工智能·ui
YHHLAI1 小时前
告别传统开发!Bun + TS 解锁前端新体验
前端
拾年2751 小时前
Bun:重新定义 JavaScript 运行时 - 为什么它可能是 Node.js 的终结者?
javascript·typescript·bun
vim怎么退出1 小时前
Dive into React——调度/并发
前端·react.js·源码阅读
假如让我当三天老蒯1 小时前
React的children属性(自学用)
前端·react.js
秋天的一阵风1 小时前
AGENTS.md:你的AI代码助手,需要一份"项目说明书"
前端·后端·ai编程
rising start1 小时前
七、Vue Router
前端·vue.js·router
羊羊小栈1 小时前
停车场管理系统(基于前后端Web开发)
前端·人工智能·毕业设计·大作业
数据知道1 小时前
网站到底是如何通过JS读取你的浏览器指纹的?
开发语言·javascript·ecmascript·指纹浏览器