JS事件深度解析四 事件的循环和异步

四、 事件的循环和异步

半年前写的这个js的事件系列,一直没完结。中间又写了个V8引擎入门的系列,也写到了执行部分。先把这个js事件系列写完。事件本身是强依赖浏览器的,尤其是循环和异步,所以在深度上,可能会比前三部分略微深入一点。对V8感兴趣的朋友可以看我写的另一个系列 V8引擎精品漫游指南 。

这是js事件系列的最后一个部分。

一 事件的综述

二 事件的完整生命周期

三 事件的传播和处理

四 事件的循环和异步

这四部分已经能覆盖js事件的绝大部分内容了,而且广度和深度都足够。


因为距离前三部分完成已经过了半年,有些术语或者概念,我觉得重要的,可能会再次解释,有些知识点相关比喻,可能会延续使用之前的。


目录

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 这里为了方便理解,暂时沿用'宏任务'和'微任务'这两个常见说法。


现在,我们将这三个概念放在在一起:

  1. 事件 (Event) 负责"宣告发生",它是底层现场数据的静态快照。
  2. 异步 (Asynchrony) 负责"延后交接",它是跨越时间的执行与状态挂起策略。
  3. 事件循环 (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(规范概念) = Context(V8 物理实现)

脚本触发的点击(调用 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 次渲染管线。它会等待当前的同步任务执行完毕并清空所有微任务,然后在本次事件循环的渲染评估阶段,判定"现在到了适合刷新画面的时机",才会启动渲染更新。而我们常说的 requestAnimationFrame(rAF),正是在浏览器决定重绘的渲染管线启动前夕、样式计算和布局之前,被集中调用的特权拦截器。


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 对象中。

调度器带着这个截止牌,开始按序调用那些排在空闲队列里的 requestIdleCallback(rIC)回调。回调函数中的代码可以通过 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减去1,15.7毫秒的空闲周期,减去的1毫秒,为渲染准备时间,规范没说具体值,但一般实现 比如chrome 都是使用1毫秒的渲染准备时间。

假如,60赫兹下, 距离下次硬件刷新还剩8毫秒,进到了空闲周期,无定时器,浏览器判断有待定渲染,那么,空闲周期为8-1=7毫秒。

最后,是一个边界情况,假如最后算出的Deadline小于等于0,那么就直接跳过了空闲周期。

至此,事件循环的一次完整"盘点"便宣告结束。

它并不是一个在后台疯狂旋转、不断扫描代码的无脑 while(true),而是一套由任务提取、微任务清算、渲染评估以及空闲调度共同组成的精密状态机。每当调用栈清空、控制权重新回到宿主环境手中时,这套盘点制度便会再次启动,周而复始地维持整个 Web 世界的运转。


3.3 僵尸的入土时机:

我们在第三部分讲解 removeEventListener 的底层原理时,我们提到过一个手的并发场景:假设在一个事件派发(Dispatch)的遍历执行过程中,前一个回调函数竟然把紧排在它后面的监听器给注销了,这会引发致命的数组遍历索引越界(Concurrency Bug)。

为了解决这个问题,浏览器底层采用的是一种极其克制的"软删除"策略:当你在 JS 代码里调用 removeEventListener 时,底层的 C++ 引擎并没有立刻把那个监听器项从内存数组里物理剔除。它只是温柔地在那个表项上打了一个勾:removed: true

这种软删除策略虽然完美保障了正在执行的事件派发不被打断,但也留下了一个悬念:这些已经在逻辑上宣告死亡的"僵尸监听器",它们极其厚重的 C++ 结构体、JS 包装壳以及缠绵在一起的闭包(Closure)上下文,到底在什么时候才能真正入土为安,释放内存?

这就是一场跨越两大引擎的精密联动。

第一步:派发落幕与宿主的"名册清洗"

有些初学的朋友误以为这些标着 removed: true 的僵尸会一直赖在监听器列表中,直到触发垃圾回收。事实并非如此。

真实的情况是:当当前的事件派发循环(Event Dispatch Loop)刚一结束的瞬间 ,Blink 引擎(宿主环境)就会立刻展开行动。

底层的 C++ 代码会重新遍历一次 EventListenerMap,将所有带有 removed: true 标记的表项,从列表中物理剔除(Erase)

注意,前面一二三部分中,我们说的 [[eventlistenerlist]] 是规范中的术语, 现在说的EventListenerMap 是在 C++ 底层的真身。这里进行了区分。可以将它们认为是 一个是规范中的术语,一个是实现中的具体名字。

这一步极其关键,它相当于宿主环境主动剥离了联系,彻底斩断了 C++ 底层指向上层 JS 回调函数对象的"强引用指针"

第二步:两套独立的堆内存

虽然名册被清洗了,但此时,内存并没有真正释放。

在浏览器的复杂架构中,存在两套截然不同的内存管理空间:

  • Blink 的堆内存 :由 Blink 引擎自己的垃圾回收器(Oilpan )管理,存放 C++ 层的 RegisteredEventListener 对象。
  • V8 的 JS 堆内存:由 V8 GC 管理,存放 JS 层的函数对象以及极其吃内存的闭包上下文。

随着第一步强引用被斩断,这两个对象虽然还停留在各自的物理内存中,但在系统的逻辑图谱里,它们都已经沦为了没有任何根节点(GC Roots)指向的绝对孤岛

第三步:空闲节拍的后台清扫与物理超度

现在万事俱备,只欠 GC(垃圾回收)。

虽然 Oilpan 和 V8 GC 在内存有压力时随时可能触发回收,但在现代浏览器中,调度器更倾向于寻找一个"不打扰主线程主线任务"的完美时机------这就是事件循环的空闲周期(Idle Period)。

当事件循环走完了前面的部分(宏任务空了,微任务清了,渲染也搞定了),进入短暂的无所事事时期,Blink 调度器会通过一层底层的 API 桥梁,向 V8 引擎发送一个明确的空闲通知,并附带上精确的剩余时间配额:

v8::Isolate::IdleNotificationDeadline(deadline)

是不是感觉很熟悉? 这就是前面刚刚讲过的 空闲周期。

这行代码本质上是宿主环境在对v8下达命令:"现在彻底空闲了,距离下一次忙碌还有精确的 X 毫秒。带着你的保洁团队,趁现在赶紧去打扫战场!"

得到了时间配额,V8 引擎的垃圾回收机制(V8 GC)便利用这几毫秒的隙缝,开启了它的大扫除:

  1. 纯粹的可达性判定 :V8 严格的垃圾回收算法(可达性分析),开始顺着内存的引用链一路查下来。在 GC 的眼中,没有业务逻辑,它也不认识什么是 removed 标记,它只认冰冷的引用链路。
  2. 确认孤立:由于 Blink 早已在第一步清除了名册断开了 C++ 引用,且 JavaScript 用户层也没有任何变量持有这些监听器,V8 确认这些函数和闭包是彻底不可达的无用垃圾(Unreachable)。
  3. 终极的物理释放:随着垃圾清理程序的扫过,这些赖在堆里多时的 JS 包装壳与厚重的闭包包袱,终于迎来了物理层面的超度。它们占据的内存单元被彻底抹平,交还给操作系统。与此同时,Blink 的 Oilpan 也会在自己的调度下,将底层的 C++ 壳子一并回收。

至此,一个 JavaScript 事件结束了。

它在 C++ 底层被实例化,经历了捕获与冒泡,在同步的调用栈中爆发,在异步的宏任务队列里排队,在微任务检查点的闸门前博弈,最终在渲染管线后的空闲时间中,被底层的双引擎联合清除掉了。

这不仅是一个单纯的 API 机制,更是宿主浏览器、调度器、JS 引擎在时间和空间维度上,精密配合的操作流程。


4.任务

在理清了事件从信号触发到路径计算的宏观旅程,以及节点上监听列表的生存清理机制后,我们终于来到了整个宿主环境最核心的宏观调度核心------事件循环的处理模型(Processing Model)。

现在,我们翻开宿主调度器的排班表,来看看事件循环中绝对的台柱子:任务(Task,在前端开发中常被通俗地称为"宏任务",因为task和宏任务 这两个名称混用并没有什么歧义,所以我们并不严格区分它们)


4.1 什么是任务(Task)

从控制权的视角来看,任务是一个占用主线程执行权的、在逻辑上不可分割的完整运算单元。

规范里的事件循环每一轮,都会从某个 包含可运行任务的任务队列中,按实现定义的方式选出一个队列,再从中取出最老的可运行任务交给宿主环境执行;同一 task source 内的任务顺序不会被打乱。任务一旦开始执行,JavaScript 就会按"运行至完成(run-to-completion)"的语义,把当前同步逻辑连续跑完。

在单线程协作式(非抢占式)调度的机制下,任务的执行具备两个核心特征:

  1. 控制权的独占性: 无论这段任务内部有多少层嵌套函数,或者进行了多么复杂的循环运算,只要当前的执行栈还没有被彻底清空归零,主线程就无法被外部强行插播或抢占。此时,外界的一切物理中断、网络就绪信号、甚至是紧急的重绘请求,都必须在主线程大门外静静排队。
  2. 执行的完整性: 遵循 JavaScript 经典的"运行至完成"(Run-to-completion)语义。只有当这一个任务所有的同步代码全部运行结束,执行栈彻底归零时,这一轮任务才算真正告一段落。在此之后,最高控制权才会交还给宿主调度器,事件循环才拥有进入后续评估、清算微任务以及更新渲染(Update the rendering)的机会。

因此,单个任务的执行时长直接决定了网页的响应质量。根据 Long Tasks API Level 1 的定义,持续时间超过 50 毫秒 的任务会被视为 long task;这里的 50ms 是监测阈值,不是引擎的硬性切断点。JavaScript 引擎不会在达到 50ms 时自动中断代码,它只会一直运行到任务自然结束,而这期间事件循环就会被占住,页面便容易出现卡顿、掉帧和交互延迟。

4.2 任务的来源与任务源(Task Source)

JavaScript 引擎本身是一个极度纯粹的代码执行器,并不包含定时器或网络 I/O 的底层物理线程。事件循环里排队的所有任务,本质上都是由外层的宿主环境(在浏览器里是渲染引擎如 Blink,配合底层多进程架构)在处理完各类底层物理或逻辑事件后,异步推送进来的。

在 HTML 规范中,任务并不是简单地堆放在一个扁平的队列里,而是通过任务源(Task Source)进行分类。规范规定,来自相同任务源的任务必须按顺序进入相同的任务队列,但浏览器可以拥有多个不同的任务队列。

这里要注意,规范并没有将特定的任务源定义为绝对的"最高优先级"。 选择哪一个任务队列进行轮询完全由具体的实现(Implementation-defined)决定。但在实际的浏览器(如 Chrome)中,为了防页面冻结、提升交互响应性,调度器通常会更多更积极地去处理与用户交互相关的任务队列(如输入事件),并在后台任务队列快要"饿死"时进行防饥饿(Anti-starvation)调度。

这些任务源在规范中有着清晰的界定:

  • 用户交互任务源(User Interaction Task Source): 用于处理用户产生的物理输入,例如鼠标、键盘等交互行为。像 click 这样的输入事件,规范明确要求使用用户交互任务源来派发。keydown 这类输入事件也属于同一类用户交互相关任务。需要注意的是scroll 事件和普通输入事件不完全一样。 CSSOM View 明确说明,scroll 事件和 resize 事件会与 HTML 的事件循环集成,并且与 animation frames 同步。也就是说,滚动相关事件更像是跟着渲染帧节拍统一派发,而不是完全按普通任务那样"来了就排队、立刻执行"。
  • 计时器任务源(Timer Task Source): 对应 setTimeout()setInterval()。很多初学者会误以为写下定时器以后,JavaScript 会"等一会儿再继续执行";其实不是。主线程只是先向宿主环境登记一个定时要求,真正的倒计时由浏览器独立完成,时间到了以后,再把回调包装成任务放入对应队列。规范还规定:当定时器嵌套层级超过 5 层时,最小间隔会被强制限制为至少 4ms。
  • 网络任务源(Networking Task Source): 用于处理网络活动相关任务。比如 XMLHttpRequest 的状态变化、资源加载完成等情况,都会由宿主环境在网络结果就绪后,把相应回调安排进任务队列。网络请求真正耗时的部分不发生在 JavaScript 线程里,JavaScript 只是负责最终处理结果。
  • 导航与历史遍历任务源(History traversal task source): 用于处理浏览器导航、前进后退、会话历史遍历这一类工作。history.pushState(...) 这类操作可以理解为脚本层对历史状态的同步修改;它并不等同于一次真正的历史遍历任务。
  • 消息投递任务源(Posted message task source): 用于不同执行上下文之间的消息传递。例如 postMessage()MessageChannelMessagePort 这类机制,都会在目标上下文收到消息后,把对应的消息事件放入消息相关的任务队列中等待执行。

5.微任务

在前面我们讲了任务(Task)如何作为宿主环境的"外层传送带",把外部世界的动静一轮一轮送入主线程。然而,在主线程之内,JavaScript 运行时还藏着一条更细、更快、也更容易制造时序错觉的微观通道------微任务(Microtask)

在标准规范中,任务与微任务有着不同的分工与血统:任务负责宏观轮转,而微任务则由微任务检查点统一清算。如果说任务是宿主环境(浏览器 C++ 层)派遣给主线程的"外部劳务",那么微任务更像是 JavaScript 引擎内部(如谷歌 V8 的 v8::MicrotaskQueue)直接调度的嫡系子弟。它们的运行,不是靠"等下一个大活",而是靠"当前同步代码一旦退场,就立刻就地清理"。

5.1 最重要的关卡:微任务检查点(Microtask Checkpoint)

微任务并不是在任意时刻乱入的。它们被一道非常严格的闸门所控制,这道闸门就是 HTML 规范中的 perform a microtask checkpoint 算法。这个算法的核心特征只有一句话:一旦开始,就持续处理微任务队列,直到队列为空。

为了深入理解微任务检查点的运转,我们可以将其底层微观机制归纳为以下核心要点:

  1. 防重入保护锁(Re-entrancy Protection): 规范在实现上专门设置了一个名为 performing a microtask checkpoint 的内部布尔标志位。当检查点启动时,该标志位被置为 true。如果此期间有任何机制试图再次触发检查点,算法会直接闪避返回。这种设计是为了防止检查点在自己运行时又被自己递归触发,避免重入打架导致执行栈自我踩踏或栈溢出。
  2. "栈空"才是真正哨兵: 微任务检查点的触发时机并不是单一的"宏观任务边界"。更准确地说,HTML 的脚本清理步骤(Clean up after running script)会在 JavaScript 执行上下文栈为空时触发微任务检查点。标准特别指出,这类算法甚至可以在间接场景中重入运行(例如脚本派发了一个带监听器的事件)。也就是说,"执行栈变空"才是唤醒这支清道夫部队的真正哨兵,而不是"任务"这个大壳子本身。
  3. 先进先出(FIFO)与 一清到底 : HTML 的清空算法是严格按照 oldestMicrotask(最老微任务)的顺序依次取出并运行的。如果在执行某一个微任务的过程中,代码又动态追加(如再次调用 Promise.thenqueueMicrotask)了新的微任务,这些新兵会被直接塞进队列的末尾,并继续在当前同一轮检查点内被强制连续处决,直到队列彻底变为空为止。
  4. 长任务阻塞而不是死锁: 这种"清到见底"的机制也解释了为什么微任务一旦写成"无限补货"(例如一个无限自我调用的微任务环),就会形成非常危险的饥饿效应。过多的微任务会像海量的同步代码一样彻底占满主线程。此时,主线程长期停留在微任务清算阶段,事件循环无法向下轮转,导致浏览器无法做自己的事,包括无法响应交互、无法重绘画面。严格来说,这属于主线程被微任务长期占有所引起的"阻塞/饥饿",而不是传统多线程意义上的死锁。

微任务检查点并非只在"任务边界"触发。根据 HTML 规范,每次 JavaScript 回调结束且执行上下文栈为空时,都可能立即执行微任务检查点。因此,在同一次原生事件派发过程中,微任务也可能在监听器之间"插播"。这一机制正是很多异步时序差异与 bug 的来源。

任务边界不是唯一的清算点

很多事件循环教学会给出一个简化的规则:

"微任务在每个宏任务结束后、下一个宏任务开始前执行。"

这对理解 setTimeoutPromise 的优先级非常有用,但不能将其绝对化,因为容易把"任务边界"误当成唯一触发点,而忽略了"执行栈为空"这一真正关键的条件。


规范:clean up after running script

HTML 标准在 §8.1.4.3.2 Clean up after running script 中明确规定:

  1. ...
  2. If the JavaScript execution context stack is now empty , perform a microtask checkpoint.
    (如果 JavaScript 执行上下文栈现在为空,则执行微任务检查点。)

这表示:**任何一个 JS 回调执行完毕后,引擎都会执行清理步骤;只要此时 JS 栈为空,微任务检查点就会被触发。**这也解释了为什么在一些事件回调之间,微任务可能会被提前清算,而不必等到"整个任务结束"。


dom标准 :定义事件派发算法:按捕获-目标-冒泡顺序同步调用所有匹配的监听器,不负责插入微任务检查点。

html标准:定义脚本执行和清理规则:在每次 JavaScript 执行上下文栈清空时执行微任务检查点

DOM 事件派发算法本身并不负责微任务调度。它只是同步地逐个调用函数,而微任务检查点之所以可能在监听器之间出现,是因为每次调用返回后,控制权短暂回到宿主环境,HTML 的清理机制就可能立刻介入。

不要把"任务边界"当作唯一哨兵。真正需要盯住的是 clean up after running scriptJS 执行栈是否为空 这两个条件。在宏任务的每一个回调间隙------哪怕是原生事件的两个监听器之间------微任务都可能发动"闪电战"。正是这些隐藏的真空地带,构成了事件循环最精密、也最容易被误判的异步肌理。


5.2 微任务的来源

能够直接进入微任务机制的来源,主要可以理解为以下几个正经来源:

  • Promise 这是日常开发中最核心的微任务源。ECMAScript 规范把 Promise 的 thencatchfinally 这类后续动作包装成作业(Job),并通过 HostEnqueuePromiseJob 机制交给宿主环境。规范要求这些作业按调度顺序执行,在 Web 平台上,它们会被宿主归入微任务处理流程中。它们不是"立即执行",也不是"丢进宏任务排队",而是被放入微任务语境中等待清算。
  • queueMicrotask() 的显式接入: 这是 HTML 标准专门提供给开发者的显式人口。它的目的非常直接:允许你安排一个回调挂到微任务队列上,并在当前同步 JavaScript 全部跑完、执行栈下一次清空时尽快运行。它唯一的职责就是向微任务队列投递条目,由于它不会像 setTimeout(fn, 0) 或者是 postMessage 那样切换、让出控制权给新的任务,因此具有极高的时效性。
  • MutationObserver 的批量记账机制: 专门用于监听 DOM 树的结构变动。DOM 标准为它准备了 mutation observer microtask queued 这个内部布尔标志位和 pending mutation observers 集合。当 DOM 变动需要通知观察者时,规范会先把变动记录进记录队列,再派发(queue)一个微任务去统一通知这些观察者。也就是说,它不是每改一次 DOM 就立刻跑一次回调,而是先批量记账,再在微任务阶段统一发通知,以此换取极高的布局重绘性能。
  • 自定义元素生命周期Interaction(Custom Elements Specification): 这是一个不常被提起的补充点。HTML 的自定义元素规范中明确提到,即使工作发生在构造函数启动的微任务里,微任务检查点也可能在构造完成后立刻发生。这进一步证明了微任务并不只是"回调之后的附属机制",它还会深度参与并影响更底层的 DOM 构造与生命周期的时序判定。

在日常开发中,我们常常需要延后某些代码的执行。理解了任务与微任务的边界后,我们就对 queueMicrotask()setTimeout(0) 有了比较深入的了解:

  • setTimeout(fn, 0) 的本质是申请一个全新的计时器任务。它会强行命令宿主环境的定时器线程去任务队列末尾排队。当它执行时,意味着主线程已经至少经历了一次完整的事件循环轮转,甚至可能已经交出控制权走了一遍重绘视觉管线。它的代价是昂贵的,因为它跨越了任务的边界。
  • queueMicrotask(fn) 的本质是在当前任务的内侧边缘疯狂追加。它直接将回调挂载到当前的微任务队列末尾。根据处理模型,它会在当前任务结束、执行栈清空的脚本清理点被直接处决。它绝对不会让出执行权,也绝对不允许浏览器在中间插入任何渲染或别的宏观任务。它是一种纯粹在当前任务时空维度内的"内卷式"延后。

5.3 例子详解

JavaScript

复制代码
// 同一个 DOM 按钮,绑定了 A 和 B 两个完全独立的点击监听器
btn.addEventListener('click', () => {
    console.log('监听器 A');
    Promise.resolve().then(() => console.log('微任务 A'));
}, false);

btn.addEventListener('click', () => {
    console.log('监听器 B');
    Promise.resolve().then(() => console.log('微任务 B'));
}, false);

路径 A:原生物理点击(C++ 循环驱动)

当用户的鼠标指针或触控在屏幕上精准命中这个按钮,真实的硬件信号触发了交互任务源。整个事件的分派作为一个独立、干净的任务被推上了主线程:

  1. 第一步(C++ 掌控全场): 浏览器底层的 C++ 引擎(Blink)接管主线程,拉开按钮的监听器名册。DOM 标准定义了事件派发会同步遍历监听器列表,C++ 的派发循环(Event Dispatch Loop)迈出第一步。
  2. 第二步(调用监听器 A): C++ 引擎跨越底层桥梁,调用绑定的第一个回调。JavaScript 执行上下文栈(Call Stack)推入 Listener A 的栈帧。
    • 当前 JS 执行栈状态: [Listener A]
    • 引擎疯狂输出,控制台打印:'监听器 A'
    • 遇到 Promise.then,将 微任务 A 挂入微任务队列。
  3. 第三步(监听器 A 退出,控制权短暂交还): Listener A 运行完毕,它的函数帧从 JavaScript 执行栈中被彻底弹出。
    • 当前 JS 执行栈状态: []彻底变为空!
  4. 第四步(微任务检查点爆发): 控制权短暂地回到了 C++ 引擎的手里。在 C++ 迈向下一个监听器之前,HTML 规范的"脚本运行后清理(Clean up after running script)"算法被触发了。算法抬头一看:"当前的 JavaScript 执行栈现在竟然是空的!" 于是,守卫大门打开,微任务检查点开始
    • C++ 的派发脚步被就地强行挂起,主线程转头去狂扫微任务队列。
    • 控制台输出: '微任务 A'
  5. 第五步(调用监听器 B): 打扫干净战场后,C++ 引擎继续在当前事件派发任务内部推进它的循环,迈向第二站。它调用 Listener B。JavaScript 执行栈再次推入 [Listener B]
    • 打印:'监听器 B',产生 微任务 B 并挂入队列。
  6. 第六步(监听器 B 退出,二次清算): Listener B 弹出执行栈,JS 栈再次归零([])。脚本清理算法第二次检测到栈空,微任务检查点第二次爆发!
    • 控制台输出: '微任务 B'
  • 最终控制台输出顺序: 监听器 A --- 微任务 A --- 监听器 B --- 微任务 B

这叫"打完一架就地分一次战利品"。因为原生事件里控制权在 C++ 宿主与 JS 引擎之间反复横跳,每次监听器返回后,执行栈都已经完全清空。因此,规范完全允许并在机制上决定了原生事件下的多个监听器之间会出现微任务插播

路径 B:脚本触发 button.click()(JS 同步套娃)

现在,我们将代码场景重置。用户没有点击屏幕,而是在我们一段正在运行的业务脚本 app.js 内部,同步调用了代码:console.log('Before dispatch'); btn.click(); console.log('After dispatch');(或者调用了 dispatchEvent)。

  1. 第一步(始祖任务压栈): 事件循环取出执行 app.js 的初始任务。JavaScript 执行栈的底部,压着这段外层脚本的上下文。控制台首先打印:'Before dispatch'
    • 当前 JS 执行栈状态: [app.js]
  2. 第二步(遭遇同步激活点): 代码运行到 btn.click() 这一行。DOM 标准把这类显式 API 激活行为与 click 事件关联起来,明确说明它走的是一条同步激活路径,绝对不会重新开一个新的异步任务去队列排队。 执行栈在不退栈的情况下,直接在顶部追加压入 click 方法的栈帧。
    • 当前 JS 执行栈状态: [app.js, click()]
  3. 第三步(就地同步调用监听器 A): 派发引擎直接在当前堆栈的顶部,同步压入第一个监听器的栈帧!
    • 当前 JS 执行栈状态: [app.js, click(), Listener A]
    • 打印:'监听器 A',产生 微任务 A 并挂入队列。
  4. 第四步(监听器 A 退出,守卫拦截): Listener A 运行完毕弹出执行栈。
    • 当前 JS 执行栈状态: [app.js, click()]不为空
    • 此时,同样的脚本运行后清理算法被触发了。但算法一看:"栈还没空呢!外层调用它的 app.js 和 click() 还压在下面呢!" 守卫条件宣告失败,微任务检查点直接被拦截闪避,拒绝执行! 微任务 A 被卡在队列里不能动。
  5. 第五步(就地同步调用监听器 B): 套娃机制推进,引擎在不清理微任务的情况下,继续在栈顶同步压入第二个监听器。
    • 当前 JS 执行栈状态: [app.js, click(), Listener B]
    • 打印:'监听器 B',产生 微任务 B 并挂入队列。
  6. 第六步(监听器 B 与派发逻辑退出): Listener B 弹出,紧接着 click() 方法同步执行完毕也宣告弹出。控制权交还给外层脚本,控制台同步打印:'After dispatch'
    • 当前 JS 执行栈状态: [app.js](依然不为空)
  7. 第七步(最外层宣告落幕,延迟的大清算): 最终,最外层的 app.js 也终于运行到了最后一行代码,完美弹出执行栈。
    • 当前 JS 执行栈状态: [](经历了漫长的等待,执行栈终于彻底归零!)
    • 在这一瞬间,始祖级任务彻底终结。脚本清理算法终于检测到执行栈回归为空,被压制了整整一力场的微任务检查点在任务结束的边界上爆发,主线程一口气冲进队列,将积压多时的 微任务 A微任务 B 顺次全部处决。
  • 最终控制台输出顺序: Before dispatch --- 监听器 A --- 监听器 B --- After dispatch --- 微任务 A --- 微任务 B

这叫"必须把整座山头的人都揍完,才能统一分战利品"。因为最外层的主力部队(app.js)还在阵地上压着,主线程判定宏观的任务没有结束,所以中途任何脚本清理点都无法强跑微任务。微任务通常会被整体拖到最外层脚本结束之后的清理阶段统一执行。

两种场景的核心差异对比表

对比维度 场景 1:用户交互触发 场景 2:代码手动触发
触发方式 物理设备输入 JavaScript 代码调用 dispatchEvent
任务类型 独立的宏任务 同步函数调用,无新任务创建
执行栈状态 监听器执行完毕后栈为空 监听器执行完毕后栈仍有外层帧
微任务检查点时机 每个监听器执行完毕后立即触发 整个 dispatchEvent 完成且外层脚本结束后触发
微任务执行时机 穿插在多个监听器之间 所有监听器执行完毕后批量执行
行为一致性 所有浏览器完全一致 所有浏览器完全一致

我们来看一个更复杂的嵌套场景:

复制代码
const button = document.querySelector('button');

// 监听外层事件
button.addEventListener('outer-event', () => {
  console.log('Outer Listener');
  Promise.resolve().then(() => console.log('Outer Microtask'));
  
  // 在监听器内部同步派发一个不同的事件,以避免死循环。
  button.dispatchEvent(new Event('inner-event'));
  console.log('After inner dispatch');
});

// 监听内层事件
button.addEventListener('inner-event', () => {
  console.log('Inner Listener');
  Promise.resolve().then(() => console.log('Inner Microtask'));
});

console.log('Start');
// 派发外层事件,启动嵌套引擎
button.dispatchEvent(new Event('outer-event'));
console.log('End');

输出结果:

复制代码
Start
Outer Listener
Inner Listener
After inner dispatch
End
Outer Microtask
Inner Microtask

步骤解析:

  1. console.log('Start') 压栈执行,输出 Start
  2. button.dispatchEvent(new Event('outer-event')) 压栈,同步调用绑定的外层监听器。
  3. 外层监听器执行,输出 Outer Listener,并将 Outer Microtask 挂入微任务队列。
  4. 关键点: 执行 button.dispatchEvent(new Event('inner-event')),派发逻辑直接在当前栈顶继续压入内层监听器。
  5. 内层监听器执行,输出 Inner Listener,将 Inner Microtask 挂入微任务队列。内层出栈。
  6. 回到外层监听器,输出 After inner dispatch。外层出栈。
  7. 最外层脚本继续执行 console.log('End')
  8. 整个始祖级脚本终于执行完毕,执行上下文栈(Call Stack)真正被彻底清空
  9. 触发 HTML 规范的 Clean up 步骤,闸门打开,积压的微任务依次执行:输出 Outer MicrotaskInner Microtask

通过区分 outer-eventinner-event,我们可以得知:无论事件的名字叫什么,只要它们是通过 dispatchEvent 嵌套触发的,它们就共享同一个同步的执行上下文栈,微任务就必须老老实实等到最外层调用结束才能执行。

注意点:

  1. 错误的观点 :不要认为"事件回调都是宏任务"。事件本身不是任务,触发事件的方式决定了它是否在一个新任务中执行。

  2. 嵌套 dispatchEvent 的情况 :如果在一个监听器中再次调用 dispatchEvent 触发另一个事件,那么内层事件的所有监听器也会同步执行,微任务会被积压到最外层任务结束。

  3. 特殊情况:冒泡与捕获阶段:无论事件处于捕获、目标还是冒泡阶段,上述规则都完全适用。微任务检查点只会在每个监听器执行完毕后检查栈是否为空。

微任务在脚本清理步骤中触发,当执行栈为空时就可能执行。任务边界只是最常见的触发点之一。

DOM 事件派发是同步的,但每个监听器返回后,只要栈空,HTML 清理步骤就可能插入微任务检查点。

要注意规范和实现的区别,不同浏览器、不同事件类型和不同回调结构下,微任务插入时机可能存在实现差异,不能简单把某一种表现当成唯一规范结论。


5.4 微任务与渲染:为什么它又快、又危险

微任务的优点是"快",因为它紧贴着执行栈的尾翼;但它的代价也正是"太快"。

HTML 标准在对 queueMicrotask() 的开发者说明里特别发出了警告:如果你安排了过多的微任务,它们的性能副作用会和编写大量的同步代码高度相似,都会阻止浏览器去做自己的工作。

这涉及到微任务与浏览器渲染管线(Update the rendering)的优先级博弈。在事件循环的处理模型中,微任务清算的优先级是高于视觉渲染的。这意味着,微任务是"不让出控制权的前提下,尽快完成收尾"。

如果你的目标只是"在下一次屏幕刷新重绘前运行一些代码",微任务绝对不是合适的工具,因为微任务会在渲染机会评估之前被强行全部处决;此时,requestAnimationFrame() 才是更契合视觉管线周期的天然拦截器。微任务在设计上应当专注于做"收口型工作",而不适合做"长链路计算"。

所以,微任务的完美适用场景: 把同一轮同步执行里产生的多笔状态变化进行合并(批处理)、在 DOM 变动后统一读写以防频繁重排、在 Promise 链的尾部做统一的异常收束逻辑。


6.异步

在经历了前面对任务(Task)与微任务(Microtask)的深入了解以后,我们终于来到了整个 JavaScript 运行时中,最容易让人产生时空错觉的认知转折点------异步(Asynchrony)

很多初学者常常将"异步"挂在嘴边,在他们的脑海深处,似乎有个幻觉:当代码执行到异步操作时,主线程里仿佛突然撕裂开了一个平行宇宙,或者分身出了一个新的小弟,在后台默默地帮他搬砖。

我们打碎这个幻觉,真正认识异步。

6.1 单线程的并发

要学透异步,首先必须在学术和工程层面上,理解两个底层概念------并行(Parallelism)与并发(Concurrency)

  • 并行(Parallelism): 指的是在同一个绝对时间点上,有多颗 CPU 核心在物理上同时执行多段不同的代码。这需要真正的硬件"分身术"。
  • 并发(Concurrency): 指的是在一段宏观的时间周期内,程序通过极其高频的切换调度,让多段任务交替执行,从而在宏观上伪造出一种"多件事情同时在运行"的丝滑幻觉。

JavaScript 的主线程,是典型的单线程协作式并发。

在 V8 引擎的真实世界里,主线程从来没有分身术。如果有一步操作需要发起网络请求去获取一个 10MB 的大文件,或者去等待磁盘读取一块厚重的数据,在以前的同步阻塞思维里,主线程就必须在原地干等着。此时,整个执行上下文栈死死卡住,程序停摆,这就是"阻塞(Blocking)"。

而异步则不同,主线程引擎一看,这个网络响应或定时器到期不知道要等到猴年马月,在原地干等就是对 CPU 算力的犯罪。于是,它运用了时间维度的拆分策略:把当下不能立刻完成、或者代价极其高昂的活儿,先在宿主环境的通讯录上登记下来,接着立刻把当前的主线程执行权让出来,去跑后面能跑的同步代码。

异步的精髓绝不是"在物理上同时干两件事",而是"发起登记 --- 移交控制权 --- 后台等待 --- 未来触发"。它保证了单线程永远在做有意义的运算,而不是死等。


6.2 异步的"三段式"旅程

无论异步在业务层面的写法多么千变万化(从古老的 Callback,到 Promise,再到现代的 async/await),在底层的真实调度中,它们一般都会经历一场"三段式"的历史旅程:

阶段一:发起与卸载

首先需要记住一个事实:所有异步操作的发起,本身都是百分之百同步执行的。

当你写下 setTimeout(fn, 1000) 或者是 fetch(url) 的这一瞬间,主线程会立刻压栈、调用这个 API。调用发生的几微秒内,主线程会迅速做两件事:第一,在宿主环境(浏览器 C++ 层)的后台线程账本上登记要干的活和回调函数 fn 的指针;第二,立刻将该 API 弹出执行栈,卸载控制权。主线程绝不在原地停留,大撒手之后,立刻衔接执行后面的同步代码。

阶段二:宿主托管与静默监听

当主线程在网页里跑别的业务同步逻辑时,被卸载出去的异步大活,正式进入了宿主环境(浏览器)的多线程异构托管区:

  • 如果是定时器,浏览器的定时器线程开始在底层精确走表;

  • 如果是网络请求,浏览器的网络进程开始拉动网卡进行底层的 TCP/IP 数据包传输;

    这个阶段,外部世界热火朝天,但 JavaScript 主线程对这一切一无所知,也毫不关心

阶段三:包装入队与未来归还

当外部的后台线程把活儿干完了(如定时器归零、网络数据下载完毕),宿主环境的总调度师就会把当时登记的那个 JS 回调函数包装起来:

  • 如果是原生的宏观任务,就作为标准的 Task 扔进对应的任务源队列排队;

  • 如果是内部的微观反应,就作为 Microtask 挂进微任务队列。

    当主线程好不容易把眼前的同步调用栈彻底空出来、事件循环的传送带旋转到这个边界时,调度器才终于把这个在门外等候多时的回调重新接引回主线程的执行栈里。至此,异步才终于完成了它的"未来回归"。


6.3 异步生态的"双轨制"

有了前面对事件循环、任务与微任务的底层了解,我们现在可以用规范视角,对整个 JavaScript 异步生态里的所有常见形态,进行一次严谨的分类:

轨道一:宿主外部宏观驱动轨(Task 队列)

这类异步形态的特征是:宿主环境在处理完外部事件后,将用户编写的回调函数直接作为一个独立的任务(Task)推入事件循环。它们在执行时会独占一轮循环节拍,结束后会拉动微任务检查点并允许浏览器选择是否插播视觉重绘。

  • 定时器回调(setTimeout / setInterval): 宿主定时器线程托管,到期后回调作为任务进入 Timer 任务队列。
  • 原生网络事件回调(XHRonload / onreadystatechange): 宿主网络进程托管,整个网络事件的就绪与用户回调的触发作为标准的任务送入 Network 任务队列排队。
  • 消息投递任务源(Posted message task source): 典型代表如 postMessage 跨域通信或使用 MessageChannel 接口进行多上下文通信,目标端接收到的回调都会作为标准的 Task 进行排队调度。
  • 文件与资源加载(onload / onerror): 浏览器文件 I/O 与资源渲染线程托管,完成后作为任务入队。

轨道二:JS 引擎内部微观驱动轨(Microtask 队列)

这类异步形态的特征是:它们由 JavaScript 引擎(如 V8)的微任务队列直接管理。它们的执行点在当前任务的内侧边缘,采用"清到见底"的机制,除非被清空,否则,绝不让出控制权给下一个任务,也绝不允许浏览器在中途插播重绘。

  • Promise.then / catch / finally 的后续反应: ECMAScript 规范要求将其包装成内部作业(Job),在 Web 平台上直接映射入微任务队列。
  • queueMicrotask() 官方定义的标准 API,唯一的职责就是绕过任何外部逻辑,直接将一个回调函数投递进当前的微任务队列。
  • MutationObserver 监听 DOM 树变动的批量记账通知机制。

xhr和fetch的辨析:

同为网络请求,XHR 的回调与 fetch 的回调,在重回主线程时发生了本质的分裂。

很多开发者会认为:"既然它们都是去网络上下载文件,那它们回来的时机和轨道应该是一模一样的吧?"

答案是否定的。

假设在代码里,同时发起了一个 XHR 请求和一个 fetch 请求。在未来的某一瞬间,宿主环境的网络进程同时下载完了这两个请求的数据:

一. XHR 的归宿(Task 轨): 外部网络线程通知事件循环调度器:"活干完了!" 接着,把 xhr.onload 这个回调函数,包装成一个全新的任务(Task),扔进主线程的网络任务队列中排队。它必须等待当前任务结束、等待当前微任务清空,在未来某一轮新的事件循环节拍中,才能出队执行。

二. fetch 的改道(Microtask 轨): 外部网络线程下载完数据后,根据 Fetch 规范,宿主环境会向主线程投递一个内部的 Fetch Task。这个内部任务在执行时的唯一职责,就是去拉动 Promise 的决议开关,将 fetch 返回的那个内置 Promise 状态变更为 resolved。而根据 ECMAScript 规范,Promise 状态的改变会立刻就地向微任务队列(Microtask Queue)注入一个微任务 。当这个短小的内部 Fetch Task 宣告结束的瞬间,由于执行栈变空,HTML 规范的微任务检查点(Microtask Checkpoint)开启,瞬间开始处决写在 fetch().then()await 后面的业务代码。

这种区别在现实中会造成什么时序差?

在同等网络就绪条件下(假设宿主环境的内部调度将两者的网络就绪通知同时推入事件循环),fetch 的后续回调往往会比 XHR 的回调抢先一步爆发。

因为 fetch 的业务回调是微任务,它会在内部接引任务结束的边界被直接"清到见底"强行处决。而 XHR 的回调是独立宏任务,它必须在门外眼睁睁地看着 fetch 的微任务全部执行完、甚至看着浏览器走完一遍重绘管线之后,才有机会在下一轮事件循环中出队。这也是为什么在同等就绪的微观时序里,fetch 能够展现出跨越任务边界的速度。

这就是异步的生态双轨制------相同的起点,却因为接引机制的规范语义不同,产生出了宏观与微观、跨越任务边界与死守内侧边缘的巨大区别。


6.4 async/await

很多教程上说:async/await 只是 Promise 的语法糖,写起来像同步而已。这种说法在 ECMAScript 规范的逻辑语义上完全没错,但我们可以继续深入的了解一下。这部分是V8的活。

我们从 V8 引擎执行上下文栈说起,async/await 并不是简单地在主线程 new 了一个 Promise 然后挂载 then 回调,它的底层启动的是 V8 内核的协程(Coroutine)与生成器挂起机制

复制代码
async function uploadData() {
    console.log('开始上传');
    const result = await fetch('/api'); 
    console.log('上传成功'); // 这一行到底什么时候执行?
}
uploadData();
console.log('主线程继续');
  1. 就地切断与挂起(先交出去): 当主线程同步执行到 uploadData(),控制台打印 '开始上传'。紧接着,代码遭遇了 await fetch('/api')。这时,V8 引擎在底层开启"就地切断指令":它会同步触发 fetch 请求,然后直接将 uploadData 整个函数的执行上下文从当前的调用栈(Call Stack)里剥离出来,转移到逻辑堆内存中进行隐形挂起(Yield)
  2. 栈空返回: 此时,对于主线程而言,uploadData 的栈帧已经消失了,调用栈回归为空,它立刻顺畅地向下执行,打印 '主线程继续'
  3. 微任务作为"接引票"(后回来): 当未来的某个时刻,网络请求结束,对应的 Promise 被 resolve 了,V8 引擎并不会自己凭空跳回刚才被挂起的代码。它的机制是:将该 await 之后剩余的所有代码(即打印 '上传成功' 这一行),包装成一个标准的微任务(Microtask),塞进微任务队列。
  4. 无缝状态恢复: 当本轮同步代码结束、执行栈变为空、微任务检查点爆发时,引擎取出这个特制的微任务。它根据里面保留的内部指针,直接将刚才挂在堆内存里的 uploadData 上下文重新拽回执行栈顶(Resume),无缝恢复现场 ,控制台终于打印出 '上传成功'

async/await 的本质,就是用 V8 内核的生成器挂起能力, 把剩余的代码就地打包、隐形交出去;在未来用微任务作为引接车,再完好无损地接回来。



异步的本质,不是"多线程的同时执行",而是"把当前不必立刻完成、也不应阻塞主线程的事,拆给未来去处理"。

它不是空间上的并行分身,而是时间上的错峰调度。从发起时的同步卸载,到宿主环境的托管,再到事件循环传送带上的精准接引,单线程的 JavaScript 用这种"先交出、后拿回"的方式,硬生生在单核的约束里,织出了整个 Web 世界看起来丝滑运转的并发幻觉。

7.Promise 与 async/await

在上一章中,我们通过"异步的三段式旅程",确立了一个宏观的认知:异步的本质是交出控制权,并在未来通过事件循环重新拿回。

然而,当控制权真正"回归"到主线程时,JavaScript 引擎到底是如何精细化地管理这些复杂的未来承诺的?当我们写下甜蜜的糖块 async/await 时,底层的内存栈到底发生了什么事情?

这一章,我们将从 ECMAScript 规范与 V8 引擎的深处,去了解 Promise 与协程


7.1 纯正的js语言级血统

首先,我们必须认识到:Promise 根本不是宿主环境(如浏览器)提供的 Web API(像 setTimeoutfetch 那样),它是 ECMAScript 语言原生的内置对象。

不管外部的宿主环境是浏览器、Node.js 还是鸿蒙的 ArkTS,只要是符合 ECMAScript 规范的 JS 引擎,Promise 的语义行为就绝对不会有任何偏差。它在底层的真身,是一个极其严密、一旦启动就绝对无法逆转的"微观状态机"。

当我们 new Promise() 时,V8 引擎在底层的 C++ 堆内存里实例化了一个对象,这个对象身上带着三个"内部插槽(Internal Slots)":

  1. [[PromiseState]](状态锁): 它的初始值永远是 pending(待定)。一旦它的状态被拨动为 fulfilled(成功)或 rejected(失败),这把锁就会在物理层面彻底焊死,再也无法改变它的状态。
  2. [[PromiseResult]](终值/拒因): 就像一个保险箱,用来存放成功拿到的数据,或者失败抛出的错误对象。一旦 [[PromiseState]] 焊死,保险箱也会同步锁定。
  3. [[PromiseFulfillReactions]] / [[PromiseRejectReactions]](反应队列): 这是最容易被误解的核心!它是一个挂载在 Promise 实例自己身上的数组,而不是全局的微任务队列! 它的作用,是用来存放所有通过 .then().catch() 注册进来的"后续反应动作(Reaction Records)"。

理解了这三个插槽,你就会明白:Promise 并没有什么魔法,它本质上就是一个自带防篡改锁的数据容器 ,外加一个静默的订阅者名单


7.2 .then 到底什么时候入队?

那么问题来了:当你写下 .then(fn) 的那一瞬间,这个 fn 到底有没有立刻进入微任务队列?

为了彻底了解这个知识点,我们必须把"同步的注册"与"异步的激发"撕裂开来看。

同步的 Executor

首先,当写下 new Promise((resolve, reject) => { ... }) 时,传入的那个 executor 函数是绝对的、百分之百的同步代码!V8 引擎在创建完实例后,会当场毫无延迟地执行它。这里没有任何异步的成分。

状态决定.then() 的走向

当接着在实例后面调用 p.then(fn) 时,V8 引擎会瞬间去查看那个底层插槽 [[PromiseState]],然后根据状态的不同,走向两条完全不同的时空分岔路:

  • 场景 A(尚未决议,pending 状态):

    如果此时的 Promise 还在等一个 5 秒后的网络请求,状态是 pending。那么调用 .then(fn) 时,引擎只是机械地把 fn 这个函数打包成一个记录,默默塞进了该 Promise 实例自己身上的 [[PromiseFulfillReactions]] 数组里存起来

    关键点:此时此刻,全局的微任务队列里什么都没有!没有任何微任务产生!

    直到 5 秒后,网络请求回来,你的代码调用了 resolve(data)。此时,[[PromiseState]] 瞬间锁定为 fulfilled,引擎立刻拉响警报,遍历自己身上的 [[PromiseFulfillReactions]] 数组,触发底层的 HostEnqueuePromiseJob 机制,这才是真正的"注水"时刻------那些沉睡了 5 秒的回调,在此刻才被真正作为微任务,推进了全局微任务队列(Microtask Queue)中排队。

  • 场景 B(已经决议,fulfilled/rejected 状态):

    如果你拿到的是一个 Promise.resolve()(状态已经焊死了)。当你调用 .then(fn) 的那一瞬间,引擎一看:"好家伙,保险箱都已经打开了!"于是它根本不往实例的数组里存了,直接当场调用 HostEnqueuePromiseJob立刻将 fn 包装成微任务,当面放进全局的微任务队列里。

小结: .then 从来不负责执行,它只负责"挂载"。是立刻推入微任务队列,还是暂存在实例身上等待未来的 resolve 唤醒,完全取决于调用 .then 那一瞬间的内部状态锁 [[PromiseState]]


7.3 链式调用的微观交错(Tick-Tock 交织机制)

理解了内部插槽,我们就可以来看一道经典题目:多 Promise 链的交错执行。

JavaScript

复制代码
Promise.resolve()
  .then(() => console.log('A1'))
  .then(() => console.log('A2'))
  .then(() => console.log('A3'));

Promise.resolve()
  .then(() => console.log('B1'))
  .then(() => console.log('B2'))
  .then(() => console.log('B3'));

所有凭直觉做题的人都会认为输出是 A1 A2 A3 B1 B2 B3。然而真正的输出是极其诡异的交织排列:A1 -> B1 -> A2 -> B2 -> A3 -> B3

为什么会产生这种像时钟滴答(Tick-Tock)一样完美的交错步调?

这里藏着 Promise 链式调用的终极规范:每一次调用 .then(),引擎在底层都会为你当场 new 一个全新的、隐形的 Promise 实例并返回。

让我们开启微任务检查点的慢镜头回放:

  1. 初始注水: 第一行的 Promise.resolve().then(A1) 和 第五行的 Promise.resolve().then(B1) 遇到了已经决议的 Promise。根据场景 B,它们立刻把 A1B1 推进了全局微任务队列。
    • 当前微任务队列: [A1, B1]
    • 注意!此时挂在 A1 后面的 .then(A2) 看到的是 A1 返回的那个全新的、且状态为 pending 的隐形 Promise 。所以 A2 只是暂存在了隐形 Promise 的身上(场景 A),根本没进微任务队列!B2 同理。
  2. 执行 A1: 微任务检查点爆发,取出 A1 执行。打印 'A1'。当 A1 函数顺利 return 结束的瞬间,V8 引擎在底层秘密地把那个隐形的 Promise 给 resolve() 了!这一动作瞬间激活了暂存在它身上的 A2,将 A2 推入了微任务队列的末尾。
    • 当前微任务队列: [B1, A2]
  3. 执行 B1: 取出 B1 执行。打印 'B1'。同理,B1 结束的瞬间秘密 resolve 了自己的隐形 Promise,激活并把 B2 推入了队列的末尾。
    • 当前微任务队列: [A2, B2]
  4. 循环往复: 接着执行 A2 激活 A3,执行 B2 激活 B3......

小结: Promise 的链式调用,就是一场完美的接力赛。当前一个 .then 的微任务彻底跑完时,它手中交出的接力棒(隐形的 resolve),才会把下一个 .then 放入排队的长龙末尾。


7.4 async/await 协程与执行栈

很多开发者习惯把 async/await 称为 Promise 的"语法糖"。这种说法从宏观上并没有错,但如果你再往下看一层,就会发现它远不只是"写法更顺手"这么简单。它真正展示的是一种很有协程味道的执行模型:当前函数在遇到 await 时先挂起,把后半段逻辑保存下来;等被等待的异步结果就绪后,再从断点处继续执行。

这也是为什么 async/await 看起来像同步代码,实际上却不会阻塞主线程。它没有把 JavaScript 变成多线程语言,也没有真的让函数"睡死在那里",而是把原本一口气跑到底的过程,切成了前半段和后半段两次完成。前半段先让出控制权,后半段通过微任务机制重新接回。整个过程非常像协程:有挂起点,有恢复点,有连续的叙事外观,但底层仍然是单线程调度。

看这段代码:

javascript 复制代码
async function upload() {
    console.log('同步执行区');
    const result = await fetch('/api/data');
    console.log('微任务恢复区', result);
}

upload();
console.log('全局同步结束');

如果从表面看,这只是一个普通函数里夹了一个 await;但从执行过程看,它其实已经被切成了两个阶段。第一阶段负责同步执行和发起异步操作,第二阶段则在未来某个时机被重新唤醒。


  1. 右侧表达式先同步执行

当执行流走到 await fetch('/api/data') 这一行时,await 右侧的表达式会先被同步求值 。也就是说,fetch('/api/data') 这一动作并不是"等到后面再说",而是当场开始。浏览器会立刻发起网络请求,并同步返回一个状态为 pending 的 Promise。

这里非常重要的一点是:

await 并不会阻塞主线程去死等结果,它只是接住这个 Promise,然后决定把当前 async 函数的后半段先暂时放下。

从这个时刻开始,网络 I/O 已经交给宿主环境处理,JavaScript 代码本身不需要继续占着执行栈不放。


  1. 当前 async 函数在挂起点暂停

await 看到的 Promise 还没有完成时,当前 async 函数就会在这里进入挂起状态。这个挂起不是线程阻塞,而是当前函数的后续执行被暂时保存起来。

可以把这一步理解成:

函数跑到一半,先把"接下来还要做什么"折起来,放到一边,等未来条件满足再打开继续。

这也是 async/await 最有协程感的地方。它并没有把整个函数销毁,而是把后半段逻辑的"继续权"保留下来。至于这个"继续权"在不同引擎内部到底怎么存、怎么挂、怎么恢复,具体实现可以有差异;但从规范语义上看,结论是稳定的:函数暂停,状态保存,之后恢复。


  1. 函数退栈,但对外返回一个 Promise

这里需要注意一个时序细节:并不是函数挂起后才返回 Promise,而是 async 函数在被调用的那一瞬间,就已经同步向外部返回了一个 Promise。

随后,当函数在内部遇到 await 并挂起时,它真正做的是把主线程的控制权交还回去

这意味着外层调用者拿到的不是一个"卡住的函数",而是一个"未来会结算"的结果容器。函数内部那一半还没跑完,但函数外部已经可以继续往下执行了。于是你会看到:

javascript 复制代码
upload();
console.log('全局同步结束');

这里的 console.log('全局同步结束') 会先打印出来,因为 upload()await 处已经把控制权交还给了外层。

upload 函数本身在逻辑上还活着,只是它当前那一半被挂起了,并没有继续占用主线程。

从这个角度看,async 函数更像一个"会分两次上场"的执行体:

前半场先跑,后半场等 Promise 结算后再回来继续。


  1. Promise settled 后,恢复任务进入微任务流程

fetch('/api/data') 最终拿到网络响应时,原先那个 Promise 会进入 fulfilled 状态。这个状态变化会触发后续的恢复逻辑。

接下来发生的事情,不是"立刻回到原位置继续执行",而是把恢复动作排进微任务队列 ,等待合适的微任务检查点再处理。也就是说,await 后半段的恢复并不是同步插队,而是遵循事件循环和微任务调度的规则,在当前同步代码完成后、合适的清理时机再接上。

这一步可以理解成:

前半段已经把"后续执行现场"保存好了,现在只是等一个规范允许的时机,把它重新接回来。


  1. 恢复时从断点处继续

等微任务检查点到来时,会把之前挂起的 async 函数继续恢复执行。于是 result 变量会拿到网络响应值,代码也会接着执行:

javascript 复制代码
console.log('微任务恢复区', result);

upload() 内部的代码来说,这一切并不是"重新执行了一遍函数",而是从上一次停下来的位置继续往下跑

这也是 async/await 最像协程的地方:它不是重新开头,而是从断点续写。


整个流程可以这样理解:

  1. await 右侧表达式先同步求值。
  2. 如果得到的是一个尚未完成的 Promise,当前 async 函数暂停。
  3. 函数向外返回一个 Promise,控制权交还给主线程。
  4. 当被等待的 Promise 以后完成,恢复逻辑进入微任务流程。
  5. async 函数从断点继续执行后半段代码。

整个过程没有引入新的线程,也没有让 JavaScript 变成真正的并行模型。它只是把"原本必须一口气执行到底的逻辑",拆成了前半段同步执行、后半段异步恢复的两段式流程。


有些教程,用"把执行栈搬到堆里"来形容 await,这个说法作为比喻是好用的,因为它能帮助初学者迅速抓住"暂停后还会回来"这个事实。

但更严谨一点说,真正发生的不是把整个函数粗暴复制一份,而是把函数继续执行所需要的状态保存起来:

包括当前跑到哪里了、下一步应该接哪一行、相关局部状态该如何恢复。

所以可以把它理解成一种"状态封存":

  • 当前的执行先退下来;
  • 后半段逻辑被保留;
  • 未来再通过微任务重新接入。

这套机制让 JavaScript 在单线程条件下,也能写出非常像同步流程的异步代码。它不是"多线程的同时执行",而是"把未来才能完成的部分,先收起来,等时机到了再继续"。


普通函数遵循的是严格的调用栈规则:进栈、执行、出栈,一口气完成,中途不会自己暂停再回来。

async/await 打破了这种"一次性跑完"的直线逻辑,允许函数在某个点主动退场,等异步条件满足后再回来续写。

这就是它和普通函数最大的不同:

  • 普通函数:必须一路跑到底。
  • async 函数:可以在 await 处先停一下。
  • Promise:负责把"未来的结果"包装起来。
  • 微任务:负责把"恢复执行"安排在合适的时机。

async/await 不是线程模型的变化,而是执行控制权的重新编排。


小结:

如果说 Promise 解决的是"如何把未来结果变成一个可观察、可组合的对象",那么 async/await 解决的就是"如何把这种异步等待,写得像同步流程一样顺滑"。

它的底层本质,不是开启额外线程,也不是把函数冻结成静态副本,而是:

await 处先挂起当前执行,把后半段逻辑保存下来;等 Promise 完成后,再通过微任务把这段逻辑从断点处恢复。

正因为有了这套机制,JavaScript 才能在单线程的约束下,既保持代码的可读性,又维持异步处理的灵活性。


7.5 async/await 使用要点

这一小节主要偏应用避坑,主要是考虑有不少朋友使用async/await时,不是那么的自如。

async/await 最容易让人误判的地方,不在语法本身,而在时序 。很多初学者一看到 await,就会下意识地把它理解成"这里会停一下,外层也会一起等一下"。其实不是。async 函数一旦被调用,就已经对外返回一个 Promise;await 只是在函数内部制造了一个挂起点,让后半段代码稍后再恢复。外层世界并不会因为你写了 await,就自动进入等待状态。

也正因为如此,async/await 的坑,往往不是"不会写",而是"把它当成了同步代码去理解"。一旦这个前提错了,循环、回调、错误捕获、并发顺序,都会跟着出问题。

这一小节作为使用要点,首先需要记住三件事:

  • async 函数一调用就返回 Promise;
  • await 只负责当前 async 函数内部的挂起与恢复;
  • 很多常见 API,本来就不是为"等待异步"设计的。

一、forEach 里的 await 黑洞

这是最常见,也最容易把人带偏的写法:

javascript 复制代码
const userIds = [1, 2, 3];

userIds.forEach(async (id) => {
    const data = await fetchUser(id);
    console.log(`拿到用户 ${id}`);
});

console.log('循环结束,可以继续后续逻辑了');

很多人第一次看到这段代码时,会以为:forEach 会一个个执行回调,等前一个回调里的 await 结束后,再进入下一轮。实际上,forEach 根本不是这个语义。

forEach 的职责非常单纯:同步遍历数组,并逐个调用回调函数 。它不会等待回调返回的 Promise,也不会因为回调里写了 async/await 就改变自己的行为。换句话说,forEach 只负责"调用",不负责"等待"。

所以这段代码真正发生的事情是:

  1. forEach 很快把所有回调同步调用一遍;
  2. 每个回调在 await 处挂起;
  3. 外层代码继续往下执行;
  4. 每个异步结果在未来的某个微任务阶段陆续回来。

于是,循环结束,可以继续后续逻辑了 先打印,而 拿到用户 1拿到用户 2拿到用户 3 则可能在后面慢慢出现。它并不是"顺序等待",而是"顺手全发出去,然后谁先回来谁先处理"。

这种写法最容易出问题的地方,是你本来想串行,却误写成了"表面上看起来像串行,实际上却是并发发起"。

常见场景包括:

  • 需要按顺序请求接口;
  • 需要前一个任务完成后,再开始下一个;
  • 需要保证日志、状态更新、资源释放的先后顺序;
  • 需要某一步失败后立刻中断后续流程。

在这些场景里,forEach(async ...) 都不合适。

如果你要的是串行 ,那就用 for...of,或者传统的 for 循环:

javascript 复制代码
for (const id of userIds) {
    const data = await fetchUser(id);
    console.log(`拿到用户 ${id}`);
}
javascript 复制代码
for (let i = 0; i < userIds.length; i++) {
    const data = await fetchUser(userIds[i]);
    console.log(`拿到用户 ${userIds[i]}`);
}

这类写法的特点非常明确:上一轮不结束,下一轮就不会开始

如果你要的是并发,应该把 Promise 收集起来:

javascript 复制代码
const results = await Promise.all(userIds.map(fetchUser));

这样写表达的意思就很清楚:一起发起,一起等待,最后一次性拿结果。

  • forEach:同步遍历,不等待回调;
  • for...of:适合串行 await
  • map + Promise.all:适合并发 await

二、map(async ...) 得到的不是结果,而是一堆 Promise

forEach 一起出现的,还有另一个高频误区:把 map(async ...) 的返回值当成最终结果数组。

javascript 复制代码
const results = userIds.map(async (id) => {
    return await fetchUser(id);
});

console.log(results);

很多人会期待 results 里装的是用户数据。实际上,它更可能是一个 Promise 数组

原因很简单:map 只是做映射,不负责等待。

而你传进去的回调又是 async,所以它的返回值天然就是 Promise。

于是 map(async ...) 的结果,不是"已经拿到的数据",而是"还在路上的承诺"。

正确的方式

javascript 复制代码
const results = await Promise.all(
    userIds.map(async (id) => {
        return await fetchUser(id);
    })
);

这时 Promise.all 才是那个真正负责"结算"的对象。

它会把所有 Promise 一起等待,最后给你一个完整的结果数组。

map(async ...) + Promise.all

适合:

  • 多个任务彼此独立;
  • 希望并发发起;
  • 希望最后一次性拿结果。

不适合:

  • 需要逐个顺序执行;
  • 中间步骤彼此依赖;
  • 单个失败不能影响全部流程。

三、Promise.all 很快,但它是"失败即全失败"

对于 map + Promise.all 不要把它当成万能答案。

javascript 复制代码
const results = await Promise.all([
    fetchUser(1),
    fetchUser(2),
    fetchUser(3)
]);

这段代码的特点是:并发发起,统一等待

它很快,因为所有请求几乎是同时出去的,但是,只要其中任意一个 Promise rejected,整个 Promise.all 就会直接失败。

  • 如果你的目标是"一组任务,只要有一个失败,整组就算失败",Promise.all 很合适;
  • 如果你的目标是"允许部分失败,部分成功,只要尽量收集完整结果",那就不合适。

更适合容错批处理的方案

javascript 复制代码
const results = await Promise.allSettled([
    fetchUser(1),
    fetchUser(2),
    fetchUser(3)
]);

Promise.allSettled 会等所有 Promise 都结束,然后返回每一项的最终状态。

这在批量请求、批量上报、批量任务收集里特别实用。

  • Promise.all:快,但失败就整体失败;
  • Promise.allSettled:稳,但需要你自己拆成功和失败。

四、try/catch 的使用限制

这是第二个特别容易误判的点:

javascript 复制代码
async function badFetch() {
    try {
        return fetch('/api/error');
    } catch (e) {
        console.log('内部捕获失败');
    }
}

很多人会以为,既然外面套了 try/catch,那网络失败一定能抓住。

实际上,这个判断往往是不成立的。

关键不在于 catch 写没写,而在于:你捕获的是不是同一层时序里的错误。

fetch('/api/error') 这一行先返回的是 Promise,而不是一个已经抛出来的同步异常。

如果你只是 return fetch(...),那么当前 async 函数很快就结束了,try/catch 也跟着退出了。

后面的失败,是在 Promise 的异步阶段发生的,很多时候已经不在这个 catch 的保护范围里。

这就是为什么很多人会遇到一种很熟悉的错觉:

明明已经写了 try/catch,为什么错误还是跑出去了?

因为它跑出去的,不是"异常对象",而是时间点

那么,仔什么时候该用 return await呢?

如果希望当前函数内部就把这个异步错误接住,那就应该写成:

javascript 复制代码
async function goodFetch() {
    try {
        return await fetch('/api/error');
    } catch (e) {
        console.log('内部捕获到了错误');
        throw e;
    }
}

这里 await 的作用,是把 Promise 的失败点拉回到当前函数的 try/catch 语境里。

这样一来,错误就不是"已经飞到函数外面去了",而是在当前保护范围内被观察到。

如果只是想把 Promise 原样交给外层去处理,而当前函数自己并不需要兜底,那么直接 return fetch(...) 完全可以。

主要的考虑点,是你希望错误在哪一层被观察到,你希望谁来承担这次异步失败的处理责任。

  • 想让当前函数内部的 catch 接住错误,常常要 return await
  • 只是转交 Promise 给外层,直接 return 就行。

五、await 不会自动把外层流程也停住

还有一个常见误解,是把 await 看成"全局暂停按钮"。

实际上,它只暂停当前 async 函数内部

外层调用者并不会因为你在内部写了 await,就自动跟着停下来。

例如:

javascript 复制代码
async function test() {
    console.log('A');
    await someAsyncTask();
    console.log('B');
}

console.log('C');
test();
console.log('D');

有朋友会以为 test() 会把外层也拖住。

但真实情况是:

  • test() 一调用,就先返回 Promise;
  • 外层继续执行,所以 D 会先打印;
  • test() 里面则会在 await 处暂停,等未来再恢复。

所以 await 的影响范围,始终是函数内部,不是整个调用链自动冻结。


六、await 放在循环里,不一定错,但要知道爱的代价

很多人一看到循环里有 await,就立刻觉得"这是不是不对"。

其实不是。await 放在循环里,既可能是合理的,也可能是错误的,关键看你到底要什么。

串行场景很合理

javascript 复制代码
for (const id of userIds) {
    await fetchUser(id);
}

这里的意思很明确:

前一个完成,再做下一个。如果本来就需要这种顺序,那它完全正确。

适合这种写法的情况包括:

  • 依赖前一步结果;
  • 需要严格顺序;
  • 需要控制请求速率;
  • 需要避免并发过高。

如果是并行场景,那么久不该这么写

如果本来是想同时发起多个请求,那一个个 await 就会把它们强行串起来,反而拖慢整体速度。

javascript 复制代码
const results = await Promise.all(userIds.map(fetchUser));

这种写法更符合并发意图:一次性发起,一次性收口。

所以问题不在"循环里能不能写 await",而是在于到底想让这些任务串行 ,还是并发


七、异步回调里的错误,不一定能被外层同步 try/catch 接住

还有些问题,发生在回调式 API 里。

javascript 复制代码
try {
    setTimeout(async () => {
        throw new Error('boom');
    }, 0);
} catch (e) {
    console.log('这里通常抓不到');
}

明明外层套了 try/catch,为什么还是没抓住?

因为外层的 try/catch 只包住了当前这段同步调用栈

setTimeout、事件回调、Promise 后续执行这些内容,很多时候都已经发生在未来的另一个调度阶段,不在这次同步栈里了。

所以,异步错误处理的原则很简单:

  • 同步抛错,用同步 try/catch
  • Promise 失败,用 await + try/catch.catch()
  • 定时器、事件、回调里的错误,要在对应回调内部处理。

不要期待一个外层 try/catch 能罩住整个未来。


八、await 不会吞掉错误,它只是把错误带到你能看见的地方

await 很容易让人误会成"帮我把错误处理好了"。

其实不是。它只是把 Promise 的结果展开:

  • 成功,就拿到值;
  • 失败,就把异常重新抛出来。
javascript 复制代码
const data = await fetchData();

这句的意思不是"保证安全",而是:

  • 成功时,data 拿到结果;
  • 失败时,异常会在这里抛出,交给上层处理。

这也是为什么 await 往往会让人感觉错误"突然出现在某一行"。

它不是制造了错误,而是把原本藏在 Promise 里的失败,搬到了你眼前。


九、不要把并发和串行混为一谈

这几乎是所有 async/await 误用的根本来源。

串行

javascript 复制代码
const a = await taskA();
const b = await taskB();

这种写法的含义是:

taskA 完成后,再做 taskB

并发

javascript 复制代码
const [a, b] = await Promise.all([taskA(), taskB()]);

这种写法的含义是:

两个任务一起发起,最后一起等结果。

很多异步问题,表面上看像语法错,实际上都是时序理解错

你以为自己在并发,结果写成了串行;你以为自己在串行,结果写成了并发。

async/await 只是把这件事写得更像线性代码,但它并不会替你决定并发还是串行。


十、不要让 Promise 悬空

这是一个很隐蔽、但很常见的问题。

javascript 复制代码
async function foo() {
    doSomethingAsync(); // 忘了 await
    console.log('继续执行');
}

这里 doSomethingAsync() 返回了一个 Promise,但你既没有 await,也没有 .catch(),也没有把它交给统一收口逻辑。

结果就是:这个 Promise 可能被"扔在半空中",后面的错误没人接,后续的逻辑也可能以为它已经完成了。

这类问题的本质是:

Promise 被创建了,但没有被认真接住。

所以,当调用一个异步函数时,至少要清楚自己是在做哪一种事:

  • 要等它:await
  • 要转交:return
  • 要统一收口:Promise.all / allSettled
  • 要显式忽略:你得非常清楚自己为什么要这么做

千万不要"无意中忽略"。


十一、async 本身不是问题,问题是不要无意中制造不必要的等待

很多人对 await 会有一种心理负担,觉得它是不是"很慢"。

其实真正拖慢代码的,通常不是 async 这个语法本身,而是你把本来可以并行的事情写成了串行,或者把不必要的等待叠加在了一起。

例如:

javascript 复制代码
const a = await taskA();
const b = await taskB();

如果 taskAtaskB 根本没有依赖关系,这样写就把它们强行串起来了。

更合适的方式往往是:

javascript 复制代码
const [a, b] = await Promise.all([taskA(), taskB()]);

所以,await 不是性能问题的源头,不必要的顺序等待才是。



这一小节比较详细而琐碎,在实际应用中,可以记住下面条:

第一,forEach 不适合等待异步。

它只同步调用回调,不等待 Promise。要串行,用 for...of;要并发,用 Promise.all

第二,map(async ...) 的结果是 Promise 数组。

别把它当最终数据,必要时要用 Promise.all 统一收口。

第三,Promise.all 快,但失败即全失败。

要容错批处理,考虑 Promise.allSettled

第四,try/catch 不会自动穿透所有异步边界。

如果你想在当前函数里接住异步错误,return await 常常是必要的。

第五,先想清楚是串行还是并发。

这比先写代码更重要。

第六,任何 Promise 都要明确"谁来接"。

要么 await,要么 .catch(),要么交给统一收口逻辑,别让它悬空。


8.任务的插队

主线程的运转往往伴随着各种不可思议的"时序错觉":明明是先写下的定时器,为什么被后发生的鼠标点击给无情地压制了?明明主线程已经因为一段死循环彻底卡死,为什么页面依然能够丝滑地滚动?

这部分,我们将进入浏览器内核(以 Chromium/Blink 调度器为主)的世界,了解一下任务的插队和控制权的争夺。


8.1 从"表层的插队感"看透本质

复制代码
setTimeout(() => console.log('定时器宏任务'), 0);
Promise.resolve().then(() => console.log('Promise微任务'));

无论把 setTimeout 写在多么靠前的位置,控制台始终是 Promise 抢先输出。

在初学者看来,这造成了极其强烈的"插队感"。它的底层机理,正是微任务对宏观任务轨道展开的"队列优先级打击"

前面我们讲了,Promise.then 派生的是微任务,而 setTimeout(0) 派生的是标准的任务。因为微任务检查点(Microtask Checkpoint)被脚本清理算法所守卫,一旦执行上下文栈变空,浏览器还来不及去拿任何下一个任务、也不考虑画面渲染之前,必须立即把微任务队列"清到见底"。

这种降维般的时空特权差异,让 Promise 总是比 setTimeout(0) 先执行。这种时序差在宏观上,就演变成了"降维插队"。

真正的插队是"多任务源的博弈"

然而,严格从规范层面来说,微任务的就地爆发一清到底,属于生命周期内的"一种确定性延伸",它在标准里是注定的,严格来说不算真正的插队。

浏览器内核中真正的"插队",发生在同属任务(Task)的不同轨道之间。

我们在前面也讲过,HTML 规范允许浏览器拥有多个不同的任务队列(如 Timer Queue、Network Queue、Input Event Queue)。规范给出的规则只有一条:同一个任务源内部的任务必须先进先出(FIFO),绝对不许乱序。 但规范留给浏览器最大的自由度在于:在面对不同的任务队列时,事件循环下一圈到底去挑哪一个队列里的任务来执行,完全由浏览器自行决定。

这就给浏览器内核留下了巨大的调度优化空间。在浏览器的生存哲学里,"响应性(Responsiveness)"和"视觉丝滑"拥有非常大的特权。为了维持这种特权,浏览器内核的调度器在后台展开了高效率的特权划分。


8.2 即便同属任务,也有特权调度

在真实的浏览器(如 Chrome 的 Blink Engine)内部,当主线程同时面对一堆已经就绪的任务时,底层的核心调度会将它们划分进不同的动态优先级层级(Priority Tiers)

这就是调度的偏心------为了防止 UI 冻结,宿主环境在底层构建了一套"特权调度"机制。

高优先级层级 ------ 输入与交互队列(Input Priority)的特权:

当用户的鼠标在屏幕上划过、键盘在疯狂敲击、表单在同步提交时,这些由原生交互产生的回调任务会被无条件地判定为最高特权。调度器哪怕看到定时器队列和网络队列里已经排了大量的任务,它也会:卡住其他所有轨道,优先提审、连续执行用户交互相关的任务!(虽然调度器对单次连续执行的输入任务数量有一定限制,但在持续的高频输入下,低优先级任务依然可能面临长时间的等待)。 这种特权调度,就是为了保证用户在打字或点击时,主线程能在几毫秒内给出反馈,从而守住 UI 的流畅度,防止界面产生肉眼可见的"UI 冻结"。

默认/普通优先级层级 ------ 网络与正常任务源:

普通的异步数据返回、文件的初次读取,通常作为标准的中等优先级,在没有高频交互时稳步推进。

低优先级层级 ------ 定时器队列(Timer Priority):

在浏览器内核的眼里,普通的 setTimeoutsetInterval 在高频交互发生时,其调度级别通常会被直接调低,随时准备给交互任务让路。

例如:高频交互下的"定时器饥饿效应"

想象 一下,我们在页面里写了一个 setInterval(fn, 10) 的高频定时器。同时,用户正用鼠标按住滚动条,在屏幕上进行极其频繁的拖拽交互:

  • 第一步: 定时器到期,fn 1 任务进入 Timer 队列。
  • 第二步: 用户的鼠标动了,产生海量的 mousemove 任务,瞬间灌满了 Input 队列。
  • 第三步: 事件循环转动。调度员极度偏心,抬头一看:"Input 队列有高优先级的用户交互!" 于是直接无视 Timer 队列,把 mousemove 提审执行。
  • 第四步: 下一轮循环,定时器再次到期,fn 2 入队。但用户的鼠标还在动,输入队列源源不断。调度器连续多次执行特权调度,偏袒 Input 队列,将 mousemove 全部插队提前执行。
  • 第五步: 此时,Timer 队列里的定时任务被无情地积压在底层,这被称为"任务饥饿(Task Starvation)"。

调度器也有底线,这就是防饥饿打捞算法(Anti-starvation)

如果调度器可以无限制地偏袒输入,那么一旦用户高频晃动鼠标,页面里的定时器和网络回调就永远别想执行了,这会导致业务逻辑的彻底崩溃。

为了守住底线,调度器内部设计了一套精妙的"防饥饿打捞算法":每当一个低优先级的任务在队列里被高优先级任务连续"插队"、等待时间超过了一个设定的内部阈值(在 Chrome 中约为 300 毫秒)时,该任务的身上就会亮起报警红灯。

调度器会在瞬间强制介入,将该定时器任务的优先级大幅提升(或在连续的输入任务流中强制穿插一次定时器任务),强行在紧密的输入流中撕开一道缝隙,让这个快要饿死的任务出队执行一次,完成物理打捞。


8.3 当合成器线程完全绕过主线程

有一个常见的问题:"既然 JavaScript 是单线程的,如果我在一个按钮的点击事件里写了一段 while(true) 的死循环,此时主线程彻底卡死,为什么我依然可以用手指丝滑地滚动这个网页,甚至页面里的 CSS transformopacity动画还在流畅地播放"

为什么会出现这个情况?主线程连执行下一次事件循环的控制权都没有了,是谁在帮它做页面的滚动和重绘?

实际上,这次"插队",不是在主线程上完成了超越,而是有人直接绕开了主线程,以另外的方式完成了自己该干的活 ,即合成器线程直接绕开了瘫痪的主线程,独立完成了视口的滚动合成与图层拼装工作。。

现代浏览器在渲染器进程中,除了负责运行 JS 的主线程(Main Thread)之外,还并存着一个独立的线程------合成器线程(Compositor Thread)

  • 主线程: 主线程负责计算复杂的样式(Style)、计算布局重排(Layout)以及记录绘制指令(Paint)。它把这些指令算好后,打包成图层,交卸给合成器线程。
  • 合成器线程: 合成器线程的唯一职责,就是把主线程交卸过来的图层,在不需要经过主线程任何计算的前提下,直接发送给 GPU 进行像素级的拼装和滚动渲染。

那么,为什么死循环卡不死页面滚动?

当你在主线程上用 while(true) 将其掐住时,事件循环确实瘫痪了。但此时,只要你滚动的那个区域没有绑定任何妨碍滚动的原生事件监听器 (或者你注册了我们在前面强调的 { passive: true } 异步执行承诺书),那么合成器线程在捕捉到你的物理滚动信号时,就会做出如下举动:

它根本不需要去敲主线程的大门,也不需要去向事件循环申请任何任务排队。它直接利用手里的图层快照,在后台线程里和 GPU 闪电般地完成了画面的位移与合成!

这种"插队",是多线程物理架构对单线程执行机制的辅助配合。它告诉我们:视觉的丝滑,在现代浏览器的底层,早已被彻底从单线程的牢笼里剥离释放了出来。


8.4 requestAnimationFrame 与 setTimeout(0)

我们经常需要在当前同步代码结束后,立刻延后执行一段逻辑。这就引入了两个高阶 API 之间的问题:setTimeout(fn, 0)requestAnimationFrame(fn),它们到底谁先执行?

它们俩的先后顺序,并不是完全不变的。而是由"硬件节拍"和"调度评估"共同确定的。它们谁都有可能先执行!

我们来细看看:

  • setTimeout(fn, 0):它的本质是命令定时器线程去申请一个全新的异步任务,在下一次循环中排队。
  • requestAnimationFrame(fn):它的本质是向宿主环境申请一个"特权拦截器",它明确表明:"只有当浏览器判定当前文档迎来了渲染时机(Rendering Opportunity)、准备拉动重绘管线的前夕,才准许调用我。"

现在,我们把显示器硬件刷新率(如 60Hz 对应每 16.6ms 刷新一帧)切入进来:

场景一(时间卡点在帧的"后半段"):

假设当前的事件循环在第 15 毫秒跑完了一段同步代码。此时,距离下一次硬件刷新的 16.6ms 节拍已经近在咫尺。调度器在清空微任务后,一盘点时间:"到了适合刷新画面的渲染时机了!"

于是,它直接拉动渲染更新闸门,rAF 的特权拦截器立刻出队执行 。而你之前写下的 setTimeout(0) 此时还在宏任务队列里排队,必须等到这一轮渲染彻底结束后的下一圈事件循环,才有机会出队。

此时的表现: rAF 成功插队,抢先于 setTimeout(0) 执行。

场景二(时间卡点在帧的"前半段"):

如果当前的事件循环非常高效,在第 2 毫秒就跑完了所有的同步大活。调度器清空微任务后,一盘点时间:"距离下一次 16.6ms 的屏幕刷新还早得很呢!现在如果重绘就是纯粹的性能浪费。"

于是,调度器下令:跳过本次渲染机会评估!直接去任务队列里摸下一个任务! 此时,排在宏任务队列首位的 setTimeout(0) 被一把捞了出来,推入执行栈执行。而 rAF 只能继续在原地苦苦等待属于它的那个刷新节拍。

此时的表现: setTimeout(0) 翻盘,抢先于 rAF 执行。

这就是为什么在高级性能调优中,我们经常看到两者的行为因环境而异。它们之间的插队与反插队,是一场微观执行时间与宏观物理硬件节拍之间的此消彼长。


9.异步的代价

我们无数次赞美了 JavaScript 异步调度的精妙与智慧:它通过时间维度的拆分,让单线程的执行器在 Web 世界里舞出了并发的幻觉。

但是,没有任何一种架构分配是毫无代价的。 这节我们将详细了解这些代价。

9.1 长任务对执行栈的霸占

长任务(Long Task)引起的宏观阻塞

我们在前面讲过,任务的执行遵循协作式(非抢占式)调度下的"运行至完成"(Run-to-completion)语义。一旦事件循环从队列里捞出了一个宏任务推上主线程,JavaScript 引擎就会牢牢握住调用栈(Call Stack)的控制权。

在浏览器的官方性能指标(如 Web Vitals、Lighthouse)中,长任务(Long Task)有确定的时间定义:任何在主线程上连续同步执行时间超过 50 毫秒的任务。

为什么是 50ms?因为人类视觉系统对 100ms 内的延迟认为是即时的。一个任务一旦超过 50ms,留给后续用户交互响应和视觉重绘的预算就会被挤占。如果在一帧(16.6ms)的周期内,某位开发者在一段点击事件的回调函数内部,写下了一段沉重的计算逻辑(例如处理长达数秒的复杂矩阵变换或高频 for 循环),这便是主线程长期控权的物理起点。

  • 执行栈死锁: 这个宏任务的函数帧将压在 JavaScript 执行栈的底部,疯狂榨取 CPU 算力。
  • 循环瘫痪: 在整整几秒钟的时间内,这个任务没有结束,控制权就绝对无法交还给宿主调度器。

在这数秒钟内,主线程的大门被关死。外面的世界正在发生大量的堆积:

  • 用户拼命用鼠标点击其他的按钮、疯狂敲击键盘、试图拉动滚动条------这些原生硬件信号被宿主捕获后,包装成了海量的交互任务,只能在门外的输入任务队列里排队排到薅头发
  • 硬件显示器的刷新节拍(16.6ms)一轮轮划过,由于当前的宏任务迟迟不肯退栈,事件循环根本无法向下轮转,"更新渲染(Update the rendering)"的闸门连摸都摸不到。页面被彻底冻结。

此时,有可能会产生一个奇怪的现象: 页面的内容彻底卡死,但是浏览器标签页(Tab)上的那个加载小圈圈(Spinner),它通常依然在顽写地转动

这是因为浏览器的多进程架构,标签页的小圈圈是由浏览器主进程(Browser Process)的 UI 线程直接驱动的,而那个死循环是发生在外层的渲染器进程(Renderer Process)的主线程里。其他地方还在运转,但是主线程却已经暂时瘫痪。


9.2 微任务疯狂干活与宏任务的饿死

我们在前面学习了微任务检查点(Microtask Checkpoint)的运行规则:微任务检查点一旦在执行栈为空的脚本清理点被启动,它的 FIFO 循环就会进入"清到见底"模式。

规范中设置的 performing a microtask checkpoint 重入锁,唯一的职责是防止检查点函数在嵌套调用时被自己"递归触发"导致栈溢出,但它却无法阻止微任务队列在横向维度上的无限膨胀

复制代码
// 一段让主线程彻底趴窝的代码
function infiniteMicrotask() {
    queueMicrotask(() => {
        // 执行一些轻量逻辑...
        infiniteMicrotask(); // 递归在自己身后无限追加微任务!
    });
}
infiniteMicrotask();

在这段代码里,每一次调用都只占用了微秒级别的算力,执行栈也会在瞬间变空。但是,在当前这轮微任务检查点还没结束的时候,它又在自己身后追加挂载了一个崭新的微任务。由于微任务队列永远无法清空,这个检查点的内部循环将永远无法结束

这对主线程造成的杀伤力是毁灭性的:

  • 渲染时机的物理剥离: 在标准的事件循环节拍中,清空微任务是进入渲染评估的绝对前置条件。因为微任务队列永远无法清空,事件循环的指针被生生卡死在了微任务检查点这一步。主线程在微观层面上疯狂空转,它连向后续渲染管线(Style、Layout、Paint)看一眼的机会都被物理剥离了!
  • 交互任务的绝对饿死: 门外等待的所有宏任务(定时器、网络、输入),必须等待微任务检查点彻底落幕才拥有出队的机会。现在大门紧闭,所有的宏任务在外面排队排到了天荒地老,这被称为"任务饿死(Task Starvation)"。

这会产生比长任务更绝望的情况: 页面不仅彻底冻结,由于主线程被卡在微任务的深渊里无法脱身,两套引擎的底层通信彻底断裂。这一次,标签页的小圈圈彻底不转了,直接卡死。甚至连按 F12 想打开浏览器的开发者工具都会发现 DevTools 窗口打开极度缓慢,甚至因无法拉取主线程的堆栈信息而产生假死白屏。 因为此时主线程一丁点空闲都没有,根本无法响应 DevTools 跨进程发来的堆栈读取 IPC 消息。


9.3 { passive: true } 的默认使用

浏览器必须要 防交互延迟、防滚动卡顿、防渲染饿死。

那么{ passive: true } 出现了。

我们在前面好像是第二部分,讲生命周期时,有提到过关于移动端页面滑动卡顿的情况。

当用户的手指在屏幕上高频划过、产生连续的 touchstart / touchmove 信号时,宿主环境的合成器线程(Compositor Thread)在第一时间截获了这一信号。

  • 没有 { passive: true } 的情况(默认状态):

    合成器线程捕捉到触点,抬头一看:"主线程上绑定了触摸监听器,且没有声明 passive!" 此时,由于合成器线程无法预知你的 JS 回调里会不会调用 e.preventDefault() 去拦截滚动,它必须强行挂起底层的滚动渲染,把信号发给主线程,同步死等主线程的任务出队和执行结果。如果此时主线程恰好爆发了长任务暴政,合成器线程也只能在旁边陪葬,页面瞬间产生剧烈的、跳跃式的滑动卡顿。

  • 签下 { passive: true } 的协议:

    当你在注册监听时,明确勾选了 { passive: true }。这就是你向浏览器签署的一份"异步执行承诺书"。你明确的告诉合成器线程:"请直接去跟 GPU 拼装图层、开始滚动渲染,千万不要等我!我承诺在回调函数里绝不调用 preventDefault() 拦你的路!"

正是由于 passive 机制对移动端用户体验有着至关重要的决定性,现代浏览器已经在默认情况下,浏览器会自动将 Window、Document 和 Body 上的 touchstarttouchmove 事件缺省勾选为 { passive: true } 开发者如果不加特殊声明便在其中强行调用 preventDefault(),甚至会在控制台收到浏览器的拒绝报警。

一旦建立了这个强有力的解耦协定,当用户的滑动手指到来时,合成器线程可以完全无视主线程那扇已经死锁的大门,直接绕过主线程,拉动 GPU 直接完成了画面的无缝位移与极致丝滑的滚动!


9.4 拆分大活

当我们在实际开发中,面对不得不处理的、长达数秒的"大活"(如解析 50 万条超大 JSON 数据,或进行复杂的图像像素级处理)时,应该如何处理呢?

核心的方法就是四个字:时间分片(Time Slicing)。也就是说,通过主动出让控制权,把一个长任务,拆解为多个微小执行单元。

方案一: setTimeout(fn, 0)

将超长循环拆解为分批执行。每一批只处理 1000 条数据,处理完后,通过 setTimeout(0) 强行向任务队列的末尾追加下一批处理的"回程票",然后主动退栈,将主线程的控制权还给宿主。这给门外排队的输入交互任务和视觉重绘管线留出了极其珍贵的隙缝。

然而 HTML5 规范里有一个4ms 的嵌套延迟 的规定。 当中断嵌套调用 setTimeout 超过 5 层时,浏览器会强制将最小延迟时间拉长到 4ms 及其以上。这意味着如果要切分一个极其庞大的循环,前几次可能流畅无缝,但后面的批次会被浏览器拖慢至 4ms 一次。

方案二: scheduler.yield()

为了消除4毫秒的限制,现代的浏览器规范为我们准备了协作式分片接口------Prioritized Task Scheduling API 中的 scheduler.yield()

复制代码
async function processHugeData(chunks) {
    for (let chunk of chunks) {
        process(chunk); // 处理当前这一批大活
        
        // 核心特权点:就地出让控制权,且免除 4ms 嵌套限制
        await scheduler.yield(); 
        // 此时主线程彻底空了,浏览器开心地去处理了用户的点击,并刷新了画面
        // 随后,宏任务接引车无缝把我们接回来,顺着下一批继续往下跑!
    }
}

相比方案一,scheduler.yield() 利用了我们之前学过的协程挂起 :执行完一批,遇到 yield,函数栈帧立刻隐形卸载到堆内存,主线程彻底归零([])。

最核心的关键点在于:scheduler.yield() 会将后续的执行逻辑封装成一个全新的、优先级为 user-visible 的宏任务(Task),重新推入宿主调度队列。 正是因为它生成的是宏任务而非微任务,主线程在这一刻才能彻底从上一个任务的大壳子里跳出来。此时,执行栈一空,之前的微任务队列被一扫而尽,浏览器松了一口气,开心地去处理门外排队已久的输入交互,并顺畅地刷新了画面。随后,调度器的宏任务小车车再用最高效的优先级把我们无缝接引回来,顺着下一批继续往下跑!


10.事件循环和渲染

在网络上绝大部分关于事件循环的文章中,对于渲染部分,提及的非常少,甚至很多都是特意回避这部分内容。这部分写起来太掉头发了。


**10.1 修改 DOM 和 布局抖动 **

我们在一层(JavaScript)所做的一切 DOM 增删、属性修改或样式变更,本质上都只是在修改二层(Blink 引擎)保存在 C++ 堆内存中的逻辑数据结构,此时屏幕像素处于"无痕静默"状态。

在渲染器进程中,V8 引擎(负责算)和 Blink 引擎(负责画)是两个独立的庞大系统。当你在 JS 里拿到了一个 document.getElementById('app') 对象时,你手里攥着的其实只是一个 JS Wrapper(包装壳对象) ,它内部包含一个底层的内部指针,指向了 Blink C++ 内存空间里的 Blink::Element 真实节点。

当执行 div.style.color = 'red' 时,控制权通过这根 C++ 指针跨越引擎边界,同步改写了 Blink 内部该节点的行内样式声明(Inline Style) ,并将相关的样式数据就地标记为失效状态,静静等待后续 Style Recalculation(样式重算)阶段去根据层叠和权重规则重新计算最终的 Computed Style

在这个瞬间,数据在内存里变了,但主线程的调用栈还没有清空,控制权依然在 JS 脚本手里。对浏览器而言,这只是内存中一笔尚未审计的"草稿账目"。宿主环境极其精明,由于走一遍真实的渲染管线代价昂贵,它会旁观你在循环里对 DOM 进行无数次的修改,直到整个同步任务和微任务全部彻底收工落幕。

正因为修改 DOM 只是修改内存中的"草稿",浏览器原本打算攒到最后统一盘点。然而,许多前端工程师在编写业务代码时,经常会无意识地亲手引爆一枚时空炸弹------强制同步布局(又称布局抖动 Layout Thrashing)

复制代码
// 布局抖动
for (let i = 0; i < 100; i++) {
    const width = box.offsetWidth; // 1. 读取当前的几何尺寸(强制同步布局点)
    box.style.width = (width + 5) + 'px'; // 2. 修改样式(使上一次的布局记录就地作废)
}

我们开启主线程执行栈的慢动作解析:

  • 第一轮循环开始: 代码先执行 const width = box.offsetWidth。这一行会要求 Blink 返回元素最新物理尺寸,此时布局数据尚为正常状态,顺利读取数值。

  • 紧接着执行 box.style.width = ... :主线程通过 C++ 指针同步修改内存样式,Blink 内部布局快照被标记为 Layout Dirty(布局数据已失效)。

  • 第二轮循环开始: 代码再次执行 const width = box.offsetWidth。引擎发现布局已被标记为脏数据,无法直接返回缓存值

  • 第四步: 为了满足你在这一行代码里发出的"强行同步读取"需求,浏览器内核被迫在这一瞬间强行打断当前 JavaScript 的正常执行流!

    要记住这个事实:这种布局计算并不会经过事件循环的分发,也不会等待下一帧的物理刷新,而是直接嵌套在当前的 JavaScript 调用栈内部同步完成!

    此时,JavaScript 引擎会一直同步阻塞在 box.offsetWidth 这个属性的 getter 读取上,动弹不得。主线程当场在栈顶压入 Blink 的 C++ 重排管线,被迫去紧急重新计算样式、重新计算盒模型几何坐标(Reflow)。等它算出一组崭新的精确像素值并塞给 width 变量后,这一轮紧急插播的渲染管线才同步退栈,JS 引擎被释放,控制权才交还给下一行代码。

  • 第五步: 读取完成后,再次执行样式修改,布局数据又一次被置为 Layout Dirty

在长达 100 次的循环里,每一轮都是「读取布局 → 触发强制同步重排 → 修改样式置脏」。浏览器被迫在 JS 同步代码中反复执行昂贵的布局计算,最终形成严重布局抖动,直接造成主线程卡顿。


10.2 Update the Rendering

如果我们的代码足够优雅,没有引爆任何强行同步布局的炸弹,那么内存里的 DOM 变更草稿,到底会在什么时候、以何种标准的节奏转化为屏幕上的像素?

答案就在 WHATWG HTML Living Standard 8.1.4.2 节------事件循环处理模型(Processing Model) 的第 11 步:Update the rendering(更新渲染)

当主线程的一个任务(Task)执行完毕,且随之派生的所有微任务队列也全部宣告清空到见底的那个空白转折点上,最高控制权重新回到了宿主环境的调度器手里。调度器并不会每转一圈就重绘一次页面,它首先会开启一次多维盘点------渲染时机评估(Rendering Opportunity Assessment)

  • 隐藏文档过滤: 盘点当前页面文档的 visibilityState 是否为 hidden(后台挂起状态)。如果是,证明用户根本看不到,浏览器会直接跳过渲染,绝不为看不见的东西浪费一丝算力。
  • 硬件刷新率节拍对齐: 读取显示系统的物理垂直同步信号(V-Sync)。如果用户的屏幕是 60Hz,意味着单帧重绘的物理周期是 16.6ms;如果是 144Hz 的高级豪华大屏幕,周期则缩短至 6.9ms。如果上一帧刚刚画完,距离下一次硬件刷新节拍的时间还早得很,调度器就会判定"当前尚未迎来渲染时机",从而直接越过渲染流程,去抓取下一个宏任务。

只有当页面前台可见,且时间刚好命中了硬件的刷新节拍时,主线程才会真正拉响最高级别的警报。为了便于理解,我们将 WHATWG 规范要求与 Chromium/Blink 的具体工程实现结合,把一次典型的渲染更新抽象为以下流转关卡,不同浏览器内核在实现细节上会有差异,但主干逻辑大体如此:

复制代码
[任务落幕 & 微任务空] ──> [满足硬件V-Sync?] ──> 进入 Update the rendering 
                                                   │
  ┌────────────────────────────────────────────────┘
  ▼
【关卡 1:处理文档集合 (Docs Collection)】──> 统一收拢当前主上下文及所有嵌套 iframe 的 sub-documents
  ▼
【关卡 2:集中派发高频连续事件】──────────> 统一派发累积的 scroll / resize 回调(避开宏任务排队)
  ▼
【关卡 3:评估媒体查询 (Media Queries)】──> 检查当前屏幕视口是否触发了 @media 临界状态并汇报变更
  ▼
【关卡 4:激活动画事件 (CSS Animation)】──> 集中触发 animationstart / animationiteration / transitionend
  ▼
【关卡 5:清空动画帧回调 (rAF Phase)】───> 集中处决当前帧快照内的 requestAnimationFrame 回调函数
  ▼
【关卡 6:清算渲染级观察者】──────────────> 触发 IntersectionObserver / ResizeObserver 等布局观察通知
  ▼
【关卡 7:全面推进五大视觉核心管线】────> Style ──> Layout ──> Pre-paint ──> Paint ──> Commit(提交)

关卡 1:处理文档集合(Documents Handle)

浏览器首先会创建一个当前需要更新的所有文档(Documents)的完整集合。这不仅包含你当前正在浏览的主页面上下文,还包含页面内部嵌套的所有异步加载的 iframe 子文档。它们被统一收拢。换句话说,浏览器不是只盯着一个孤零零的页面碎片,而是在一次渲染时机里,统一审视整棵页面文档树。这一步的意义在于,同一个顶层浏览上下文里的多个文档,往往不能各自为政。

它们之间共享同一轮显示时机、同一组刷新节拍、同一条渲染管线,所以浏览器要进行一次集体点名,把"谁需要更新"这件事盘清楚。

关卡 2:集中派发高频连续事件(Fire Consolidated Events)

很多人误以为页面的 scroll(滚动)和 resize(视口缩放)这类高频事件是由普通的宏任务异步驱动的。但在标准中,它们与普通的点击事件完全不同。

由于用户的物理缩放和页面滚动发生得极高频,如果每一次微小的位移都向宏任务队列里塞入一个 Task,主线程会瞬间发生严重的任务积压。规范的做法是:宿主环境会将这些高频位移信号静默累积起来。直到正式切入 Update the rendering 流程的这一刻,在这个专属的关卡 2 里,将积压的 scrollresize 回调函数统一作为集中派发(Consolidated)一并打包执行。这完美解释了为什么滚动事件天然具有与重绘周期同步的特性。

这一步里最需要理解的是:这类事件和渲染更新之间关系很近;它们 常常会在合适的时机里被成批处理;这样做的目的,是减少主线程被细碎输入淹没的概率。

所以,滚动卡不卡,很多时候不是"滚动本身有多复杂",而是浏览器有没有机会把输入、布局、合成和画面推进协调好

关卡 3:评估媒体查询(Evaluate Media Queries)

当页面尺寸、设备方向、视口状态或布局条件变化时,CSS 媒体查询也可能跟着发生重新判断。

比如:视口宽度变化了;设备横竖屏切换了;某些响应式断点被触发了;页面可用空间发生了变化。

这时浏览器需要检查:当前页面的 @media 条件有没有变化?如果变化了,那么对应的样式规则就要重新生效,页面的视觉结构也可能跟着变。

这一关的本质,是在告诉浏览器:别急着画,先看看规则是不是变了。

因为如果媒体查询条件已经改变,那后续的样式计算、布局计算和绘制结果都可能要重新来一遍。

它虽然只是"检查",但影响却非常大。如果有,就地记录并汇报变更,准备应用全新的样式层叠规则。

关卡 4:激活动画事件(Fire Animation Events)

这一关处理的是 CSS 动画与过渡状态的变化。

当元素的样式状态随着时间流逝或属性变化而发生转移时,浏览器会在合适的时机派发一些和动画相关的事件,比如:animationstartanimationiterationanimationendtransitionend

这一步的意义在于,动画不是只靠视觉在动,动画也可能伴随着脚本层面的状态通知。

也就是说,浏览器不只是负责"画出来",还负责把"动画已经进入哪个阶段了"这类信息通知给 JavaScript。这样,脚本才能在动画开始、循环、结束时做进一步处理。

这一关可以理解为,浏览器在准备画面之前,先把那些"时间到了,该通知一下脚本"的事情办掉。

关卡 5:清空动画帧回调(Run the animation frame callbacks)

这一关就厉害了。是大名鼎鼎的 requestAnimationFrame(简称 rAF)啊。

主线程会一把锁定当前已经通过 requestAnimationFrame 注册的回调函数队列,将其生成一份只针对当前帧的静态快照,并开始从头到尾同步依次执行。

requestAnimationFrame 不是普通意义上的宏任务,也不是微任务。它更像是浏览器在准备渲染下一帧之前,专门留给脚本的一个出场窗口

它的特点有两个,一是它和刷新节拍对齐,rAF 的回调通常会在浏览器准备绘制下一帧之前执行。

这表示你可以在这里拿到一个相对稳定的时机,去做动画更新、状态同步、位置计算等操作。二是它不会让当前帧无限自我复制,浏览器在执行这一帧的 rAF 回调时,会把当前回调集合看作一个快照,你在这个回调里再注册新的 rAF,通常不会插到当前帧里抢跑,而是会进入下一次渲染机会。

这就是为什么 rAF 适合做动画循环,因为它既跟得上帧率,又不会把当前帧硬生生拖死。

关卡 6:清算渲染级观察者(Run the background observers)

这一关主要是统一触发和清算那些和布局、几何、可见性密切相关的观察机制,包括 IntersectionObserver(交叉观察器,判断元素是否在视口可见内)和 ResizeObserver(尺寸观察器),这些 API 在此处由引擎统一调度计算,由于它们紧贴在布局计算的前后,因此比起传统的通过滚动事件高频读取布局尺寸,能够获得高出几个数量级的极致工业性能。

这类 API 的优势,是它们不需要开发者像以前那样不断地在滚动事件里手动读取布局信息、不断轮询元素状态。浏览器会在合适的渲染节点上,统一计算这些状态,再把变化通知出来。

这一步的价值非常大,因为它把很多原本容易造成性能浪费的"反复读取布局"问题,变成了更有节奏的"浏览器帮你算好了,再通知你"。就类似于,以前你可能要自己频繁问:"它现在可见了吗?",而现在浏览器可以更聪明地告诉你:"我已经算过了,它变了。"

这就是现代观察者 API 的用途,让浏览器替你做那部分本来就应该由浏览器做的几何判断。

关卡 7:推进真正的视觉核心管线(The Core Visual Pipeline)

完成了所有的前置回调和准备动作后,主线程终于深吸一口气,开始拉动真正决定像素命运的硬核底层视觉链条。把逻辑变化真正变成屏幕上的图像。这就是最纯正、最沉重的"渲染管线"。

这一关通常可以概括为五个步骤:

  • Style(重算样式): 引擎的解析器开始分析内存中被修改过了的 DOM 树和 CSSOM 规则树,重新将它们交织匹配,计算出在这一帧里,受影响的每一个 DOM 节点最终所应用的具体 CSS 属性字典,生成一棵带有完整样式属性的结构树。这一步的工作 近似于---得到现在长啥样了。
  • Layout / Reflow(布局重排): 引擎开始顺着这棵树从上到下、从外到内进行严密的几何拓扑计算。它要确定在当前视口大小下,每一个元素在物理屏幕上的精确几何像素坐标、宽高大小、是否换行以及多行文本的实际折行边界。这是渲染管线中最吃 CPU 算力的硬骨头。这一步的工作,近似于---放在哪里呢。
  • Pre-paint(预绘制计算): 这是一个现代浏览器(如 Blink)引入的内部优化阶段。它开始遍历布局树,计算并生成两张极其关键的底层账本:属性树(Property Trees)。属性树将剪裁(Clip)、变换(Transform)、透明度(Opacity)和滚动的几何状态从原本沉重的 LayoutTree 中剥离出来独立存储。这极大地优化了后续在多图层架构下进行坐标变换的性能,注意的是,这是一个更细的中间阶段,用来整理绘制前的状态信息。和裁剪、变换、透明度、滚动等相关的数据,会被组织成更适合后续合成的结构。这一步的意义,是把一部分本来很重的布局信息拆出来,让后续处理更高效。
  • Paint(绘制指令记录): 此时的主线程依然没有在屏幕上画出像素。这一步的本质,是主线程拿着前面算出来的几何数据,去为每一个元素生成一张"像素绘制指令名册"(Display Item List)。名册里记录着类似这样的冰冷指令:"在绝对坐标 (X:Y) 处,画一个半径为 10 的蓝色圆角矩形,接着在上面涂抹一段白色文本。" 主线程只负责把这些剧本写好,就算完成了它的使命。这一步本质上是:把该画什么、怎么画,先整理成一份可执行的绘制计划。
  • Commit(主线程提交交接): 主线程将这些写满了绘制指令的列表(Display Item List)、属性树和最新的逻辑拓扑结构进行最终的打包。在这个极其短暂的瞬间,主线程会被锁定,将这些数据以原子拷贝的方式提交(Commit)给负责后续合成与物理输出的合成器线程(Compositor Thread)。至此,主线程的 Update the rendering 大活宣告彻底功成退场,它解除锁定,事件循环的传送带继续向下转动。



扩展内容:

Commit以后,很多朋友可能会以为就表示已经完工了,屏幕显示了。

但是,Commit以后,我们所关注的 Update the rendering 完工退场,主线程继续事件循环。

而视觉显示这一块则进入另一个阶段。 那么,后面还有什么步骤呢?

Commit 已经把页面的视觉结果整理成可交接的最终清单,那么之后,浏览器还要再跨过三道真正决定"画面能否落到屏幕上"的步骤:Raster 把绘制意图烤成可用图块,Viz 把多路合成结果统一聚合,最后再由显示提交与刷新节拍把这一帧真正送到用户眼前。

我们大致的浏览一下就可以了。

Raster ------ 把"绘制指令"烤成真正可用的图块

Commit 之后,控制权来到了合成器线程(Compositor Thread)手里。它会根据属性树自主决定如何划分图层(Layerize),随后 浏览器并不是直接把画面往屏幕上一拍了事。它还要继续做一件非常具体、非常底层的事:开启光栅化(Raster),把前面准备好的绘制信息,转换成适合图形管线吞吐的实际图块。

这一步可以理解成"把剧本烤成胶片"。

在前面的 Paint 阶段,浏览器记录下来的还是"该画什么"的信息:

背景怎么填、边框怎么画、文字怎么摆、阴影怎么叠、圆角怎么处理。

这些东西本质上还是绘制意图,还是逻辑层面的指令,还没有真正变成可以高效流转的像素资产。

而到了 Raster,情况就变了。

浏览器开始把这些绘制意图进一步拆解、切块、栅格化,转化成更适合后续图形系统使用的纹理数据和图块数据。这样做的意义非常现实:图形系统处理块状数据,比处理一大坨连续的抽象逻辑,更高效,也更容易并行。

你可以把这一步理解成,前面写的是"剧本",这里开始把剧本拍成"可以放映的素材",这些素材不再只是概念,而是能够被 GPU 和后续合成阶段直接消费的实际材料。

所以 Raster 的本质,不是"再算一遍",而是把已经决定好的视觉结果,烘焙成真正可传递、可缓存、可复用的图块。

Viz ------ 把多路 compositor frame 拼成最终大图

如果说 Raster 更像是把单个页面的内容素材准备好,那么 Viz(Visuals Service,通常运行在独立的 GPU 进程中) 更像是一个全局的总控拼装中心。

它负责把来自不同来源的 compositor frame 聚合起来,整理成最终能被显示系统接受的统一结果。

这里最重要的一点是:

现代浏览器最终看到的画面,往往不是单一路径自己画出来的,而是多路内容一起汇合后的结果。

比如,页面内容本身,浏览器 UI,某些跨进程内容,可能存在的额外可视层。

这些东西并不是各画各的就完事了,它们需要在一个统一的图形调度层里,按层级、顺序、遮挡、透明度、变换等规则重新拼装。这个拼装中心,就是 Viz 所扮演的角色。

所以,Viz 真正干的事,不是"继续画",而是把不同来源的画面结果统一收拢,把它们按正确的结构和顺序重新组织,把多个局部结果整合成一个全局可呈现的大结果。

从这个角度看,Viz 的意义非常大。因为它告诉我们:Commit 之后的世界,已经不是单一渲染进程的自我完成,而是整个浏览器图形系统协同作战的开始。

也正因为有了Viz这一层,浏览器才有能力把各种复杂来源的画面统一调度起来,而不会让它们彼此打架。

显示提交与刷新节拍 ------ 让最终结果真正到达屏幕

真正到"用户眼睛里看见"的最后一步,还要经过显示提交与刷新节拍这一关。

这一步非常关键,因为它决定了一个现实问题:

不是画面准备好了就一定立刻看见,而是画面必须赶上显示设备的节拍。

这就像你买了快递,并不是你刚买了快递,你的快递就会马上装车,而是需要等到固定的发车时间窗口, 比如,傍晚6点,快递车来商家收快递,你的宝贝才会被真正揽收。

浏览器把内容合成好之后,还要和显示系统的节拍对齐,等待合适的提交窗口,再把这一帧真正送到屏幕上。

这里,最值得理解的有两件事:

第一个是,整个底层的渲染管线执行完成,不代表已经上屏

浏览器内部把画面整理完,只能说明"这帧准备好了",但准备好了,不等于已经被用户看见,中间还隔着显示提交、刷新时机、以及硬件节拍的配合问题。这是一段非常现实的"排队等待展示"的过程。

如果错过了这一拍,就可能出现部分呈现、延迟呈现,甚至掉帧。

第二个是, 画面必须顺着显示节拍走

屏幕本身不是无限连续地刷新,而是按照自己的刷新率一拍一拍地更新。

浏览器要想把画面稳定地送上去,就必须尽量贴合这个节拍。

这也是为什么 requestAnimationFrame() 这样的 API 之所以重要:它本质上就是在帮你把脚本更新放到更接近下一次重绘的那个窗口里,让你有机会和显示节拍对齐,而不是在完全不合适的时机乱冲一气。


上面内容,可以作为了解内容。



10.3 rAF 与 rIC

这是两个在实际开发中让很多前端开发者面临困惑的 和事件循环深度纠织的原生 API:requestAnimationFramerequestIdleCallback

它们都不是单纯的"延时执行工具",更不是 setTimeout() 的语法替身。它们真正解决的问题,是如何把代码放进浏览器最合适的时间缝隙里:一个放在渲染前夕,一个放在主线程真正空下来的时候。


requestAnimationFrame(rAF):渲染管线前夕的准点出场

必须先为 requestAnimationFrame 进行正名:rAF 既不是标准的宏任务,也不是微任务,它是一个完全依附于 Update the rendering 管线、拥有固定物理执行切点的"拦截器"。

近似于,浏览器经过评估,确定马上要进行update the rendering,它对脚本打招呼:这一帧我准备要更新要重新画了,你现在把这一帧该更新的状态交给我。

这就解释了为什么 rAF 特别适合做动画。

动画最怕的不是"不能动",而是在错误的时机动 :你要么太早,把这一帧还没准备好的状态硬塞进去;要么太晚,错过了这次重绘窗口,只能等下一拍。rAF 的价值就在于,它把你的更新动作塞到了一个和浏览器刷新节奏高度对齐的位置上。

rAF 还有一个很重要的机制特征:它是一次性的

这点很容易被忽略。它不会像 setInterval() 那样帮你机械地每隔固定时间自动续命;你如果想让动画持续,就必须在当前回调里再次调用 requestAnimationFrame()。它把"下一帧要不要继续跑"这件事交给了你决定,而不是让浏览器自作主张替你无限复制同一帧逻辑。

还需要注意的是, rAF只能帮我们把工作安排到"更合适的时机",却不能替我们减轻工作本身的重量。如果把大量计算、复杂布局逻辑、同步读写 DOM、重型数据处理全塞进 rAF,它照样会卡。rAF的作用是节拍对齐,不是降低负载,更不是进行负载清零。浏览器给我们的只是一个更接近下一次重绘的窗口。

因此,rAF 最适合做的事情通常是:更新动画状态、同步视觉位置、批量写样式、提交这一帧的轻量变化。它特别适合"这一帧要显示成什么样"的工作;但不适合"这一帧要完成多少计算量"这种重任务。

rAF 在大多数浏览器里,在后台标签页或隐藏 iframe 中会暂停。这个行为说明它本来就不是为了无差别地"持续跑回调",而是为了给可见页面的视觉刷新服务。

rAF的执行切点是唯一的 ,只有当浏览器经过评估,确定本轮循环一定要刷新画面 、并且已经驶入渲染更新流程内部时,rAF 才会获得执行控制权。它的位置被钉在关卡 5 处------即样式计算(Style)和布局重排(Layout)的最前夕

rAF还有一个基于快照的防阻塞机制 ,为了防止开发者在 rAF 内部写出恶意死循环,规范在处决 rAF 队列时采用的是"锁定快照"策略,这个前面讲过了。

复制代码
// rAF 内部的自我调用
function animationLoop() {
    console.log('当前帧被处决');
    // 如果你在当前帧的回调里,又调用了一次 rAF
    requestAnimationFrame(animationLoop); 
}
requestAnimationFrame(animationLoop);

当主线程驶入关卡 5 并开始执行上面的 animationLoop 时,由于它属于这一帧的静态快照内部。它内部新调用的那一行 requestAnimationFrame(animationLoop)是没有资格在当前帧的渲染管线里抢跑执行

引擎会把这个新生成的回调塞进一个专属于下一帧的 AnimationFrameCallbackCollection(下一个动画帧回调集合)里存起来。这一快照设计,从机制上彻底封死了 rAF 像微任务那样因为无限自我复制、而导致当前帧的渲染管线被永久卡死的致命漏洞。


requestIdleCallback(rIC):空闲周期里的低优先级清扫工

requestIdleCallback 在整个事件循环的处理模型中,扮演着一个卑微的"垃圾搬运工"角色。

rIC的目标非常明确:让开发者在不影响动画和输入响应 的前提下,做一些后台和低优先级工作。它不是为了抢主线程,而是为了与主线程合作

它的执行机会取决于浏览器有没有空闲预算。HTML 标准对 idle period 的定义:当事件循环里没有可运行任务时,浏览器才可能进入 idle period;而且它给这个空闲期设置了一个 50ms 的上界,目的就是保证对新用户输入的响应性。也就是说,浏览器不是在给你一个无限延长的休假,而是在给你一小段可控的喘息时间。前面我们讲过这个空闲周期了吧, 还计算过空闲周期时间,忘记的朋友可以往前翻翻。

它的执行点,位于一轮事件循环的最终末尾(Idle Period,空闲周期) 。只有当主线程的调用栈彻底归零、微任务全部清空、页面该画的视觉管线也全部打包合成移交完毕,且距离下一次物理硬件刷新信号到来之前还剩余宝贵的时间预算(Deadline)时,调度器才会网开一面,去唤醒排在空闲队列里的 rIC 回调。而且,它有严格的时间截止牌: rIC 的回调函数在苏醒时会接收到一个极其关键的 IdleDeadline 对象。

复制代码
requestIdleCallback((deadline) => {
    // 必须在第一行代码里实时盘点自己还剩几毫秒可用预算
    while (deadline.timeRemaining() > 0) {
        doLowPriorityWork(); // 只有预算大于 0,才敢干一点点低优先级的活
    }
});

为了保证用户的交互不会被这些低优先级的小活卡住,规范给这个 deadline.timeRemaining() 设立了一个最大 50毫秒的硬性上限

一旦 rIC 里的代码盘点发现时间预算耗尽,就必须立刻在下一行代码中主动交还主线程控制权,将执行现场封存,以防拖累下一轮高优先级的用户交互。

还记得我们在前面讲过的,浏览器内核用来清理那些带有 removed: true 标记的僵尸监听器的 V8 Idle GC(空闲期垃圾回收机制) 吗?其底层正是位于这片最卑微的空闲节拍中,借由 Isolate::IdleNotificationDeadline 的时间配额,才展开高效率的堆内存物理清理。

所以,rIC 最适合干的活,通常是这些类型, 低优先级缓存整理、预计算、懒加载辅助处理、分析埋点的延后整理、非关键 UI 的补充加工。它更像后台收尾班,而不是前台冲锋队。


这两个 API 经常被放在一起讲,不是因为它们长得像,而是因为它们刚好站在浏览器调度系统的两端。

rAF 面向的是渲染前的视觉更新 ,它追求的是"这一帧画得准不准、对不对、跟不跟得上节拍";rIC 面向的是主线程空闲时的低优先级补作业,它追求的是"页面忙的时候别添乱,闲的时候把剩余工作慢慢清"。一个是向前对齐画面,一个是向后利用空档。

任务先跑,微任务清空,然后浏览器判断是否存在渲染机会;如果要更新渲染,rAF 就在渲染前夕上场;如果这轮真的还有空闲预算,rIC 才可能在后面补充执行。它们不是互相替代,而是把"脚本什么时候插进去"这件事,切成了两个不同的时间层次。

HTML 标准里关于 rendering task source、update the rendering 和 idle period 的安排,本身就说明了这种分工。

最后:

画面相关的、必须和下一帧对齐的,就放进 rAF;不着急、可以延后的,就考虑 rIC;而真正需要立即完成的关键逻辑,不要押在 rIC 上。


10.4 { passive: true } 承诺书的最终归宿

虽然前面我们讲过,这个选项,现在已经是默认的了,但是,作为这四部分内容都出现过很多次的熟脸,我们最后再总结一下。

在过去,当我们在页面上绑定一个高频的触摸或滚动事件(如 touchstarttouchmove)时,用户的每一次手指划过都会引发主线程的一场动荡。

路径 A:未开启 passive 时

当用户的滑动物理信号传来时,负责快速合成响应的合成器线程(Compositor Thread)在第一时间截获了这个手势。但合成器线程发现你在主线程上绑定了原生的滚动监听器,且没有声明 passive 标志。

由于它在底层无法预知你的 JavaScript 回调函数里会不会调用 e.preventDefault() 去强行拦截、甚至扼杀这次滚动,合成器线程在物理层面被迫必须挂起当前帧的滑动渲染,向主线程发送 IPC 信号,同步死等主线程的事件循环去轮询出队、并跑完你的 JS 逻辑结果。

如果此时主线程恰好因为一段长任务陷入了长期控权,或者被微任务的死循环拖住在深渊里,合成器线程就只能在门外沦为陪葬。在用户的宏观体验上,就会表现为移动端页面瞬间卡得像幻灯片一样跳跃掉帧,这就是经典的滑动卡顿(Scroll Jank)。

路径 B:签署 { passive: true } 承诺书后的解耦

而当你在注册监听时,明确勾选了 { passive: true }。这就是向浏览器内核签署的一份"异步执行承诺书"。通过这个标记,你告诉合成器线程:"请直接去跟 GPU 拼装图层纹理、开始滚动渲染,千万不要等我!我承诺我的 JS 回调里绝不调用 preventDefault() 拦你的路,你放一万个心。"

一旦建立了这个解耦协定:

  1. 当用户的滑动手指在屏幕上移动时,合成器线程它根本不需要去敲主线程那扇已经被长任务或微任务死锁的大门,也不需要去事件循环里申请任何任务排队。
  2. 它直接在独立线程里,利用主线程之前交卸给它的图层快照与属性树,在更高的物理维度上和 GPU 闪电般完成了画面的位移与极致丝滑的滚动,画面早已同步刷新到了用户的眼睛里。
  3. 而主线程依然在主线程那条漫长、拥堵的事件循环传送带里磨唧蠕动。过了几十毫秒甚至上百毫秒,主线程才好不容易从任务队列里摸到了这个被包装成普通宏任务的原生滚动回调函数。
  4. 此时主线程里开始执行的 JS 代码,纯粹只是在做滞后的业务埋点记录、或者非核心的数据上报运算!

10.5 要点总结

一、执行栈与异步的基本前提

  • 执行栈(Call Stack)是否清空,是 JavaScript 进入后续调度的重要前提之一。
  • 微任务清空、脚本收尾、部分宿主级清理,通常都发生在当前栈执行完之后。
  • 异步本质上不是并行执行,而是**"先交出去,再在合适时机取回来"**的时间切换机制。

二、事件循环不是"只要空了就立刻画"

  • 事件循环 不等于 渲染循环
  • 浏览器不会因为一个任务结束就立刻绘制,而是会判断当前是否到了渲染机会
  • 是否渲染,通常会综合考虑页面可见性、刷新节拍、主线程压力、当前是否有必要更新等因素。

三、浏览器喜欢"攒起来批处理"

  • DOM、样式、布局相关修改,通常会先被浏览器缓存为待处理状态
  • 浏览器更倾向于在合适时机,把一批变化一起结算,而不是每次改动都立刻重算。
  • 这样做的目的是减少样式计算、布局、绘制的重复开销。

四、滚动、缩放这类事件和渲染高度相关

  • scrollresize 这类事件之所以"感觉和渲染绑得很紧",是因为它们直接影响可见区域和布局结果。
  • 这类高频视觉输入,如果处理不当,很容易把主线程压得很重。
  • 前端优化时,重点不是它们"属于什么队列",而是尽量避免把高频输入和昂贵的布局/绘制操作绑在一起

五、requestAnimationFrame 的定位

  • requestAnimationFrame 适合把视觉更新放到下一次绘制前的合适时机
  • 它更贴近刷新节拍,适合动画和连续视觉更新。
  • 但它不是性能魔法;如果回调里做了重计算,照样会卡顿。

六、requestIdleCallback 的定位

  • requestIdleCallback 适合处理不紧急、可延后、可拆分的低优先级任务。
  • 它不是为了立即响应交互,也不是为了画下一帧。
  • 它的核心思路是:有空就做一点,没空就别抢主线程

七、强制同步布局的风险

  • 浏览器通常希望把样式计算、布局计算延后统一处理。
  • 但如果你在修改样式后,立刻读取依赖最新布局的数据,比如宽高、位置、滚动值,就可能触发强制同步布局
  • 反复"读---写---读---写"会导致浏览器不断重算,性能很差。

八、最重要的优化原则

  • 把"读取布局"和"修改布局"尽量分开。
  • 尽量避免在循环中交替进行读写操作。
  • 对视觉更新:优先考虑 requestAnimationFrame
  • 对低优先级杂事:优先考虑空闲时段处理。
  • 对滚动等高频事件:尽量减少每次触发里的重活。

11.例子

最后,我们来分析一段代码,作为事件的循环和异步这部分内容的结尾。

复制代码
// 代码
console.log('同步 1');

setTimeout(() => console.log('定时器宏任务'), 0);

Promise.resolve().then(() => console.log('微任务'));

requestAnimationFrame(() => console.log('rAF 回调'));

button.style.backgroundColor = 'red';

11.1 第一阶段:始祖任务压栈与异构大撒手(同步执行流)

几个名词解释:

什么是异构: 指由不同指令集、不同调度器、不同内存空间的计算单元组成的系统。

什么是异构协作: JavaScript 主线程(V8)与浏览器的定时器线程、网络线程、合成器线程、GPU 进程等异构单元,通过任务队列这个唯一的消息管道进行协同工作。

什么是异构并发: JavaScript 本身无法实现真正的并发,但它可以将耗时任务卸载给宿主的异构单元并行执行,从而在单线程模型下获得并发能力。。


当这段脚本被渲染引擎(Blink)加载的一瞬间,事件循环的处理模型正式启动,将其作为本轮次事件循环的第一个"始祖级宏任务(Task)"推入了主线程。

慢镜头01:物理堆栈的同频横扫

V8 引擎瞬间接管执行栈(Call Stack),开启了非抢占式调度的协作狂奔:

  • 第 2 行:遭遇 console.log
    • 微观动作: 该函数帧压入执行栈顶,由于是底层 I/O 的同步绑定,控制台毫无延迟地物理输出:'同步 1'。随后该帧迅速弹出执行栈。
  • 第 4 行:遭遇 setTimeout(..., 0)
    • 微观动作: 主线程开启"异构大撒手"的越境旅程。它在物理调用栈里同步调用宿主环境(浏览器)的 Web API,在底层的**浏览器调度系统(定时器模块)**的账本上,同步登记下这个回调函数。
    • 异构并发: 由于超时时间写的是 0(根据 HTML 标准,当定时器的嵌套深度 ≥5 层 时,超时时间才会被强制钳制为 4ms 底线;而对于 <5 层的定时器,标准允许的最小超时是 0ms。但在真实的浏览器物理实现中,受限于操作系统的系统节拍与线程切换开销,非嵌套的 setTimeout(0) 通常会表现出 1ms 左右的实际最小延迟 ),定时器线程在后台几乎瞬间判定到期,并将绑定的 () => console.log('定时器宏任务') 回调逻辑,打包成一个崭新的宏任务,静默地塞进了主线程门外排队的 Timer Task Queue(定时器任务队列) 的末尾。
    • 主线程: 登记完毕,主线程绝不等待,setTimeout API 瞬间弹出执行栈。
  • 第 6 行:遭遇 Promise.resolve().then(...)
    • 微观动作: 这是一个纯正的 ECMAScript 语言级血统调用。由于 Promise.resolve() 产生的实例其内部状态锁 [[PromiseState]] 已经就地钉死为 fulfilled
    • 决议注水: 根据我们第7部分学过的内容,调用 .then 的这一瞬间,V8 引擎根本不需要暂存记录,引擎直接通过 HostEnqueuePromiseJob 宿主机制 ,当场将 () => console.log('微任务') 包装成微任务,推进了当前事件循环专属的微任务队列(Microtask Queue)中。
  • 第 8 行:遭遇 requestAnimationFrame(...)
    • 微观动作: 这是一个完全依附于视觉重绘管线的特权 API。主线程同步调用它,向宿主环境申请一张特权拦截门票。
    • 静态快照登记: 浏览器在底层将这个回调函数塞进与当前文档关联的 动画帧回调列表(List of animation frame callbacks) 中封存,并打上封印:"不撞见真实的视觉渲染切点,绝对不放你出来。"随后,API 快速退栈。
  • 第 10 行:遭遇 button.style.backgroundColor = 'red'
    • 微观动作: 这是一次纯粹的 C++ 内存桥梁跨界修改。控制权顺着 JS Wrapper 的内部指针瞬间穿透至 Blink 引擎的 C++ 堆内存中,同步改写了该按钮节点的行内样式声明(Inline Style) ,并将其旧的样式快照打上 Style Dirty 的失效标记。
    • 时空静止点: 在这一微秒,内存里的状态已经彻底变红!但是,由于主线程的控制权依然被当前的始祖任务攥住,渲染管线根本没有拉动,物理屏幕上的按钮依然是原本的颜色。画面与逻辑在此时彻底绝交。

至此,第一行到第十行的同步代码全部扫荡完毕。最外层的始祖任务终于完成了它的历史使命,从 JavaScript 执行上下文栈中退栈。


11.2 第二阶段:执行栈首度归零,微任务检查点的控权

随着始祖宏任务的退栈,主线程的执行栈在物理层面上首度回归为空(Empty, []

慢镜头02:脚本清理点的爆发

就在栈帧归零的瞬间,标准中的"脚本运行后清理(Clean up after running script)"算法被激活了。算法一看,守卫条件完美触发,于是在主线程的门口拉响警报------微任务检查点(Perform a microtask checkpoint)全面爆发了

  1. 封锁大门: 调度器直接拒绝了去 Timer 队列里拿那个早就到期的 setTimeout 宏任务,也毫不理会屏幕是否需要重绘。主线程将内部的 performing a microtask checkpoint 标志位置为 true(挂上防重入锁,防止在执行微任务的过程中递归触发新的微任务检查点),然后来到 V8 的微任务队列。
  2. 就地正法: 队列里,那个在第 6 行被塞进去的 Promise 微任务早已等候多时。清空算法严格按照 FIFO 规则将其取出,推入执行栈。
  3. 输出结果: 引擎运转,控制台打印:'微任务'
  4. 清到见底: 微任务函数帧弹出。算法再次检查,发现微任务队列彻底见底,变为空。微任务检查点重置标志位,宣告这一轮"微观大清算"落幕。

要注意:微任务检查点的职责仅仅是清空微任务队列。清空结束后,控制权重新回到宿主调度器手中。至于接下来是否立刻进入渲染流程,则完全取决于浏览器接下来对 Rendering Opportunity(渲染时机)的评估。


11.3 第三阶段:渲染时机评估与 rAF

微任务的战场打扫得连一个渣都不剩后,控制权终于交回到了宿主环境的调度器手里。此时,事件循环来到一个决定页面生死的十字路口:渲染时机评估(Rendering Opportunity Assessment)

慢镜头03:V-Sync 命中与视觉管线

假设在这一瞬间,浏览器经过严格评估,结合硬件刷新率(如 60Hz 的 16.6ms 节拍)与页面的可见状态,认为当前已经迎来了一个合适的 Rendering Opportunity(渲染时机) 。 于是浏览器获得了推进 Update the rendering(更新渲染)流程 的机会:

  1. 特权拦截器释放(rAF 阶段): 视觉更新流转到关卡 5。浏览器抓取当初在第 8 行登记的动画帧回调快照,将其推入空荡荡的执行栈。

    • 代码执行,控制台输出:'rAF 回调'
    • 为什么规范要把 rAF 放在这里?因为 rAF 通常位于浏览器即将推进新一轮渲染更新的最前夕,因此它经常被作为本帧最后一次集中修改 DOM 的机会
    • 这里要特别注意 :根据 HTML Standard 规范,在集中处决完所有的 rAF 回调之后,由于 JavaScript 执行栈再次归零,浏览器会立即触发一次完整的微任务检查点 ! 也就是说,如果在 rAF 回调中产生了新的微任务(例如 Promise.then),这些微任务会被当场就地清算,并在接下来的 Style 阶段之前 彻底执行完毕,绝对不会被拖延到下一轮事件循环。
  2. 视觉管线全面推进(The Visual Pipeline):

    • Style 阶段: 引擎解析器开始清算我们在第 10 行留下的那笔"行内样式草稿"。它将 red 这个变动层叠应用,计算出了该按钮在这一帧的终极账目------全新的 ComputedStyle
    • Layout 阶段: 盒模型拓扑几何树被复核。由于修改的只是背景色,没有改变元素的宽高等几何体积,所以幸运地没有触发昂贵的重排(Reflow)。
    • Paint 阶段: 主线程在 Display Item List 名册上写下了最新的像素绘制剧本:"将该按钮涂抹为红色。"
    • Commit 阶段(提交): 主线程打包绘制指令列表与属性树,在这个短暂的瞬间被锁定,通过原子拷贝向独立的合成器线程(Compositor Thread)进行最终的数据交接。这个原子拷贝是 DOM 修改与渲染物理分离的根本保证------一旦提交动作完成,主线程后续对 DOM 的任何突发修改,都绝对无法污染和影响当前帧的渲染结果。
    • 最终像素落地: 合成器线程接收到数据后,自主决定图层划分,调度光栅化(Raster)线程池生成瓦片,随后将数据移交给独立的 GPU 进程(Viz)进行统一的纹理拼装与显示提交。新的画面将顺着显示器的刷新节拍,真正物理地呈现在用户的视网膜中!

11.4 第四阶段:下一轮事件循环开启

当视觉提交的闸门闭合,这一轮包含了"始祖宏任务 --- 微任务清算 --- rAF特权 --- 渲染管线物理输出"的宏大周期,才终于画上了完美的句号。主线程重新陷入了绝对的空白静默([])。

慢镜头 04:全新齿轮的咬合

此时,宿主调度器重新睁眼,开始审视所有的外部宏任务队列。

它在 Timer 任务队列的首位,一眼看到了那个早在第 4 行就被定时器模块塞进来的、已经等待了一段时间的宏任务() => console.log('定时器宏任务')

  1. 提审出队: 调度器伸出大手,开启了全新的、下一轮事件循环的宏观迭代。它将这个定时器宏任务从队列中摸了出来,推入已经空荡荡的 JavaScript 执行栈顶。
  2. 输出结果: 引擎最后一次工作,执行代码,控制台打印:'定时器宏任务'
  3. 退栈休眠: 任务退栈,由于该回调内没有触发新的微任务或渲染,主线程彻底回归休眠,静静等待下一次物理鼠标点击或网络中断的唤醒。

11.5 结论

在"微任务清空后恰好迎来了渲染时机(Rendering Opportunity)"的典型标准周期下,执行时序如下:

  • 【第一:牢固掌权】 同步 1
    • 所处时空: 当前事件循环,始祖宏任务执行期。
    • 底层判定: 只要 JavaScript 执行栈(Call Stack)没有清空,主线程的控制权就绝对不会交出。无论后台的定时器多么紧急、网络请求有多快返回,所有异步任务只能在各自的异构队列中老实排队。
  • 【第二:栈空即爆】 微任务
    • 所处时空: 当前事件循环,宏任务结束边界(微任务检查点爆发)。
    • 底层判定: 微任务是执行栈的"影子"。只要执行栈一空,HTML 规范的脚本清理算法就会强行插播。它拥有凌驾于所有宏观任务和视觉渲染之上的最高清算优先级,必须"就地正法、一清到底"。
  • 【第三:渲染前哨】 rAF 回调
    • 所处时空: 当前事件循环,命中渲染时机(视觉管线启动前夕)。
    • 底层判定: 此时微任务已清空。调度器盘点时间,决定拔下更新渲染(Update the rendering)的闸门。作为渲染管线前夕的特权拦截器,rAF 被定向释放,它代表着本帧最后一次集中执行 JS 动画逻辑与修改 DOM 的机会。
  • **【第四:时空交汇】 物理屏幕上的按钮在此时突然变红 **
    • 所处时空: 当前事件循环,Commit 提交完毕,合成器线程接管。
    • 底层判定: 逻辑草稿终于兑现为屏幕物理像素。主线程完成任务,事件循环的当前轮次在视觉上画上完美句号。
  • 【第五:全新纪元】 定时器宏任务
    • 所处时空: 下一轮事件循环开启。
    • 底层判定: 视觉提交闭合,主线程完全空闲。调度器重新巡视外部的宏任务队列(Timer Queue),捞出到期的定时器任务,推入空荡荡的执行栈,开启一段崭新的事件循环生命周期。

那么,有没有一种可能,定时器宏任务 会比 rAF 回调 先打印出来?

确实有可能! 回想一下 11.3 节的"渲染时机评估"。因为 rAF 的执行依赖于浏览器是否决定进入本轮的渲染更新流程,而 setTimeout 则依赖于任务队列的调度。如果当微任务执行完毕时,浏览器经过评估,发现当前时间尚未越过下一次显示器 VSync 刷新信号的截止时间(Deadline) 。此时,尚未迎来 Rendering Opportunity。此时,调度器在盘点时会极其冷静地判定:"当前尚未迎来渲染时机!现在重绘纯属浪费显卡算力!"

于是,大逆转发生了:

  1. 事件循环直接跳过整个 Update the rendering 管线(rAF 被继续扣留在门外)
  2. 调度器直接转头,去摸下一个宏任务。
  3. 此时 setTimeout 那个定时器任务早就到期排在队列里了,于是它被当场提审出队执行!控制台提前打印:'定时器宏任务'
  4. 甚至,如果在这轮意外提前执行的定时器宏任务中,又派生了新的微任务。那么这些新产生的微任务,会紧贴在这个宏任务结束的边缘被立刻清算。也就是说,定时器宏任务及其产生的微任务,会作为一个坚不可摧的"宏任务+微任务"执行单元 ,被整体插入到随后的 rAF 和渲染流程之前。直到这轮意外上位的宏任务(及其微任务)彻底执行完、甚至后面又跑了几个网络请求宏任务后,时钟终于走到了 16.6ms 的卡点。渲染时机判定通过,rAF 队列才终于重见天日,控制台最后打印:'rAF 回调'

12.附录-知识点

本部分内容,都是比较重要的知识点。

一。事件循环的原材料仓库

(事件循环的原材料 ---> 必须进 JS 主线程)

事件循环(Event Loop)本质上是一个"任务调度器"。无论是宏任务(Task)、微任务(Microtask),还是渲染专线里的 rAF、UI 事件(scroll),它们在底层的数据结构里,其实都只是排好队的 JavaScript 回调函数(Callback)。

JavaScript 是一门单线程语言,它的执行栈(Call Stack)就建立在主线程上。因此,事件循环无论从哪个通道里取出了"原材料",最终的归宿只有一个:把这些 JS 代码推入主线程的执行栈中,让 V8 引擎去执行。

(注:Web Worker 拥有完全独立的执行环境和自己专属的事件循环(Worker Event Loop),它运行在工作线程(Worker Thread)上,与当前浏览器窗口的主线程调度体系相互隔离。但在 Worker 内部,原材料送入其执行栈的宏观逻辑依然成立。)


那么,事件循环的原材料,归纳如下:

通道一主线调度通道(离散任务池 / Task Queue)

这是最传统、最基础的原材料池,负责处理绝大多数离散的业务逻辑。

  • 进货范围(原材料):
    • 用户交互事件回调(clickkeydown 等。注意:scroll 和 resize 并不是典型的 Task。虽然 HTML 标准允许不节流的事件通过 Task 派发,但在现代浏览器中,为了防止主线程卡死,它们绝大多数情况下会被积压,并作为专门的步骤在 Update the rendering 阶段集中处理)。
    • 定时器到期回调(setTimeoutsetInterval)。
    • 网络 I/O 状态回调(XHR、Fetch API)。
    • MessageChannel 消息。
  • 提取调度规则: 一次事件循环(Tick)只从中取出一个任务执行。绝不贪多,保证主线程不会被单一类型的任务长期霸占。

通道二微观状态决议通道(微任务队列 / Microtask Queue)

这批原材料的优先级极高,主要用于处理同步代码执行后遗留的状态决议和 DOM 变化追踪。

  • 进货范围(原材料):

    • Promise 决议后续回调(.then.catch.finally)。
    • DOM 变动观察器(MutationObserver 回调)。
    • 开发者手动调度的微任务(queueMicrotask)。
  • 提取调度规则: 死守执行栈清空的边缘,不惜一切代价"清空见底"

    在每一个宏任务执行完毕,且当前 JS 调用栈为空时,调度器会集中提取微任务。即使在执行微任务的过程中又产生了新的微任务,也会在这一轮被强行执行完。如果不加限制地递归产生微任务,会直接卡死主线程。

通道三渲染专线通道(Update the rendering 内部专属)

(这是最核心、最容易被忽略的隐秘通道)

当硬件的 VSync(垂直同步)信号到来,浏览器决定进行下一帧重绘时(通常每 16.6ms 一次),事件循环会进入 Update the rendering 阶段。

在这个阶段触发的 JS 回调,统统不进入常规的宏/微任务队列,而是严格按照 WHATWG 规范,作为渲染流水线的前置工序,按以下顺序集中唤醒执行:

  1. 渲染前奏事件(UI 积压事件):

    为了防止极高频的滑动卡死主线程,浏览器会将这段时间内的状态变化积压。在此阶段第一步集中执行 run the resize stepsrun the scroll steps这就是为什么连续的 window.onscrollwindow.onresize 是一条独立的渲染通道。

  2. 动画与状态计算事件:

    集中派发媒体查询改变(media query change)以及 CSS 动画/过渡相关的事件回调(如 animationstarttransitionend)。

  3. 视觉特权拦截器(rAF):

    执行 requestAnimationFrame 注册的回调。它的执行时机通常与显示器的刷新率(VSync)严格对齐,是在下一次绘制前同步视觉状态的最佳时机。(注:当页面处于后台标签页或隐藏 iframe 中时,rAF 通常会被浏览器主动暂停以节省算力)。

  4. 现代几何观察器(Render-Blocking Observers):

    它们与浏览器的样式/布局(Style & Layout)计算深度绑定,有着极其严格的执行先后顺序:

    rAF 执行完毕。

    浏览器首次计算 Style 和 Layout。

    执行 ResizeObserver 回调(如果在回调中修改了 DOM 尺寸,会再次触发 Layout 的内部微循环)。

    布局彻底稳定后,在渲染流水线极其靠后的步骤(规范第 19 步)执行 IntersectionObserver 回调。

通道四空闲捡漏通道(Idle Period)

这是一批"优先级最低但不能丢"的兜底原材料。

  • 进货范围(原材料): requestIdleCallback (rIC) 注册的回调。
  • 提取调度规则: 当主线任务(宏/微任务)处理完,且当前帧的渲染也已完成,调度器会计算出一个安全截止时间(Deadline)去拉取 rIC 回调执行。 注意: 这里的 50ms 是规范为了保证"用户输入能及时响应"而设定的上限阀值,并非每次执行必定能拿到的固定时间配额。实际拿到的空闲时间取决于上一帧画完后剩下的毫秒数。一旦时间耗尽(Deadline 归零),即使回调还没执行完,主线程也会挂起任务并交还控制权,等待下一个空闲周期的到来。

主线程上还有大量非 JS 属性、非事件循环队列调度 的底层 C++ 工作在运行。以下这些东西全都跑在主线程上,但它们绝不是事件循环的"原材料"。

以下都不是事件循环的原材料:

浏览器的"纯 C++ 视觉渲染管线"(Rendering Pipeline)

当事件循环进入 Update the rendering 阶段时,除了会执行我们之前提到的 rAF 等 JS 回调外,主线程还要亲自干苦力,执行一套底层的 C++ 逻辑来画图:

  • Style(样式计算): 匹配 CSS 选择器,计算每个 DOM 节点的最终样式。

  • Layout(布局/重排): 计算每个元素在屏幕上的确切几何位置和大小。

  • Paint(绘制/重绘): 记录元素的绘制指令(比如画个红色的矩形)。

  • Layerize(分层)& Commit: 将渲染树交给合成器线程。

结论: 这些全都是主线程在执行的代码,但它们是浏览器内核自带的流水线机制,不需要排队,也没有 JS 回调,根本不是事件循环里的"原材料"。

V8 引擎的"底层杂务"(Engine Chores)

主线程还是 V8 引擎的家,V8 在运行 JS 时,还会时不时停下来做一些内部清理:

  • 垃圾回收(GC): 当内存不足时,V8 会直接在主线程上发起"Stop-the-world"(全停顿)的垃圾回收。这时候主线程什么都不干,专门去清理内存垃圾。
  • JIT 编译与去优化: V8 将 JS 编译成机器码,或者因为类型改变而撤销优化(De-opt),这些编译器级别的工作也会占用主线程的时间。

结论: 垃圾回收和 JIT 编译是底层引擎在干活,它们不属于任何宏任务或微任务,甚至会强行打断当前事件循环的节拍。

同步 C++ 侧的"阻塞调用"(如强制同步布局)

当你在 JS 里写下一句 let h = element.offsetHeight 时,主线程并没有去任何队列里拿新任务。它只是执行到这行代码时,当场变身,从"执行 JS 的工人"变成了"计算布局的 C++ 工人",算完之后再变回来继续执行下一行 JS。

结论: 这种行为是直接占用主线程的同步副作用,和事件循环的进货调度毫无关系。

被错误混入的"伪调度通道",必须将那些"看似在排队,实则在插队"的概念彻底剥离出去。

强制同步布局(Forced Synchronous Layout)

  • 打假:根本不属于事件循环的"进货渠道"或"调度机制"。
  • 原理解析: 当你在一段普通的同步 JS 代码中调用了 element.offsetHeightgetComputedStyle() 时,V8 引擎在执行这行底层 C++ 绑定代码时,会当场、就地触发 Blink 渲染引擎的 Style 和 Layout 计算逻辑。

结论: 它没有脱离当前正在执行的 Task,没有进入任何特殊队列,也没有使用回调函数。它仅仅是同步代码执行时引发的底层计算阻塞(同步副作用)

宿主内部流水线(非 JS 执行层),浏览器的视觉管线(Style、Layout、Paint、Composite)以及底层服务(如 V8 的垃圾回收 GC、JIT 编译优化)确实占据了事件循环的节拍和时间预算。但它们是由 C++ 底层驱动的机制,并不执行用户编写的 JS 回调,因此并不属于事件循环的原材料。


二。渲染更新 Update the rendering

下面是渲染更新的流程,感兴趣的朋友可以阅读参考。

这是标准里 Update the rendering 的主流程。你可以把它理解成:先筛选哪些文档真的要参与这一轮更新,再做前置状态同步,然后给开发者最后一次修改机会,再做样式/布局稳定,最后更新观察器、记录时间、刷新界面。

1。记录这次渲染机会的时间戳

标准第一步是:把 frameTimestamp 设成 last render opportunity time。这意味着这一轮所有需要带时间戳的东西,比如动画、rAF 回调,都会用同一个统一时间点。

为什么要这样做?因为动画、回调、渲染都需要对齐同一帧的时间。这样浏览器不会出现"同一轮里不同步骤拿到不同时间"的混乱情况。


2。构造 docs 列表

浏览器接下来会收集当前事件循环里所有 fully activeDocument,并按规则排序。标准强调:后面每一步遍历 docs 时,都是按这个列表的顺序处理。

这一步的作用是:先把"这一轮要一起处理的页面/文档"统一列出来,因为一个事件循环可能对应多个窗口、多个文档、嵌套导航容器等情况,不是只有一个页面。


3。过滤非可渲染文档

标准的第一道筛选叫 Filter non-renderable documents。会被移除的文档包括:

  • render-blocked 的文档
  • visibilityStatehidden 的文档
  • 渲染被 view transition 抑制的文档
  • 当前 node navigable 没有 rendering opportunity 的文档。

为什么要做这一步?因为浏览器不应该给那些当前根本无法向用户呈现新内容的文档白做工。标准自己也说了,这一步的目的就是防止在无法展示新内容时更新渲染。

怎么做这一步?很简单:浏览器检查每个文档的状态,如果不满足可渲染条件,就从 docs 里删掉。


4。过滤"没必要渲染"的文档

第二道筛选叫 Unnecessary rendering 。如果用户代理认为:这次更新渲染不会产生任何可见效果,而且这个文档的 animation frame callbacks 还是空的,那么这个文档也会被移出 docs

为什么要这样?因为浏览器要避免"画空气"。如果当前没有可见变化,也没有 rAF 回调,那这一轮继续走后面的复杂流程,只是在浪费 CPU。标准明确说,这一步还允许浏览器把多个 timer callback 合并在一起执行,而不插入中间渲染更新。

怎么判断?这是用户代理的判断,也就是浏览器自己根据当前页面状态和实现策略决定。标准没有把这个判断写死成某个固定公式。


5。其他原因的跳过

标准还允许用户代理出于其他理由跳过这次更新渲染。也就是说,前两步不是唯一的过滤条件,浏览器还保留实现层面的优化空间。

为什么要留这个口子?因为浏览器是复杂系统,需要在性能、响应速度、电池、节流、合并任务等方面做折中。标准只要求最终效果合理,不要求每一帧都机械执行同样的耗时流程。


6。reveal

接下来对 docs 里的每个文档执行 reveal。标准就是这么写的。

你可以把这一步粗略理解为:把前面被隐藏、收起、暂时不展示的内容,准备好进入这一轮可见更新。标准没有在这里展开成更细的开发者语义,所以科普写作时最好把它描述成"浏览器内部用于让文档进入可展示状态的预处理步骤"。


7。flush autofocus candidates

然后浏览器会刷新 autofocus 候选,但只在该文档的 node navigable 是 top-level traversable 时做。

这一步的作用是:处理那些应该在这一轮里自动获得焦点的元素。为什么要放在这里?因为焦点状态也是界面状态的一部分,应该在真正进入后面的渲染前对齐。


8。resize steps

标准接着对每个文档运行 resize 步骤。

这一步是在干什么?当视口尺寸、窗口大小、布局容器尺寸发生变化时,需要先把"尺寸变化"同步到浏览器内部状态。为什么要先做?因为后面的样式和布局计算,需要建立在正确的尺寸信息上。


9。scroll steps

然后运行 scroll 步骤。

这一步是在同步滚动状态。为什么要在 rAF 之前做?因为开发者常常希望在 rAF 里拿到"这一帧最新的滚动位置",从而根据最新的滚动状态去修改 DOM。标准把 scroll steps 放在 rAF 前面,正是为了让后面的回调能看到较新的状态。


10。evaluate media queries and report changes

接下来是媒体查询评估与变化报告。

这一步是在做什么?浏览器重新判断当前环境是否满足某些媒体条件,比如视口宽度变化后,某些 @media 规则是否已经变成生效或失效。为什么要在这里做?因为媒体查询变化会直接影响后面的样式计算,必须先同步。


11。update animations and send events

然后浏览器更新动画并发送事件,使用 frameTimestamp 作为时间戳。

这一步处理的是 CSS 动画、Web Animations 等时间驱动的动画状态。为什么要放在 rAF 前面?因为浏览器需要先把动画时间推进到当前帧,后面的 rAF 回调才能基于当前帧的动画状态去做逻辑。


12。run the fullscreen steps

接着对每个文档运行全屏步骤。

这一步是处理全屏状态变化。为什么放在这里?因为全屏会影响页面可见区域和界面状态,必须在真正的样式/布局更新前同步。


13。Canvas context lost 的处理

如果浏览器检测到 CanvasRenderingContext2DOffscreenCanvasRenderingContext2D 的 backing storage 丢失,就要对这些 context 运行 context lost 步骤。标准列出的动作包括:把 context 设为 lost、重置默认状态、触发 contextlost 事件、尝试恢复 backing storage,恢复成功后再触发 contextrestored

为什么要做这一步?因为 canvas 的底层存储可能会丢失,如果不处理,开发者看到的就是画布内容异常或消失。标准专门给了这一套恢复流程,用来把 canvas 状态拉回正常。


14。requestAnimationFrame 回调执行

然后才轮到最重要的开发者入口之一:run the animation frame callbacks。也就是执行 requestAnimationFrame 注册的回调。

为什么这个位置很关键?因为它在样式与布局之前。也就是说,rAF 是开发者在这一帧真正进入渲染计算前的最后一批脚本机会之一。你在这里做 DOM 修改,通常可以参与后面的样式计算和布局更新。

这也是为什么 rAF 被认为是动画和视觉更新的"黄金位置"。标准还规定,rAF 的时间戳也来自刚才那一轮 frameTimestamp


15。记录样式与布局开始时间

rAF 之后,标准记录 unsafeStyleAndLayoutStartTime

为什么要记这个时间?因为后面的样式重算和布局阶段可能很耗时,浏览器需要知道这个阶段从什么时候开始,以便后续做时序记录。


16。样式重算 + 布局更新,直到稳定

这一步是整个流程里最容易被误解的部分。标准写的是:对每个文档进入一个 while true 循环,先 Recalculate styles and update layout,然后处理 content-visibility 的初始可见性判断,再收集 active resize observations;如果仍有 active resize observations,就继续循环,否则 break。

为什么要这么做?因为浏览器不能只算一次就假装稳定了。布局变化本身可能触发新的尺寸观察,新的尺寸观察又可能要求再次布局,所以这里必须反复检查,直到当前帧的几何状态稳定下来。标准还明确说,如果有 skipped resize observations,还要触发 resize loop error。

怎么理解这一步?你可以把它想成:浏览器不断把"页面几何关系"算到没有新的连锁反应为止 。这就是为什么这里不是简单的一次性 layout,而是一个循环。


17。focusing steps

如果文档的 focused area 不是 focusable area,就运行该文档 viewport 的 focusing steps,并把 navigation API 的 focus changed during ongoing navigation 设为 false。

为什么要做这一步?因为页面布局和可见状态变了以后,原来的焦点可能已经不合理了,比如元素被隐藏、禁用、移除。标准后面的说明也提到,这种情况通常会触发 blur,有时还会触发 change


18。perform pending transition operations

然后对每个文档执行待处理的 transition 操作。

这一步和 CSS View Transitions 相关。为什么放在这里?因为过渡效果需要依赖当前布局和渲染状态,浏览器要在渲染流程的后半段统一处理这些过渡收尾工作。


19。run the update intersection observations steps

接下来是 IntersectionObserver 相关更新。标准把它放在样式和布局之后,并且传入当前时间戳。

为什么要在这一步做?因为交叉观察器要判断元素是否进入视口、可见比例是多少、是否相交,前提就是浏览器已经有了最新的布局结果。换句话说,先算布局,再算交叉状态,顺序不能反过来。


20。record rendering time

然后浏览器记录渲染时间。

这一步的意义是给后续性能计时、渲染计时提供时间点。标准把它明确列为单独一步,说明它是渲染更新流程里的一个正式时序标记。


21。mark paint timing

接着是 mark paint timing

这一步和 Paint Timing 相关,用来记录 paint timing 数据。注意,标准在这里写的是"标记 paint timing",不是把"Paint"作为一个独立的大步骤展开。你在科普里可以把它解释成"浏览器记录这次渲染带来的绘制时序信息"。


22。更新界面

然后标准写的是:update the rendering or user interface of doc and its node navigable to reflect the current state

这一步就是把前面所有状态更新、布局结果、动画结果、观察器结果,真正反映到用户能看到的界面上。你可以把它理解为:浏览器终于把这一轮算出来的结果提交给用户界面


23。process top layer removals

最后,浏览器对每个文档处理 top layer removals。

这一步是顶层层叠内容的收尾,比如某些最上层 UI 元素或覆盖层的移除处理。标准把它放在最后,说明这是这一轮渲染更新的收尾动作。


三。Promise 构造函数是同步执行的,只有后续回调才是微任务

原理解析: 当使用 new Promise((resolve, reject) => { ... }) 时,传入的 executor 函数是完全同步执行的,会立即压入当前调用栈运行。如果在 executor 函数中抛出了未捕获的异常,Promise 会立即变为 rejected 状态。

微任务的产生: 只有调用 .then().catch().finally() 绑定的回调函数,才会被注册为异步任务。并且,只有当 Promise 的状态实际变为 resolved 或 rejected 时,这些回调才会被推入微任务队列等待执行。

总结: Promise 本身只是一个同步创建的状态机容器,真正的微任务是在状态决议后才产生的回调反应。


四。async/await 不会阻塞主线程,它是基于 Promise 与微任务的异步控制流

原理解析: 遇到 await 时,主线程并不会原地停顿死死等待异步结果。相反,JS 引擎会挂起当前 async 函数的执行上下文,并将控制权交还给外部的同步代码。

恢复机制:await 等待的异步操作完成后,引擎会将"恢复该函数执行"的任务作为一个微任务推入队列。等调用栈清空并执行到该微任务时,函数才会从挂起点恢复上下文继续向下执行。

总结: 在 ECMAScript 规范层面,async/await 拥有自己独立的底层抽象操作,它并非单纯基于 Generator 的语法糖,而是直接利用 Promise 和微任务队列驱动的非阻塞状态流转。


五。setTimeout(fn, 0) 不代表立即执行,而是尽快加入宏任务队列排队

原理解析: 根据 HTML 标准,设置为 0 毫秒只是向宿主环境请求最小的延迟时间。标准规定:当定时器的嵌套深度 ≥ 5 层时,超时时间会被强制钳制为 4ms 底线;对于非嵌套的定时器,虽然标准未强制要求,但受限于操作系统的系统节拍与线程切换开销,真实浏览器中通常会表现出 1ms 左右的实际最小延迟。

排队机制: 即使定时器已经到期,它的回调也不能强行插队。它必须等待当前正在执行的宏任务完毕、所有的微任务彻底清空后,才有资格被推入主线程执行。

总结: setTimeout(0) 仅代表最小定时延迟,具体执行时间受到嵌套层数、系统节拍以及当前主线程任务积压状况的严格制约。

六。事件循环是宿主环境提供的机制,而非 ECMAScript 规范本身

原理解析: ECMAScript 官方规范(ECMA-262)只定义了"作业队列(Job Queue)"和基本的逻辑流转,并未定义宏大的事件循环模型。

宿主差异: 完整的事件循环模型、宏任务分类、渲染流程等,都是由具体的宿主环境定义的。浏览器的事件循环遵循 WHATWG HTML 标准,需要处理 DOM、用户交互和渲染;而 Node.js 的事件循环由 libuv 驱动,侧重处理文件 I/O 和网络操作。

总结: JS 引擎(如 V8)负责解析执行代码,而代码何时被调度入栈,完全由宿主环境搭建的事件循环体系决定。


七。异步不等于多线程并行,它的核心在于非阻塞的控制权转移

原理解析: JavaScript 的主执行环境是单线程的。即使存在多个密集的计算任务,无论是否用 Promise 包装,它们在主线程上的执行依然是串行的,算力无法在同一物理时刻一分为二。

真实价值: 异步机制的主要价值在于处理耗时的 I/O 操作。在等待外部资源响应期间,主线程不会被阻塞原地死等,而是交出控制权去处理其他逻辑。

总结: 异步是时间轴上的任务调度优化。如果需要实现真正的 CPU 多核物理并行计算,必须使用 Web Worker(浏览器)或 Worker Threads(Node.js)来开辟独立的线程和独立的事件循环。


八。连续无节制地生成微任务会导致极其严重的性能阻塞

原理解析: 事件循环的微任务检查点(Microtask Checkpoint)拥有极高的清算优先级,其策略是"不到空不罢休"。只要微任务队列不为空,浏览器就不会执行任何后续动作。

负面影响: 如果在执行微任务的过程中不断递归生成新的微任务(如无限调用 Promise.then),主线程会被死死锁在这个检查点。这会导致页面完全失去响应,彻底阻断用户的宏任务交互(如点击),并完全剥夺浏览器的渲染更新时机(Rendering Opportunity)。

总结: 微任务适合处理高优先级的状态同步,但绝对不能在其生命周期内挂载核弹级的大批量循环或无限递归。


九。物理事件发生的先后顺序,不等于回调函数的最终执行顺序

原理解析: 外部事件(如点击、定时器到期)发生时,宿主环境只是将对应的回调函数塞入底层的任务队列中。

调度策略(极其关键): 现代浏览器内部维护了多个独立类型 的任务队列(如输入交互队列、定时器队列、网络队列),每个队列内部是严格的 FIFO(先进先出)。但在提取下一个宏任务时,事件循环调度器会在这些队列之间按优先级选取(通常用户交互任务 > 网络 > 定时器)。

总结: 即使定时器先到期并进入了定时器队列,由于调度器偏袒 UI 响应,稍后发生的用户点击回调依然可能会被优先提取执行。

十。微任务无法打断正在执行的同步代码

原理解析: 微任务的执行时机,总是在当前 JS 调用栈清空(即当前宏任务的同步代码执行完毕)后,且在下一个宏任务执行前,被集中清空。

常见误区: 初学者常误以为微任务具有"抢占式"特权,会打断正在执行的普通代码。实际上,只要主线程的执行栈里还有代码在跑,哪怕微任务队列已经堆积如山,也必须耐心等待。

总结: 在事件循环中,最基础的执行先后规则是:正在执行的同步代码 > 当前宏任务所产生的所有微任务 > 队列中的下一个宏任务


参考列表:


本文首发于: 掘金社区

同步发表于: csdn

博客园

码字虽不易 知识脉络的梳理更是不易 ,但是知识的传播更重要,

欢迎转载,请保持全文完整。

谢绝片段摘录。

相关推荐
广州灵眸科技有限公司7 小时前
瑞芯微RV1126B开发板(EASY-EAI-PI2) Easy-Eai编译环境准备与更新
服务器·前端·人工智能·python·深度学习
万少8 小时前
我把 Kimi 接进微信,几分钟做了个随手出图助手
前端
xiaofeichaichai8 小时前
网络请求与实时通道
前端·网络
kTR2hD1qb9 小时前
从 Responses API 到 Chat Completions:一个模型网关的设计复盘
linux·前端
kyriewen10 小时前
浏览器缓存最强攻略:强缓存、协商缓存、CDN、更新策略,一篇搞定
前端·面试·浏览器
持敬chijing10 小时前
Web渗透之SQL注入-联合查询注入-注入点数据类型判断
前端·sql·安全·web安全·网络安全·安全威胁分析
卷帘依旧11 小时前
Web3前端一面
前端
古韵11 小时前
告别手写分页逻辑:usePagination 从 50 行到 3 行
java·前端