JS事件深度解析四 事件的循环和异步 📅 2026/6/28 1:52:31 事件的循环和异步半年前写的这个js的事件系列一直没完结。中间又写了个V8引擎入门的系列也写到了执行部分。先把这个js事件系列写完。事件本身是强依赖浏览器的尤其是循环和异步所以在深度上可能会比前三部分略微深入一点。对V8感兴趣的朋友可以看我写的另一个系列 V8引擎精品漫游指南 。这是js事件系列的最后一个部分。一 事件的综述二 事件的完整生命周期三 事件的传播和处理四 事件的循环和异步这四部分已经能覆盖js事件的绝大部分内容了而且广度和深度都足够。因为距离前三部分完成已经过了半年有些术语或者概念我觉得重要的可能会再次解释有些知识点相关比喻可能会延续使用之前的。目录四、 事件的循环和异步1.事件的循环和异步的综述2.dispatchEvent返回之后3.事件循环到底是在循环什么4.任务5.微任务6.异步7.Promise 与 async/await8.任务的插队9.异步的代价10.事件循环和渲染11.例子12.附录-知识点一。事件循环的原材料仓库二。渲染更新 Update the rendering三。Promise 构造函数是同步执行的只有后续回调才是微任务四。async/await 不会阻塞主线程它是基于 Promise 与微任务的异步控制流五。setTimeout(fn, 0) 不代表立即执行而是尽快加入宏任务队列排队六。事件循环是宿主环境提供的机制而非 ECMAScript 规范本身七。异步不等于多线程并行它的核心在于非阻塞的控制权转移八。连续无节制地生成微任务会导致极其严重的性能阻塞九。物理事件发生的先后顺序不等于回调函数的最终执行顺序十。微任务无法打断正在执行的同步代码1.事件的循环和异步的综述我们首先需要搞清楚事件 循环 异步 这三个东西是什么。事件在第一部分开头就讲过了。而循环和异步甚至包括事件有不少朋友都是糅杂在一起讲的。把它们在概念上分清有助于我们更好的学习。事件 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 引擎主线程执行。