在 JavaScript 中,通过 事件驱动模型,结合系统的同志机制和内部任务调度。
一、核心机制:事件驱动 + 回调列队
浏览器处理异步事件(如用户交互、定时器等)的流程如下:
-
- 事件发生 :如用户点击、定时器到期
-
- ** 操作系统** / 浏览器内核通知
-
- 事件加入任务列队
-
- 事件循环处理列队
浏览器不主动扫描事件栈,而是由操作系统或内部计时器 主动通知 通知事件就绪
二、不同类型的触发原理
1. 用户交互事件
底层机制:
- 操作系统捕获硬件中断(如鼠标移动) ➞ 生成事件对象 ➞ 通知浏览器进程
- 浏览器通过 I/O 多路复用 (如 Linux 的 epoll , Windows 的 IOCP )监听机制, 无需轮询
2. 定时器事件( setTimeout/setInterval )
底层机制:
- 浏览器启动时创建 高精度计时器线程 (独立于 JS 主线程)
- 计时器到期时,由操作系统发送中断通知 ➞ 浏览器将回调加入任务列队
对于 setInterval() 安排下一次执行,情况稍微复杂一些,它并非等待上一次执行完毕后才安排下一次执行:
| 调度方式 | 工作原理 | 潜在问题 |
|---|---|---|
| 固定间隔调度 | 每个固定时间(如 1000 ms ),就会将一个回调函数放回宏任务队列, 无论上一个回调是否已执行完成 | 如果回调函数的执行时间超过了设定的间隔,会导致多个回调在宏任务队列中进行堆积,一旦主线程空闲,它们会连续快速的执行,造成“累积效应” |
| 非活跃标签页优化 | 为了节省资源, 大多数浏览器会对当前非当前标签的 setInterval 进行限流,比如将最小的间隔时间延长至 1000 ms | 这是浏览器的默认优化行为,需要注意其对京都要求高的任务(如动画)可能产生影响 |
为了避免固定间隔可能带来的累积问题(像动画的快进 ),一个常见的实践是使用 setTimeout 来模拟 setInternal 。 也就是说每次在回调内部,再次设置一个新的 setInterval 来安排下一个新的 setTimeout 来执下一次回调。但是在动画上可能造成 跳帧 现象。
3. 其他异步事件(网络请求、文件读写)
类似于定时器,由浏览器后台进程处理,完成后通过 时间通知 将回调加入列队。
三、事件循环(event loop)
理解异步逻辑的前提是了解 JavaScript 的事件循环机制,它决定了代码的执行顺序。
在执行 JavaScript 时,Javascript 运行时实际上维护了一组用于执行 JavaScript 代码的 代理 。每一个代理由一组执行上下文的集合、执行上下文栈、主线程、一组可以创建用于执行 worker 的额外线程集合、一个人任务列队以及一个微任务列队构成。除了主线程(某些浏览器在多个代理之间共享的主线程)之外,其他组成部分对该代理都是唯一的。
每个代理都是由事件循环驱动的,事件循环负责收集事件(包括用户事件以及其他非用户事件)、对任务进行排队以便在何时的时候执行回调。然后它执行所有处于等待中的 JavaScript 任务,然后是微任务,然后再开始下一个循环之前执行一些必要的渲染和绘制工作。
一共有三种事件循环:
- window 事件循环: window 事件循环驱动所有共享同源的窗口
- worker 事件循环: worker 事件循环驱动 worker 的事件。包括所有的 worker ,基本的
web worker、shared worker、service worker - worklet 事件循环: worklet 事件循环驱动运行的 worklet 代理。包含
Worklet、AudioWorklet、PaintWorklet
若当前没有任务代执行,事件进入 休眠状态 (不消耗 CPU ),直到新事件触发。
事件触发机制是由操作系统/浏览器内部线程 主动唤醒 事件循环(如用户点击、定时器到期)
1. 宏任务和微任务
一个任务就是指计划由标准机制来执行的任何 JavaScript,如程序的初始化、事件触发的回调。
- 宏任务(Macrotask):由宿主环境(浏览器/Node.js)产生,执行一段程序,执行一个事件回调或一个
interval/timeout被触发子类的标准机制而被调度的任意 JavaScript 代码 。如:setImmediate、setTimeout、setInterval、DOM event、I/O event - 微任务(Microtask):由 JavaScript 引擎触发,将在当前任务完成其工作后运行,并且在执行上下文的控制权返回浏览器的事件循环之前没有其他代码等待运行时运行。如
Promise.then/catch/finally、MutationObserver(该接口提供了监视 DOM 树所更改的能力)、queueMicrotask(将微任务加入列队以在控制返回浏览器的事件循环前的安全事件执行)
任务队列和微任务队列的区别很简单,但却很重要:
- 当执行来自任务队列中的任务时,在每一次新的事件循环开始迭代的时候运行时都会执行队列中的每个任务。在每次迭代开始之后加入到队列中的任务需要在下一次迭代开始之后才会被执行。
- 每次当一个任务退出且执行上下文栈为空的时候,微任务队列中的每一个微任务会依次被执行。不同的是它会等到微任务队列为空才会停止执行——即使中途有微任务加入。换句话说,微任务可以添加新的微任务到队列中,这些新的微任务将在下一个任务开始运行之前,在当前事件循环迭代结束之前执行。
2. 执行规则
- 每轮事件循环先执行当前宏任务
- 执行完当前宏任务后,清空所有的微任务列队(按入对的顺序执行)
- 最后执行下一个宏任务
四、补充
JavaScript 中的执行栈( Call Stack )是栈结构( LIFO :Last In , First Out ),而微任务列队和宏任务列队是列队结构( FIFO :First In , First Out ),而非“堆结构”。
在库存管理中,除了 LIFO 、 FIFO 还有 FEFO (过期先出, First Expire , Fist Out)
1. 执行栈( Call Stack ) -- 栈结构( LIFO:后进先出 )
执行栈是 JavaScript 引擎用来跟踪函数调用结构,遵循 后进先出(LIFO) 原则 :
- 当一个函数被调用时,它被压入栈顶
- 当函数执行完毕(返回),它从栈顶弹出
- 栈底时全局执行上下文(如
window或 Node.js 的global)
2. 任务列队(微任务/宏任务) -- 队列结构( FIFO :先进先出 )
微任务队列( Microtask Queue )和宏任务列队( Macrotask Queue )是 队列结构( FIFO : 先进先出 ) ,用于储存异步回调:
- 异步操作完成后,回调按“先到先得”顺序加入队列
- 事件循环每次从队列头部取出一个回调执行
但他们不是堆结构:堆( Heap )是一种动态内存分配区域(如对象,数组储存在堆中),与任务列队的逻辑结构无关。队列是“有序排序”,堆是“无序的内存块”,二者完全不同。
3. 堆(Heap) -- 内存分配区域,非任务队列结构
堆是 JavaScript 运行时用于动态分配内存的区域(如对象、闭包、大数组等),它与任务列队的“储存结构”无关。任务列队(微/宏)是逻辑上的“待执行回调列表”,用队列( FIFO )管理,而非堆。