事件的循环和异步

发布时间:2026/7/10 6:05:20
事件的循环和异步 事件的循环和异步的综述我们首先需要搞清楚事件 循环 异步 这三个东西是什么。事件在第一部分开头就讲过了。而循环和异步甚至包括事件有不少朋友都是糅杂在一起讲的。把它们在概念上分清有助于我们更好的学习。事件 Event事件本身没有任何执行代码的能力。在第一部分我们花了很大的篇幅去讲解事件的本体也就是 C 底层那个庞大的结构体以及在 JS 层包裹它的那个“代理壳”Proxy Wrapper。事件的本质是系统在某个物理瞬间或逻辑节点发出的一个被动信号。当鼠标在屏幕上精准点击了某个按钮底层操作系统路由了硬件中断通过进程间通信通知了浏览器最终在内存里实例化出了一个 MouseEvent 对象。这个对象上密密麻麻地盖满了印章内部插槽点击的绝对坐标 [[screenX]]、相对视口坐标 [[clientX]]、事件类型 [[type]]: “click”以及预先计算出来的、锁死在 [[path]] 插槽里的那条从 window 顺流而下再反弹回来的物理传播路径。这一整套动作只是在表明了一个事实有事发生了。它是一个现场数据载体但它自己动不了。它躺在内存里就像是一本写好了“第 3 场第 4 幕主角被刺”的剧本。至于谁来演、什么时候演、事件自己一概不管也管不着。异步 Asynchrony很多初学js的朋友以为异步是某种高深的多线程并发技术其实不然。对于单线程的 JavaScript 来说异步纯粹是一种在时间维度上的执行策略。它的核心逻辑用一句话概括就是“把当下不能立刻完成、或者代价极其高昂的活儿先在后台登记下来把当前的主线程执行权让出来拆给未来去干。”在传统的同步世界里执行是死板的。如果有一步需要发起网络请求去获取一个 10MB 的大文件主线程就必须在原地干等着直到数据返回。此时整个程序停摆这就是“阻塞Blocking”。而异步策略则灵活得多主线程引擎一看这个网络请求不知道什么时候才能响应。于是它把实际的网络 I/O 工作交给了宿主环境的网络线程并在通讯录上登记一下“等网络线程把数据拿回来了触发这个回调函数。” 随后主线程立刻转头去执行后面的同步代码。异步的精髓就在于“发起登记 - 移交控制权 - 后台等待 - 未来触发”。它是一种让资源利用率最大化的智慧保证了单线程永远在做有意义的运算而不是死等。异步主要解决的是I/O 密集型任务的阻塞问题。对于 CPU 密集型任务单线程的 JavaScript 本身仍然会阻塞这时候需要使用 Web Worker 等技术。事件循环 Event Loop这是第四部分真正的主角首先要明确事件循环不是 JavaScript 引擎比如谷歌的 V8的一部分。在 V8 引擎源码中会看到精妙的解释器、优化编译器以及处理调用栈Call Stack的机制。但是在 V8 里面绝对找不到任何关于 setTimeout 队列、网络请求回调或者事件循环 while 循环的底层实现。JS 引擎本身是一个极度纯粹的执行机器。 比如 chrome中的V8 只负责解析、编译和执行 JavaScript 代码以及垃圾回收和内存管理。所有与外部世界的交互 —— 定时器、网络请求、DOM 操作、事件监听 —— 全部由宿主环境提供。而js引擎比如v8只有看到眼前的同步代码当前的执行上下文栈时才会开始疯狂工作直到把眼前的逻辑跑完、调用栈彻底清空。一旦栈空了JS 引擎就会陷入无所事事的休眠状态它自己既不知道接下来还有没有网络请求要来也不知道用户有没有点鼠标。真正让这一切运转起来的是包裹在 JS 引擎外层的宿主环境Host Environment。在浏览器里这个宿主是 Blink 渲染引擎和底层的多线程架构在 Node.js 里则是异步事件驱动库 libuv。而事件循环就是宿主环境派驻在 JS 引擎身边的一个“总调度师”。浏览器作为宿主环境在 JS 引擎之外维护着多个独立的线程定时器线程、网络 I/O 线程、UI 渲染线程等等。当外面的底层线程把活儿干完了总调度师就会把对应的 JS 回调代码塞进他手里的“任务队列”。总调度师的工作极其机械死板它持续监控着 JS 引擎的调用栈。只要 JS 引擎一跑完眼前的代码把调用栈空出来总调度师就会立刻走过去翻开任务队列抽出下一个排在首位的任务塞到 JS 引擎的执行栈里“歇够了没该处理这段逻辑了马上执行”注意这里说的 任务队列 并不是一个单一的队列而是包含了不同优先级的多个队列其中最重要的就是我们后面还会详细讲解的宏任务队列和微任务队列关于任务队列在第一部分开头也有讲过。task queue / microtask queue 这里为了方便理解暂时沿用‘宏任务’和‘微任务’这两个常见说法。现在我们将这三个概念放在在一起事件 (Event) 负责“宣告发生”它是底层现场数据的静态快照。异步 (Asynchrony) 负责“延后交接”它是跨越时间的执行与状态挂起策略。事件循环 (Event Loop) 负责“统筹轮转”它是宿主环境在宏观上掌控的排班排程制度。它们三位一体严丝合缝地配合在一起。事件在外部触发异步将结果包装后在队列里排队而事件循环在中间掌控传送带的节奏将一个又一个任务送入 JS 引擎的处理流水线。正是因为宿主环境在外部打好了这套精妙的调度配合拳JavaScript 这个单线程的执行器才能在前端庞大、复杂的动态世界里撑起丝滑流畅的宏大场面。2.dispatchEvent返回之后在第三部分的结尾随着事件派发之车跑完全程所有的 capture捕获和 bubble冒泡监听器依次执行完毕dispatchEvent 函数终于返回了。但是事件处理并没有画上句号从底层系统的视角来看真正的关键时刻此时才刚刚开始。当同步代码的战斗完成留在战场上的并不是风平浪静而是一个需要清理和调度的庞大状态网。2.1 执行栈 Execution Context Stack 和 微任务检查点我们先看看主线程的核心工作区域 执行上下文栈 Execution Context Stack即调用栈。当事件相关的回调函数被压入调用栈并执行完毕后调用栈会一层层退散。但是dispatchEvent 函数本身的返回并不绝对等于执行栈彻底清空。这里我们需要精确的描述出三条不同的物理路径它们在底层的处理逻辑有着本质的区别用户真实点击原生物理交互这是纯正的异步调度。当用户在硬件上完成点击浏览器将其包装成一个宏任务推入任务队列。当这个任务出队并在主线程上执行时最外层并没有其他 JS 脚本在压着栈。因此当 dispatchEvent 执行完毕返回、且该任务对应的领域执行上下文realm execution context被弹出时在规范的抽象状态机判定中JavaScript 执行上下文栈将彻底回归到为空Empty的状态。注领域realm是规范中的概念对应一套完整的全局内置对象体系。有助解释不同脚本/模块之间的原型链与全局隔离等高级语义如 iframe 之间的差异。大多数情况下Realm规范概念 ContextV8 物理实现。脚本触发的点击调用 element.click()这是一种“激活行为”它的本质是一次同步的函数调用。当你在一段正在运行的脚本中写下 btn.click() 时浏览器会无缝切入事件派发流程。这就表示当派发结束、click() 返回时控制权只是交还给了外层那段还没跑完的同步代码。此时执行栈并没有空最初触发它的那行脚本依然在栈底压着。手动派发事件调用 element.dispatchEvent(event)这属于纯粹的合成事件派发Synthetic Event Dispatch。它和 click() 类似同样是完全同步的会直接在当前的执行栈里压栈执行。但它与 click() 的核心区别在于element.click() 会同时触发事件派发和浏览器原生默认激活行为如复选框勾选、链接跳转而 element.dispatchEvent(event) 仅会执行纯粹的事件派发流程绝对不会自动触发与该元素关联的原生默认激活行为。那么我们为什么要如此精确精准的描述执行栈的清空状态呢因为在 WHATWG 规范中存在一个极其严格的判定关卡——微任务检查点Microtask Checkpoint。微任务如 Promise.then 的回调、MutationObserver并不是写下就会立刻执行的。规范给出的底层规则是微任务的就地清空由 HTML 规范的 Clean up after running script 算法死守。只有当一个独立的回调执行完毕、JavaScript 执行上下文栈彻底为空且当前未处于“正在执行微任务检查点”的保护状态时宿主环境才会立刻拉响警报触发微任务检查点。一旦满足条件主线程就会立刻停下宏观的任务轮转转头去把微任务队列里的所有积压一口气清理干净。注意这里规范会开启一个底层的重入保护锁将 performing a microtask checkpoint 标志位置为 true这个锁的作用是防止微任务检查点被递归调用避免出现栈溢出如果在清空微任务的过程中又动态追加了新的微任务它们会被继续挂载到队列末尾并在本次检查点中被强制解决掉直到队列连个渣都不剩。如果执行栈因为 btn.click() 或手动 dispatchEvent 被外层脚本压住微任务就只能憋在队列里继续等待。2.2 其他宏任务正在排队就在主线程认真的在执行栈里处理 dispatchEvent 的这几毫秒甚至几十毫秒内外面的世界也是同样在忙碌。主线程在执行同步代码时对外界的变化是无法即时响应的。但是包裹着它的宿主环境浏览器是一个庞大的多线程架构。在这个短暂的时间差里后台的各个线程可能已经发生了许多事情网络 I/O 线程刚刚把一张图片下载完触发了 load 数据的就绪信号。定时器线程发现一个 setTimeout 的 1000ms 倒计时刚好归零。用户烦很大的又在键盘上敲了一下引发了新的输入信号。这些在后台已经干完的活儿主线程此时分身乏术。于是宿主环境会将它们的回调逻辑分别包装成一个个崭新的宏任务Macrotask悄悄地塞进对应的任务源Task Source队列中在门外静静地排队等候临幸。2.3 内存树已变与渲染时机在刚才的事件回调里你的代码可能执行了类似 button.style.backgroundColor ‘red’ 或者 document.body.appendChild(div) 这样的操作。这里要注意执行完这些代码的瞬间屏幕上的颜色并没有变成红色新的元素也没有立刻出现在显示器上。主线程对 DOM 的修改仅仅是同步改变了浏览器内存中逻辑数据树DOM Tree和样式规则树CSSOM的状态。修改内存是在极短时间内同步完成的但这并不意味着浏览器会立刻把变化“画”到显示器的像素点上。从高层流程上理解页面的视觉更新需要走一遍完整的渲染管线重新计算样式Style、重排布局Layout、绘制图层Paint以及最终的图层合成Composite。但从底层调度来看浏览器的渲染是由自身的事件循环和“渲染时机Rendering Opportunity”共同决定的。浏览器非常精明它通常会结合显示器的刷新率例如 60Hz 对应约 16.6ms 一帧来评估性能。如果你在一个宏任务里连续修改了 100 次颜色浏览器也绝对不会频繁走 100 次渲染管线。它会等待当前的同步任务执行完毕并清空所有微任务然后在本次事件循环的渲染评估阶段判定“现在到了适合刷新画面的时机”才会启动渲染更新。而我们常说的 requestAnimationFramerAF正是在浏览器决定重绘的渲染管线启动前夕、样式计算和布局之前被集中调用的特权拦截器。2.4 宿主调度器重新接管综合以上所有的内部状态我们可以得出一个至关重要的结论当程序的“这一轮”同步逻辑宣告结束时主线程并不会自己凭空旋转着去寻找下一段代码。 负责执行的底层引擎本质上只是一个高级的运算器它内部并没有掌控全局的死循环。当微任务打扫完毕、渲染评估完成后如果没有新的宏任务就绪JS 引擎就会暂时陷入静默的休眠状态等待调度器唤醒。真正让整个网页保持生命力、决定“下一轮”该谁上场的是宿主环境的调度器Scheduler。在这一刻调度器重新接管了最高控制权。它严格按照 HTML 标准中规定的事件循环标准处理模型Processing Model节拍开启了一轮极其精准的状态盘点宏任务落幕当前作为运行单元的宏任务彻底宣告终结并将其从当前执行任务清空。宏观微任务清算在宏任务结束后强制触发一次完整的 Microtask Checkpoint。由于宏任务已结束此时执行栈必然为空宿主环境会彻底强行清空整个微任务队列包括执行期间新产生的微任务。渲染时机评估与更新盘点当前距离上一次屏幕刷新过去了多久。如果刚好命中了硬件的刷新节拍Rendering Opportunity则立刻启动渲染更新步骤依次执行当前的 rAF 回调并无缝推送页面走完计算样式和重排布局的渲染管线。下一个宏任务决策审视各个不同优先级任务源队列Task Source Queues的积压情况根据实现定义的调度策略现代浏览器通常优先抓取更紧急的用户交互任务以保证响应性选择最老的可运行任务重新将其推入 JS 引擎主线程执行。3.事件循环到底是在循环什么很多前端初学者在被问到“什么是事件循环”时脑海中往往会浮现出这样一幅画面JavaScript 引擎比如 V8的底层源码里写着一个类似 while(true) 的无限循环。它就像一个不知疲倦的纺车没日没夜地在原地疯狂空转疯狂地去扫描任务队列里有没有新代码一旦抓到就立刻烧热 CPU 去执行。这个认知在底层逻辑上是完全错误的。在底层的真实世界中事件循环不是 JS 引擎内部的空转而是宿主环境浏览器 Blink 引擎 / Node.js 的 libuv精心设计的一套“调度协议”与“状态盘点制度”。 在没有任务处理时事件循环不仅不会空转反而会通过底层的操作系统内核如 Linux 的 epoll、macOS 的 kqueue 或 Windows 的 IOCP将主线程彻底挂起进入休眠状态让出 CPU 资源。只有当底层硬件中断、网络数据包到达或定时器到期时主线程才会被操作系统瞬间唤醒。3.1 非抢占式调度和调用栈的绝对控制权要理解事件循环在循环什么首先要明白主线程在运转时的核心规则JavaScript 的代码执行是极其依赖执行上下文栈Call Stack即调用栈的。而这个执行机制是典型的“协作式调度Cooperative Scheduling”即常说的“非抢占式”。什么是协作式非抢占式在操作系统的层面一个线程正在算账操作系统可以通过硬件中断强行把这个线程掐断把控制权夺走去干别的这叫抢占式。但 JavaScript 主线程不吃这一套它遵循的是运行至完成Run-to-completion的特性。当宿主环境把一段 JS 脚本推上主线程V8 引擎开始解析并压栈执行的瞬间在 JavaScript 的逻辑层面只要当前调用栈中仍有代码正在执行 宿主环境就无法插入新的js任务无法从外部打断它哪怕此时用户把鼠标点烂了引发了海量的物理中断哪怕此时网络下载完了一百个文件急需触发回调宿主环境浏览器也只能在旁边默默看着绝对无法在函数执行的中途强行插播代码。在单线程的限制下宿主环境想要重新夺回主线程的控制权、去看看“接下来的大局该怎么安排”唯一的契机就是当前 JavaScript 执行上下文栈彻底变为空Empty的瞬间。注意栈空后引擎会先清空微任务队列随后控制权才真正交还给下一步调度。只有当当前的脚本跑完最后一个函数弹出调用栈控制权才会从 V8 引擎的手里交还给宿主环境的调度器如浏览器的 Blink Scheduler。在这个绝对空白的转折点上宿主环境才有机会真正睁开眼睛开启它的状态盘点。3.2 循环的本质是“状态盘点”事件循环所谓的“一圈”绝非无脑的代码扫描本质上是宿主环境在控制权交还的空白间隙严格按照 WHATWG HTML Living Standard 规范中的事件循环处理模型Processing Model进行的一场高密度的状态盘点与大清算。它手里拿着一张严密的底层清单按部就班地核对以下四个维度的时空状态一 宏任务源盘点Task Source Check在规范的严谨定义中并不存在一个名叫“宏任务队列”的单一实体。规范使用的核心术语是 任务源Task Source例如DOM 操纵源DOM manipulation、用户交互源User interaction、网络源Networking、导航与遍历源Navigation and traversal等。规范规定每一个 Task Source 必须关联到一个具体的 Task Queue任务队列。但在浏览器具体实现时为了优化调度可以将多个不同的 Task Source 塞进同一个物理的 Task Queue 里例如将网络任务源与导航任务源合并到同一个物理任务队列中。此时调度器会在这里进行实现定义Implementation-defined的优先级裁决。通常情况下为了保证页面的丝滑响应与防冻结浏览器会尽量优先处理用户交互相关任务。调度器会首先以实现定义的方式从多个任务队列中选出一个队列然后从该特定队列中取出最老的一个可运行任务Runnable Task将其推入主线程的调用栈。规范在此处有一个硬性限制无论采用何种优先调度策略同一任务源Task Source内部的任务顺序绝对不可被打乱。二 微任务全面清算Microtask Checkpoint当上一步的宏任务执行完毕调用栈再次归零的瞬间调度器会立刻切入微任务检查点Microtask Checkpoint。这里要注意微任务检查点不仅会在任务执行结束后触发。根据 HTML Standard 中的 Clean up after running script 算法在脚本或回调函数执行完毕且判定执行栈为空时同样会触发微任务检查点。在这一步中调度器会盯着事件循环的微任务队列Microtask Queue开始清空。这个盘点是非常彻底的它循环从队列中取出最旧的微任务交由引擎执行直到队列为空。如果在清空微任务的过程中动态追加了新的微任务它们会被继续挂载到当前队列末尾并在本次检查点中被继续执行。前面也讲过了这里再次重复一下规范通过将 performing a microtask checkpoint 标志位置为 true重入保护锁防止了清空算法在嵌套执行时被重复进入。但这并不影响当前正在运行的微任务清空循环如果在执行过程中动态追加了新的微任务它们会被挂载到队列末尾并在当前循环中立即执行直到队列彻底清空。三 渲染时机评估Rendering Opportunity微任务彻底打扫干净后主线程将迎来事件循环中最精明的一个阶段。调度器会开始盘点时间的流逝“当前文档是否迎来了渲染时机Rendering Opportunity”需要注意是否渲染是针对每个文档Document和可导航上下文Navigable独立评估的绝不是事件循环每转一圈就盲目重绘一次。调度器在宏观上会进行多维过滤目标文档当前是否处于可见状态例如 visibilityState 是否为 hidden距离上一次渲染是否过于接近尚未到达显示设备的下一次刷新节拍本轮更新是否根本不会产生任何可见效果注JavaScript 的执行速度极快事件循环Event Loop一秒钟可能转了几千圈。但是绝大多数普通显示器的硬件刷新率是 60Hz每秒刷新 60 次也就是大约每 16.6 毫秒屏幕才会物理重绘一次。如果评估结果判定当前不适合进行渲染更新调度器会直接跳过本次视觉渲染流程迅速开启下一轮任务提取。反之如果浏览器判断当前存在渲染时机它就会进入规范中的 Update the rendering 流程。关于渲染更新的内容我们在后面将专门用一小节来进行详细讲解。四 空闲节拍盘点Idle Period如果当前窗口的事件循环中已经没有任何可运行的任务No Runnable Task意味着主线程在这一刻彻底闲了下来。面对下一个硬件刷新信号到来前的这段珍贵间隙且浏览器判定当前适合执行低优先级工作时调度器便会开启空闲周期Idle Period。在进入空闲周期前调度器会利用标准算法盘点出一个极其精确的剩余时间截止牌Deadline。这个 Deadline 的计算极为严格它首先给出一个默认上限——“当前时间 50ms”以确保一旦用户突然产生新的输入主线程能够在合理时间内迅速响应随后它会去翻看定时器账本找出所有待处理定时器中最早到期的那个时间点如果此时还存在即将到来的渲染机会它还会结合下一次渲染节拍进一步收紧时间预算。调度器会综合这些时间点并取其中最早的那个作为最终 Deadline。最终计算出的安全截止时间会被注入到 IdleDeadline 对象中。调度器带着这个截止牌开始按序调用那些排在空闲队列里的 requestIdleCallbackrIC回调。回调函数中的代码可以通过 deadline.timeRemaining() 实时查看自己还剩余多少可用时间。一旦发现时间预算即将耗尽就应主动结束当前工作并交还控制权从而确保这些低优先级后台任务不会拖累高优先级的用户交互与流畅的动画渲染。这里我们需要详细的说一下在某一轮事件循环中当微任务被清空后进行渲染更新的判断当判断结果为不需要那么就继续事件循环看任务队列取任务如果队列中没有可执行的任务了那么就进入空闲周期首先给出初始的50毫秒然后看是否有定时器假如没有定时器那么就看是否有待定渲染 pending render假如有那么就看下依次渲染边界在60赫兹下是16.7毫秒。可能有朋友会疑惑在进入空闲周期之前渲染更新也判断过了队列里也没有可执行任务了这才刚进空闲周期啥也没干呢怎么就又要判断是否有待定渲染 这个待定渲染是什么在微任务清空后update the rendering 判断的是本轮次循环的渲染机会就是要不要真的更新渲染。进到空闲周期以后 **pending render 是在问 **“同一个事件循环里是否还存在需要保留渲染边界的页面/窗口状态”在规范层面一个“Window event loop”是可以服务于多个“Document”。比如主页面 A.com 中嵌入了一个同源的 iframe B.com那么很大可能它们就是同享一个主线程同一个事件循环。浏览器通过自己对待定渲染的判断加上是否有定时器然后把可用的空闲周期的时间Deadline从最初的50毫秒按照最小的时间收紧。假如页面完全静止 无定时器 无任何待定渲染空闲周期为50毫秒。假如刚刚渲染完毕就进到了空闲周期60赫兹下此时距离下次硬件刷新还剩16.7毫秒无定时器浏览器判断有待定渲染那么空闲周期为16.7减去115.7毫秒的空闲周期减去的1毫秒为渲染准备时间规范没说具体值但一般实现 比如chrome 都是使用1毫秒的渲染准备时间。假如60赫兹下 距离下次硬件刷新还剩8毫秒进到了空闲周期无定时器浏览器判断有待定渲染那么空闲周期为8-17毫秒。最后是一个边界情况假如最后算出的Deadline小于等于0那么就直接跳过了空闲周期。至此事件循环的一次完整“盘点”便宣告结束。它并不是一个在后台疯狂旋转、不断扫描代码的无脑 while(true)而是一套由任务提取、微任务清算、渲染评估以及空闲调度共同组成的精密状态机。每当调用栈清空、控制权重新回到宿主环境手中时这套盘点制度便会再次启动周而复始地维持整个 Web 世界的运转。3.3 僵尸的入土时机我们在第三部分讲解 removeEventListener 的底层原理时我们提到过一个辣手的并发场景假设在一个事件派发Dispatch的遍历执行过程中前一个回调函数竟然把紧排在它后面的监听器给注销了这会引发致命的数组遍历索引越界Concurrency Bug。为了解决这个问题浏览器底层采用的是一种极其克制的“软删除”策略当你在 JS 代码里调用 removeEventListener 时底层的 C 引擎并没有立刻把那个监听器项从内存数组里物理剔除。它只是温柔地在那个表项上打了一个勾removed: true。这种软删除策略虽然完美保障了正在执行的事件派发不被打断但也留下了一个悬念这些已经在逻辑上宣告死亡的“僵尸监听器”它们极其厚重的 C 结构体、JS 包装壳以及缠绵在一起的闭包Closure上下文到底在什么时候才能真正入土为安释放内存这就是一场跨越两大引擎的精密联动。第一步派发落幕与宿主的“名册清洗”有些初学的朋友误以为这些标着 removed: true 的僵尸会一直赖在监听器列表中直到触发垃圾回收。事实并非如此。