吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 378|回复: 3
上一主题 下一主题
收起左侧

[Web逆向] web逆向基础知识--- js 事件 事件循环 和异步

  [复制链接]
跳转到指定楼层
楼主
f20171110 发表于 2026-6-7 14:11 回帖奖励

一 、事件的综述

在web逆向  js逆向  前端逆向中,  js语言本身是必须掌握的内容,甚至对于js内部原理和实现的掌握程度,比对普通前端开发者的要求都要高些。 js本身并不难,难点在于 js和js引擎,比如V8,js和js引擎和宿主环境 比如Blink 之间交织在一起。当然,主要重点就是js和V8。  至于这一篇的内容,事件和事件循环,主要是js和宿主环境 Blink 的,但是期约是js原生的。所以这篇文章的第一  第二部分,可以作为浏览性了解,第三部分事件的传播和处理  第四部分循环和异步 可以适当的多了解一下.
事件循环这个时序性的问题,对于web逆向来说,需要了解,但不是重点。


但是,在现阶段,对于开发者来说,不管是哪方面的开发者,js和浏览器方面的知识掌握,都是多多益善。


一 事件的综述

二 事件的完整生命周期

三 事件的传播和处理

四 事件的循环和异步

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

一二三的完成时间,距离现在时间比较长了, 第四部分是最近完成的,讲解比较通俗易懂,可以重点了解。


首先需要了解几个术语:

宿主环境:将js引擎作为一个组件包含在内,并且为它提供运行所需的资源的外部系统
就是说,宿主环境提供了所有的资源,比如网络 文件 渲染 各种功能接口等等,没有了宿主环境,     js引擎就是光杆司令,它就只能空转,做不了任何事情。

宿主对象:所有不是由 JS 语言本身定义的、而是由环境提供的对象/功能/api,都叫宿主对象。

比如 Fetch document  XMLHttpRequest    等等 由宿主环境提供的都叫宿主对象。

而js语言定义的对象,比如 Array, Object, Promise, Math, Map 等等,是js的原生内置对象。

我们常说的 事件循环  任务队列 都是由宿主环境提供并且管理的。

说 js能怎样怎样,实际上是  js语言的能力+宿主环境赋予的能力 。


1。  事件的核心定义

JavaScript 中的事件是宿主环境提供的一套标准化的异步消息分发机制,是系统内发生的、可被代码侦测到的“发生”或“信号” 。

事件是一种能力,那么是不是所有的对象 所有的元素  所有的节点,都具备事件的能力呢?

并不是所有, 这里就要提到一切的源头  事件目标 EventTarget  。

EventTarget是一个接口  是一个对象 ,你要具备事件的能力,必须要实现这个接口。

记得红皮书里讲那个迭代器部分,说要想具备迭代功能,必须实现迭代协议。事件也类似,

EventTarget是宿主环境提供的一种能力 一种功能 一个对象,拥有了它 就拥有了事件的能力。

这里要注意的是,事件 是宿主环境 也就是浏览器提供的,并不是js语言本身所有,这点很重要。

那么,如何获得这种能力呢?

  • 继承源头

    创建一个类 , extends EventTarget ,直接获得原生的正宗事件能力。

  • 纯手工写

  • 引入其他事件库

  • 框架内置

以上所说,是如何获得事件的能力, 而在平常的开发中,绝大部分,都是通过原型链,直接继承了EventTarget ,并不需要特地去获得。

所以,在很多文章中,并没有提到EventTarget,因为单纯从js的角度来说,它处理的元素 节点 对象 等等  都已经通过原型链拥有了  或者通过一些框架内的自定义实现了或者封装了事件的能力。

2。  事件的来源

事件的来源,分两个层面,一个层面  是规范中定义的来源,灵一个层面  是浏览器具体实现的队列。

  • 规范定义

    • dom源
    • ui用户接口源
    • 网络源
    • 导航和历史源
    • 渲染源

      这是几个主要的事件来源。

  • 浏览器的具体实现

    浏览器将不同的来源的事件,映射为自己的多个任务队列,并不是完全按照规范中定义的来源来划分宏任务队列的。至于优先级,浏览器有自己的优化和调度策略 比如用户交互高优先 防鸡鹅调度打捞低优先等等。

    这些队列,一般来说是依据优先级的大小来划分。

    • 输入事件队列   通常是最高优先级

      处理用户的交互,保证用户打字 滚动  点击 没有延迟

    • 计时器事件队列  普通优先级

      settimeout  等,  有限的优先级,定时器中的回调函数都放在这里等待执行。(settimeout实际是一个浏览器提供的api函数,它是一个同步执行函数,但是做的是异步调度的工作)

    • 普通事件队列   一般默认优先级

      最常用的队列  处理逻辑的主战场  网络  文件  数据  等等

    • 空闲队列   最低优先级

      requestIdleCallback

      事件循环完全空了   没事做的时候   来这里瞄一眼。

3。 事件和观察者模式

观察者模式是一种软件设计模式。它定义了一种一对多的依赖关系。

  • “一” : 指的是被观察者。当它的状态发生改变时,它会对外发送通知。

  • “多” : 指的是观察者。它们一直盯着“被观察者”,一旦收到通知,就会自动执行相应的操作。

    而事件机制,是对观察者模式的一个实现。

    我们写代码时

  • DOM 节点(如 button 就是 被观察者

  • 我们写的回调函数(function() { ... } 就是 观察者

  • addEventListener 就是整个观察者模式的核心api,它安排了一个或多个观察者去盯着被观察者。

  • 被观察者状态发生改变,触发通知

4。  事件和DOM事件

可能还是有不少朋友对事件这个概念有疑惑。

事件 是归属于宿主环境的,请记住js语言中 并没有事件的概念。

事件是一个信号,是系统内发生的任何值得注意的事情。比如:键盘按下了、图片加载完了、网络断了、数据到了。。。。。。

DOM 事件只是这个庞大信号系统中的一部分,专门负责网页内容(文档)层面的交互。

之所以把DOM事件单独拿出来说,是因为它是我们在编写代码时,接触最多的一类事件。

   DOM 事件 是指发生在 HTML 文档元素(节点) 上的特定的交互瞬间。

   核心特征: 它们必须依附于某个 DOM 节点(如 <div>, <button>, document)。

   典型场景: 用户和网页 UI 的交互。

   常见例子:

   click (鼠标点击)

   keydown (键盘按下)

   submit (表单提交)

   touchstart (手指触摸)

那么作为对比,除了DOM事件以外,还有什么非DOM事件呢?

   A. BOM (Browser Object Model) 事件 / Window 事件
   这些事件发生在浏览器窗口层级,而不是具体的 HTML 标签上。

   resize: 浏览器窗口大小被改变。

   scroll: 页面滚动(虽然常绑定在 document,但本质是视图窗口的行为)。

   hashchange: URL 的锚点(#后面部分)发生变化(单页应用路由的基础)。

   storage: localStorage 或 sessionStorage 被修改时触发(用于跨标签页通信)。

   online/offline: 网络连接状态断开或恢复。

   B. 网络请求事件 (Network Events)
   当 JS 发起异步请求时,请求的状态变化也是事件。

   XMLHttpRequest (AJAX):

   readystatechange: 请求状态改变。

   progress: 下载进度。

   load/error/timeout: 请求成功、失败或超时。

   WebSocket:

   open, message, close, error。

   C. 媒体事件 (Media Events)
   专门针对 <video> 和 <audio> 对象的播放状态。

   play / pause: 播放/暂停。

   ended: 播放结束。

   volumechange: 音量改变。

   waiting: 缓冲中。

   D. 跨线程/跨窗口通信事件
   Web Worker: message 事件(主线程和 Worker 线程互相发消息)。

   iframe: message 事件(父页面和子页面通信,即 postMessage)。

   E. 开发者自定义事件 (Custom Events)
   这是最高级的用法。不由浏览器触发,而是由代码手动触发。

   使用 new CustomEvent() 创建,使用 dispatchEvent() 发送。

   用途: 用于组件间通信。可以手动派发一个事件,而不是依赖点击。

那么,DOM事件和非DOM事件,有什么区别吗?

DOM 事件:

因为 DOM 结构本身是一棵树(Tree)。 当你点击一个按钮时,你不仅仅是点击了这个按钮,你同时点击了包裹它的 div,点击了 body,点击了 html,甚至点击了整个浏览器窗口。

  • 特征: 事件会在 DOM 树上“旅行”。
  • 路径: 捕获阶段(从外向内) -> 目标阶段(到达节点) -> 冒泡阶段(从内向外)。
  • 结果: 你可以在父节点(比如 div)上监听到子节点(button)的事件。这就是事件委托的基础。
非 DOM 事件:

比如 XMLHttpRequest(网络请求)或 Worker(线程通信)。它们的对象没有“父节点”的概念,它们是内存中独立的 JS 对象。

  • 特征: 只有目标阶段
  • 路径: 事件直接发送给该对象,触发完就结束了。它不会传给它的“上级”(因为它没有上级)。
  • 结果: 你不可能在 window 上通过冒泡监听到某个具体 ajax 请求的 load 事件(除非你自己手动去转发)。

除了上面所说的传播机制不同,还有一个极其重要的区别:与浏览器原生行为的绑定。

DOM 事件: 通常带有浏览器的默认行为

  • a 标签的 click 会导致跳转。
  • form标签的 submit 会导致刷新页面。
  • 键盘的 keydown 会导致输入文字。
  • 因此: DOM 事件提供了 e.preventDefault() 来阻止这些行为

非 DOM 事件:  通常纯粹是信息通知

  • XHRload 只是告诉你加载完了。

  • 因此: 非DOM事件通常没有(但有例外)所谓的“默认行为”可供阻止。你调用 e.preventDefault() 没有任何意义。

5。  事件和事件对象event

通常来说,一个事件,之所以能成为事件, 要具有三个特质:

  • 遵循观察者模式
  • 携带事件的现场数据
  • 可观测的发生或状态的改变

    那么 携带事件的现场数据 ,这个就是要讲的event了。

    很多文章说,事件发生 比如鼠标被点击  马上就有事件对象被创建, 这个说法其实并不准确。

    严谨的描述 event 的创建时机:在事件被包装成任务 放入红任务队列排队 然后被取出开始执行,执行的第一步 是进行命中测试,确定事件发生的目标, 第二步,才是创建事件对象 。 第三步 是路径计算,确定传播路径。

    关于具体的流程,下面会详细讲。这部分作为综述,只是讲事件对象本身。

    在js层面的事件对象被创建之前,所有的相关信息,只是作为一个内存中的 c++ 结构体存在。

    那么  这里可以再给事件对象一个较为明确的定义:

    事件对象是浏览器将底层存有事件信息的 C++ 结构包装成 JS 对象,并在路径计算前完成创建,目的是为了让路径计算算法能读取其配置,并再气候的传播过程中充当一个携带现场数据及动态上下文的载体

    我们知道,以前的很长一段时间,前端的情况是  先有规范  再有实现 或者 先有实现  才有规范 或者虽有规范 但是实现不完全符合规范 ,总之是比较混乱,但是现在的情况已经好了很多,我们已经可以逐渐的信赖规范了。学习的时候  尽管实现上有些许的差别,但是可以用规范去加强理解。

    规范含义:在 ECMAScript 相关的规范中,[[ ]] 形式的名字表示一种抽象的内部插槽,它们定义了对象在语义上的内部状态或行为。它们是规范用来描述对象如何工作的术语,不是 JS 层能直接访问的普通属性。

    实现层面:js引擎和浏览器会用各种方式来实现这些规范中定义的抽象的内部插槽。

    JS 提供的可访问接口:很多内部插槽会通过公开的属性或方法提供出来(例如 event.typeevent.targetevent.bubbles 等,不止事件对象,js的其他对象也是如此。),这些公开接口并不是“直接读写了内部插槽”,而是这些内部状态的一种通过api暴露出来的方式。

    因为事件对象可以说是事件中最重要的部分,所以,很有必要重点来学习,下面 我们用比较大的篇幅来详细学习事件对象。

    事件对象,从js的角度来讲,它确实是一个真正意义上的对象,我们平常从红皮书 或者权威指南上看到的js对象的定义,略有简化,请记住这个终极理解:

    js对象的本质 = 非原始值 + 属性记录集合 + 原型链继承 + 由内部槽/内部方法决定行为

    从这个角度来说, 事件对象完全符合js对象的本质定义。

    读过js红皮书的朋友也许记得,在不少章节中 都有 ... 这样的内部属性的写法,也就是上面所说的内部插槽。

    我们首先介绍js事件对象的内部插槽:

    核心状态插槽

    定义在 Event 接口中,所有事件对象共用。

    内部槽位 类型 描述
    [[type]] String 事件类型(如 "click", "load")。初始化时设定。
    [[target]] EventTarget? 初始派发目标。在 dispatchEvent 调用时被设定。
    [[relatedTarget]] EventTarget? 与事件相关的次要目标(主要用于 MouseEventFocusEvent)。注意:它也参与重定位。
    [[currentTarget]] EventTarget? 当前正在执行监听器的对象。在传播过程中实时更新,派发结束后重置为 null。
    [[eventPhase]] Integer 当前阶段:0 (NONE), 1 (CAPTURING), 2 (AT_TARGET), 3 (BUBBLING)。
    [[timeStamp]] DOMHighResTimeStamp 事件创建时间(相对于 Time Origin 的高精度时间戳)。
    [[isTrusted]] Boolean true 表示由 UA(浏览器)生成;false 表示由脚本创建。
    [[path]] List<Struct> 传播路径。由一系列结构体组成,每个结构体包含 item (invocation target) 等信息。
    [[touch target list]] List (仅用于触摸逻辑)用于处理“隐式捕获”,即手指移出元素后仍将事件发送给初始目标。

path 是传播路径,关于它的结构和填充,我们后面会详细的学习。

标志位插槽

通常在实现中会被压缩为一个 Bit Field 以节省内存。

内部槽位 (Flag) 描述
[[stop propagation flag]] 设置后停止向后续节点传播(stopPropagation)。
[[stop immediate propagation flag]] 设置后停止传播停止当前节点剩余监听器的执行。
[[canceled flag]] 设置后表示默认行为被阻止(preventDefault)。
[[in passive listener flag]] 标识当前是否处于 passive 监听器中(此时忽略 preventDefault)。
[[composed flag]] 标识事件是否可以穿越 Shadow DOM 边界传播。
[[initialized flag]] 标识事件对象是否已完成初始化(防止重复调用 initEvent)。
[[dispatch flag]] 标识事件是否正在派发中(防止重入/多次 dispatch)。
[[bubbles]] 标识事件是否支持冒泡。
[[cancelable]] 标识事件的默认行为是否可取消。
子类专用槽位

根据事件类型(C++ 类)的不同,按需存在的槽位。以下列举最核心的几类。

a.  CustomEvent 接口
内部槽位 描述
[[detail]] 存储开发者传入的自定义数据(payload)。
b. UIEvent 接口 (鼠标、键盘事件的基类)
内部槽位 描述
[[view]] 通常指向 WindowProxy(即 window 对象)。
[[detail]] 对于 UI 事件通常是数字(如点击次数),不同于 CustomEvent 的 detail。
c. MouseEvent 接口
内部槽位 描述
[[screenX]], [[screenY]] 屏幕绝对坐标。
[[clientX]], [[clientY]] 视口(viewport)相对坐标。
[[ctrlKey]], [[shiftKey]], [[altKey]], [[metaKey]] 修饰键状态(按下为 true)。
[[button]] 触发事件的按键(0:左,1:中,2:右)。
[[buttons]] 当前按下的按键(位掩码,例如 1=Left、2=Right、4=Middle、8=Back、16=Forward)。
d. KeyboardEvent 接口
内部槽位 描述
[[key]] 键值字符串(如 "Enter")。
[[code]] 物理按键代码(如 "KeyA")。
[[location]] 按键位置(如 DOM_KEY_LOCATION_STANDARD)。
[[repeat]] 是否为长按自动重复。
[[isComposing]] 是否在输入法(IME)组合过程中。
结构化/底层实现槽位
内部槽位 描述
[[Prototype]] 指向 Event.prototype 或子类原型。
[[Extensible]] 对象是否可扩展。
[[NativePointer]][[EmbedderField]] 这是js包装对象中存储的指针,指向底层C++ 的 原始对象

最后还有内部槽位通过对象属性对外提供的可访问的部分,即公开接口,在后面的部分会详细学习。

上面是出于对知识的完整性考虑,列出的表格, 在实际学习中, 前端开发者,了解到事件对象的插槽/槽位的深度,就已经是极限了,再继续深入学习,就是对应的c++结构,毫无必要。

而没有列出的path路径字段和内部槽位对外提供的可访问接口,后面会专门学习。

我们继续回到事件对象的创建,有两种创建方式:

  • 原生事件的创建

    比如鼠标点击  网络事件  等等,这类事件,是在宏任务被取出,执行第一步命中测试,取得具体目标,第二步创建事件对象时创建的, 一旦确定了目标元素,浏览器引擎(C++ 层,而不是 JS 引擎)就会实例化一个 Event 对象(例如 MouseEventPointerEvent)。这个对象是宿主对象,它被填充了所有相关的上下文信息:target(刚刚找到的元素)、currentTarget(最初为 null)、坐标、时间戳、bubbles 属性等。(这些信息,原本是存在于值钱的c++结构中。) 这个时候,浏览器会在 JS 环境上创建一个 JS wrapper。这个 wrapper 和底层的 C++ 对象互相关联(wrapper 内含对宿主对象的指针/引用,就是上面表格中的[[NativePointer]][[EmbedderField]],而宿主对象则通常保存一个对该js包装对象的弱引用或记录,以便于重复利用该js对象)。

    至此,js已经有了事件对象,虽然是‘包装对象’,但是依旧是真正意义上的js对象。

  • js创建的事件对象

    是在js代码中自己创建的,通常使用  new ,在最新的红宝书第5版里,依旧在使用createevent的方式,已经不建议使用了。在自己new事件对象时,需要知道自己使用哪种具体事件的构造函数,因为每种具体的构造函数所拥有的内部槽位不同,无法混用或通用。

    另外,js创建的事件对象,是同步创建的,执行到new代码,对象事件就立即生成, 这和原生的事件对象的创建不同。 自己new的事件对象 是纯正的js对象, 原生事件对象  是包装对象, 但是  他们都是真正的js对象。

    事件对象的创建详细过程将在后面的事件的生命周期部分介绍。

二、 事件的完整生命周期

在第一部分中, 介绍了事件中的一些重要的知识点。

重要的是eventtarget和event。

请注意,不要把这两个概念搞混淆了。

EventTarget 是一切的源头,它让某个东西,具备了事件处理能力。任何能处理事件的东西,都必须是已经实现了(继承也好 自己写也好 使用第三方库也好 )这个接口。

Event 是 一次事件的全部内容与状态的载体  它包含一次事件中的所有状态 (所有状态 所有关联到的对象 所有动态行为等等)

在这第二部分里,我们介绍事件的完整生命周期。

以一个物理点击事件为例,他的整个生命流程如下:

  1. 物理信号: 用户在硬件(例如鼠标或触摸屏)上按下。设备向操作系统 (OS) 发送一个硬件中断信号,并附带位置数据。

  2. OS 路由: 操作系统(例如 Windows、macOS、Android)接收该信号,确定哪个应用程序处于活动状态(即浏览器),并将此低级输入(例如“鼠标按下,坐标 X:Y”)传递给浏览器的浏览器进程 (Browser Process)

  3. IPC 到渲染器: 浏览器进程负责浏览器的“外壳”(地址栏、选项卡),但它不知道选项卡内的内容。它通过进程间通信 (IPC) 将事件(例如 mousedown)和坐标发送到负责该选项卡的渲染器进程 (Renderer Process)

  4. 合成器线程接收: 在渲染器进程中,事件首先由合成器线程 (Compositor Thread) 接收。该线程独立于主线程(js运行的地方)运行,负责平滑地合成页面的各个层(例如,用于平滑滚动)。

  5. 合成器命中测试: 合成器线程执行一次“快速”命中测试。它检查事件坐标是否落在它标记为“非快速滚动区域”(Non-Fast Scrollable Region) 的地方。该区域是页面上附加了事件处理程序(如 touchstartclick 监听器)的区域 。

  6. 事件路由决策:

    • 如果事件不在非快速滚动区域(例如,在可滚动的空白区域),合成器线程可以立即处理它(例如,开始滚动页面),而无需等待主线程 。

    • 如果事件非快速滚动区域,合成器线程必须将该事件转发到主线程 (Main Thread),因为只有主线程才能运行 JavaScript 。

    • 在合成器线程的决策逻辑中,存在一个关键的性能瓶颈:当合成器线程发现触点位于“非快速滚动区域”(即绑定了 touchstart/wheel 等事件)时,默认情况下,它必须挂起页面的滚动渲染,先向主线程发送事件信号,并同步等待 JS 回调函数的执行结果。

      为什么要等?因为浏览器无法预知你的代码中是否会调用 e.preventDefault() 来阻止默认的滚动行为。这种“跨线程的同步等待”一旦遇上主线程繁忙,就是造成移动端页面滑动卡顿(Scroll Jank)的根本原因。

      { passive: true } 的本质,是开发者向浏览器签署的一份“异步执行承诺书”

      通过这个标记,你告诉合成器线程:“请直接开始滚动渲染,不要等我。我承诺在回调函数中绝不调用 preventDefault()。”

      一旦建立了这个协定,合成器线程就会立即处理滚动帧(保证丝滑流畅),同时将事件以非阻塞的方式发送给主线程去执行逻辑。此时,即便你违约在回调中强行调用了 preventDefault(),浏览器也会直接忽略该指令并在控制台抛出警告。

  7. 排队成为宏任务: 当事件(现在是 C++ 层面上的一个结构)到达主线程时,它不会立即执行。它被封装并放入红任务队列(也称为“任务队列”或“回调队列”)中,等待执行。此时,它已成为 JavaScript 事件循环模型的一部分。

  8. 事件循环出队与任务启动: JavaScript 事件循环机制持续监控着状态。当主线程的调用栈为空,且微任务队列也被清空(确保前一个循环彻底结束)时,事件循环才会从宏任务队列中取出那个排队已久的 mousedown 任务。 注意: 取出这个任务,标志着浏览器开始执行该任务内部包含的一系列逻辑

  9. 主线程命中测试(深度): 任务执行的第一步是在主线程上进行“深度”命中测试。与合成器线程(只知道图层)不同,主线程拥有完整的 DOM 树、CSS 样式和布局信息。它使用这些数据(特别是“绘制记录”)来精确确定事件坐标下最顶层的 DOM 元素。这个元素将成为 event.target

  10. 创建事件对象: 一旦确定了目标元素,浏览器引擎(C++ 层,而不是 JS 引擎)就会实例化一个 Event 对象(例如 MouseEventPointerEvent)。这个对象(一个“宿主对象”)被填充了所有相关的上下文信息:target(刚刚找到的元素)、currentTarget(最初为 null)、坐标、时间戳、bubbles 属性等。

    注意   :现在 浏览器引擎会让js引擎创建js层面的事件对象,就是把c++层的宿主对象包装成js层的事件对象。但是,浏览器出于优化的考虑,也许会采用懒加载的方式  在第11步完成后,按需让js引擎创建js事件对象。   不过从整个流程的合理性来说,可以认为此时 js事件对象也被创建。

  11. 确定传播路径: 浏览器根据 DOM 树结构计算事件的完整传播路径。这是一个包含从 window 开始,一直向下到 event.target 的所有祖先元素,然后再回到 window 的有序数组。

  12. 开始调度(捕获阶段): 任务现在开始沿着计算出的路径“调度”事件对象。它从 window 开始,向下传播到目标,在每个节点上触发已注册为在捕获阶段运行({capture: true})的 JavaScript 监听器 。

  13. 目标阶段: 这是一个特殊的阶段。规范在实现上并没有一个独立的“目标阶段循环”,而是将其拆解到了另外两个阶段中。

    1. 捕获遍历到达目标时,浏览器会将目标标记为 AT_TARGET,并执行目标上所有 capture: true 的监听器。
    2. 冒泡遍历开始时,浏览器再次访问目标,将其标记为 AT_TARGET,并执行目标上所有 capture: false(非捕获)的监听器。

    所以,实质上是捕获类监听器先执行,非捕获类监听器后执行同类监听器内部,才按添加顺序执行。

  14. 冒泡阶段: 事件随后从 event.target 向上传播回 window,在路径上的每个祖先元素上触发标准的(冒泡阶段)JavaScript 监听器 。

  15. 任务完成: 一旦事件到达 window 并且所有处理程序都已运行(前提是没有调用 stopPropagation()),这个宏任务就完成了。

  16. 微任务检查点: 在事件循环查找下一个宏任务之前,它会立即执行并清空微任务队列中的所有任务(例如,在事件处理程序中调度的 Promise.then() 回调)。

  17. 渲染: 在微任务队列清空后,浏览器现在有机会执行渲染更新(重绘页面)。

  18. 循环: 事件循环现在返回宏任务队列,以查找下一个任务。

下面,我们将以这整个流程为线索,介绍几个重要的步骤

从结构的角度来讲  物理点击  浏览器c++层创建初始结构  包装成宏任务入队列   被取出执行  命中测试 确定目标元素  浏览器c++层将初始结构和目标组合一起 创建了一个新的c++结构 填充槽位    浏览器调用js引擎  让js创建js层的事件对象 (包装了c++层的结构)填充关键槽位(可能有懒加载)  建立和c++结构的关联。

在具体的浏览器实现中, 有时会将第11步计算传播路径提前, 先计算传播路径   再开始创建js的事件对象。主要目的是可以通过先计算传播路径,确定是否有针对具体目标的监听, 假如没有, 那就根本没必要创建js的事件对象了。

介绍一下内部插槽的填充:

在命中测试完成 确定了具体的目标元素, 这个时候浏览器会创建一个c++事件实例结构,包括了最初的那个结构 又包括了目标元素,还有和事件类型相对应的内部槽位, 这是因为浏览器会根据事件的不同 调用不同的构造函数  创建不同的c++事件实例。每种事件实例都有专属于自己的槽位, 同时还有通用槽位。

浏览器创建事件对应的c++事件实例,填充内部槽位,我们先介绍静态数据, 这些数据一旦填充,在整个生命周期就不会改变。以下以一个点击事件为例

  • [[type]] 根据事件类型(如 "click")硬编码  静态只读

  • [[isTrusted]]  值被设为true(因为是浏览器原生触发,如果是脚本模拟自定义 则为假)静态只读

  • [[timeStamp]]  读取当前的高精度时间  静态 (只读)

  • [[target]]  指向命中测试找到的最深层的那个 DOM 节点。  半静态 (Shadow DOM 中表现不同)

  • [[NativePointer]]  指向底层的 C++ 结构体地址。  静态 (内部引用)

  • [[screenX/Y]]  读取操作系统传入的硬件光标坐标数据。  静态

  • [[bubbles]] 根据事件类型查表确定(例如 "click" 默认为 true,而 "focus" 或 "scroll" 默认为 false)。 状态: 静态 (只读)

  • [[cancelable]] 根据事件类型查表确定(指示该事件是否允许通过脚本取消默认行为)。 状态: 静态 (只读)

  • [[defaultPrevented]] 初始化为 false。 仅当脚本调用 event.preventDefault()[[cancelable]] 为真时,该值才会被修改为 true状态: 动态 (可变)

  • [[propagationStopped]] 初始化为 false。 这是一个内部控制标志,当脚本调用 event.stopPropagation() 时被设为 true,用于通知事件分发器停止遍历后续路径。 状态: 动态 (内部标记/不可见)

  • [[underlying_platform_event]] 保存对原始底层硬件输入结构的 C++ 指针引用 就是最开始的那个初始c++结构。这实现了“零拷贝”机制,仅在 JS 访问特定属性(如 pressure, tiltX)时才通过此指针去读取底层数据。 状态: 静态 (内部引用)

注意:此时,[[currentTarget]] 还是 null[[eventPhase]]NONE (0)

这里插一段,写这篇文章,一是自己需要对知识的总结归纳 二是希望写出来 是种分享,大数据时代  我们除了获取,不能忘记提供,关于木有图片。。。是因为我没有图床。。。其实就是懒。关于木有代码实例。。。还是因为懒。我喜欢用文字来描述来表达,虽然可能很多地方表达能力跟不上自己的想法。。。我是尽力了。其实我是有整篇的写作意图和明确的串联线索,只是写的多了,有时候就忘记或者是跑偏了,反正就是能力跟不上,反正就一个特点:字多。 大家当成小说看吧,其实我以前是写网文的。

我们学习到现在,已经能青春地意识到:

  1. “真身”在下层: C++ 层面的事件实例 才是真正意义上完整、权威的事件状态载体。它用有物理原始数据、DOM 传播的实时状态指针以及所有标准定义的内部槽位。
  2. “外壳”在上层: 我们在代码中操作的 JS 事件对象,本质上只是js 引擎创建的一个 代理壳 (Proxy/Wrapper)
  3. 核心连接: 这个壳内部并不直接存储大量数据,它最核心的东西是一个指向 C++ 结构的 内部指针 ([[NativePointer]])
  4. 数据获取的方式: 当我们在 JS 中访问属性时,并不是简单的读取内存,而是根据属性的特性,触发了不同的底层机制:
    • 实时透传 : 对于动态变化的数据(如 currentTarget, eventPhase),JS 对象通过 Getter 访问器 直接穿透到 C++ 结构中读取最新值。
    • 懒加载: 对于昂贵的计算属性(如 composedPath() 或标准化的 path),只有当 JS 第一次请求时,C++ 才会计算并将其转换为 JS 数组,然后挂载到 JS 对象上。
    • 缓存和优化: 对于静态不可变数据(如 type, timeStamp, isTrusted),js引擎可能会在第一次读取后将结果缓存在 JS 壳的“快照”中,以避免频繁跨越 C++/JS 边界带来的性能损耗。
  5. 可扩展性: 这个 JS 壳虽然是代理,但它也是一个标准的 JS 对象。因此,我们手动添加的自定义属性(如 e.myTag = "test")是保存在 JS 壳 自己的堆内存中的,C++ 层对这些数据一无所知。

下面开始计算传播路径

  • 浏览器使用内部算法, 从 target 开始,沿着父节点一直向上找,直到 window。这个过程会填充非常重要的 path 插槽。

  • 现在我们开始详细的介绍path内部插槽的构成和用途

    假如不包括  Shadow DOM,那么路径的计算和确定,将是非常简单的   沿着target一直向上找到window就可以了。但是正因为Shadow DOM的存在,让传播路径的计算成了一个微有难度的工作。

    简单的描述一下概念,不算严谨,但可以当作了解。

    一个dom树中, 一个元素被挂载了一个影子dom,那么 该元素被叫为 host, 然后,逻辑上看 以host为根, 有了两颗树, 一棵是刚挂载的影dom树  另一棵是host原本的子节点元素树。  而挂载的影dom树,并不是直接挂载  而是用一个root 挂在host上,root下面   才是影dom。  host原本的子元素树  叫光dom  。  

    看这部分内容的朋友,应该是对shadow dom已经有了解的, 以上简单介绍,只是为了后面方便使用影dom 光dom host root 等名词。

    在第一部分,曾为了知识的完整性,列出了内部插槽的其他部分的列表。 这里这部分作为可跳过的选看部分,将详细的介绍path内容,这部分内容我个人认为在可跳过的内容中,算是重要的,所以打算用略大的篇幅来讲,不感兴趣的朋友依旧可以跳过这部分 。

    path中 是一个结构列表,每一项都是一个结构,对应着事件传播路径中的一个元素 严谨的说 对应着一个具备事件能力 即实现了targetevent接口 的对象。通过此列表,就可以观察到  本次事件的完整传播路径。  而且 事件的传播路径  是一次性创建, 创建好以后, 不会再更改,存在于事件的整个生命周期。传播路径是固定的,但是监听的调用等等还是会动态变化,这里只是讲路径的确定, 监听和传播过程  后面部分会详细讲。

    在最新的权威文档中,path中的每一项,都有7个字段,下面逐一介绍

1。 invocation target(调用目标)

  • 类型:一个 EventTarget 对象(通常是 Node / Element / Document / Window,也可以是其它实现了 EventTarget 的对象)。
  • 描述: 这是该路径项对应的实际 DOM 目标。通常来说,就是当前的节点。

2。  invocation-target-in-shadow-tree (调用目标是否在 Shadow Tree 中)

  • 类型: Boolean
  • 描述: 一个布尔值,标记 invocation target 是否位于 Shadow DOM 树内部。
  • 作用: 用于处理 Shadow DOM 边界的事件封装(Encapsulation)。此标志影响分派算法在决定重定位(retargeting)和阶段(capturing/at-target/bubbling)时的行为,以及是否需要将目标“影子化/重定向”给 shadow host 等逻辑。规范在处理路径和设置 eventPhasecurrentTarget 时会检查此值。

3。 shadow-adjusted target (Shadow 修正目标)

  • 类型  要么是 null,要么是一个 潜在的事件目标(potential event target)

  • 描述(最关键)

    • 这是“对监听器可见的那个目标(retargeted target)”  具体说,当事件从一个 shadow tree 向外传播(或在 shadow 边界处被观察)时,浏览器会把实际原始目标根据监听器位置做重定位,重定位后的对象就称为 shadow-adjusted target

    • 在事件分派过程中:如果path中的某个项的 shadow-adjusted target 非空,规范把该 struct 视为“AT_TARGET”类型的位置(用于设置 eventPhase = AT_TARGET),并用它来决定在该位置要以什么 target 值去调用监听器。

  • 举例:若真实事件发生在 shadow 内部的某个 div,当在 shadow host(外部)上触发监听器时,shadow-adjusted target 可能是 host(或 host 的某个可见代理),而不是内部真实 div,从而实现了 Shadow DOM 的封装(retargeting)

  • 再举例:当事件从影DOM 冒泡到光DOM 时,为了保持封装性,外部不应看到内部的真实节点。这个字段决定了在当前项所处的位置上,开发者调用 event.target 时应该返回哪个节点(通常是 host,即影子的宿主,而不是影dom内部真实的节点)。

  • 再再举例  算了,不举了

4。 relatedTarget (相关目标)

  • 类型 null 或 一个 潜在事件目标

  • 描述  用于那些有“related target”语义的事件(例如 mouseover / mouseout、焦点事件中的 relatedTarget 等)来记录在该路径层次上与当前 invocation target 相关联的另一个目标(经过 retargeting 后可能是不同的对象)。

    简单来说  就是类似于 shadow-adjusted target,但是专门用于修正 event.relatedTarget

    比如在 mouseover/mouseout 事件中,如果相关元素在 Shadow DOM 内部,这个字段确保外部只能看到 Shadow Host,而不是内部细节。

  • 注意relatedTarget 的值也会受到 shadow tree 封装/重定位规则影响

5。 touch target list (触摸目标列表)

  • 类型 / 含义:一个“潜在事件目标”列表(sequence/list)List of Touch objects。主要用于触摸/多点触控相关的事件以记录与路径中该 struct 相关的所有触摸目标(比如 touchstart 的多个触点)。
  • 语义 / 用途:在分派触摸/Pointer 类型的事件时,规范需要知道该路径层上哪些具体触摸点是相关的,以便在给监听器报告事件时能确定哪些触点属于当前 currentTarget 的上下文。
  • 专门用于触摸事件(Touch Events)。当触摸点在 Shadow DOM 内部移动时,需要修正触摸点的 target 属性,以符合 Shadow DOM 的重定标(Retargeting)规则。

6。 root-of-closed-tree (是否为封闭树的根)

  • 类型 / 含义:布尔值。表示该 struct 表示的 invocation target(或其相关信息)是否处在一个 closed shadow tree 的根(即该 struct 表示的那一层涉及到一个 closed shadow root)。
  • 描述: 标记该路径项是否是一个模式为 closed 的 Shadow Root。
  • 如果为 true,则在使用 composedPath() 获取路径时,路径会在这个节点被截断,外部无法通过 API 获取到封闭 Shadow DOM 内部的节点。
  • 这个标志用于实现 closed shadow tree 的封装保护:当 root-of-closed-tree 为真时,规范在构建对外暴露的 invocation target 列表或在清理路径时会采取特殊处理(例如阻止 closed tree 内部节点出现在 composedPath() 的对外结果中,或在路径清理时决定是否插入清理 struct 等)。通俗说:它帮助浏览器决定“哪些内部节点必须对外屏蔽”。

7。  slot-in-closed-tree (是否为封闭树中的 Slot)

  • 类型 / 含义:布尔值。表示在路径构建时当前节点是不是“一个处在 closed shadow tree 中的 slot(slot-in-closed-tree)”的上下文标记。
  • 语义 / 作用:与插槽(<slot>)与被插入的 light DOM 元素相关的路径构建有关。规范在把路径 append 到 event.path 时把这个标志一并记录,以便 later 在决定 clearTargets、retargeting、以及是否把某些 struct 暴露到对外路径(或触发 activation behavior)时使用。简单说,它用于正确处理插槽 + closed shadow tree 的组合场景
  • 同样用于控制 composedPath() 的暴露范围,确保封闭树的内部结构不泄露。

    以上7个字段,是规范规定的path中的字段,属于内部使用的数据,在我们的js层,并不能直接使用。

    新的一天,有点忘记进度了,上面讲了path的7个规范定义的字段。目的是为了下面讲传播路径的计算。前面好像也讲过, 如果是没有影dom,那么从命中具体目标以后,直接网上挨个找爸爸,挨个填path中的项。很简单。 但是因为有了影dom,路径的计算有点繁琐。

    那么被插槽进影dom的光dom元素,发生的事件它的路径如何呢?

    如果composed为真,此被slotted的元素上发生事件,路径为  此光dom--影slot--影root--光host--Document

    如果composed为假,路径依旧是 此光dom--影slot--影root--光host--Document

    这是因为规范规定:

    composed:false 只是 一个必要条件,但不是充分条件;还要满足 “该 shadow root 是事件目标所在根” 这个前提,才会被拦截返回 null(从而阻止继续向上到 host)。

    如果事件目标的根不是该 shadow root(例如目标属于 light tree,其根是 document),那么该 shadow root 不会返回 null,而是返回 host —— 事件继续传播。

    也就是说,被插槽进影dom的光dom元素, 依旧归属于光dom树,在它身上发生的可冒泡事件,在影dom它的slot位置开始,经历  此元素---slot---root 达到影dom边界,此时 规范定义了判断算法,必须要满足两个条件,才会被拦截, 一 是 composed为假  二是 该发生事件的元素的根是影root, 这样才会被拦截。

    被插槽进影dom的光dom元素,归属于光dom树, 它的根为document, 而不是影root,所以不满足拦截条件。

    很多资料或者文章把composed为假的情况 绝对化,从规范上说  是不对的。

    对于影dom内部的元素发生的事件,composed为假会拦截,因为他们同时符合根为影root的条件。

    但是对于归属于外部光dom的被插槽元素来说,它的根为document,不符合条件, 所以不会拦截。

    上面第4个  relatedTarget 比较有意思,可以稍微了解一下

    • 含义:对于 mouseover/mouseoutfocusin/focusout 等“有关联目标”的事件,记录相关目标。

    这个字段,并不是所有事件都具有,一般是有节点间转移的动作的事件才有。

    假如有元素a和b,鼠标此时在a上,现在,把鼠标从a移到b,那么,对于a来说,在它身上发生了mouseout事件,鼠标离开, 创建这个事件对象的时候,path路径中,target当然是a,而relatedtarget表示关联目标,就是和target对应的一个目标,因为鼠标是移动到了b身上,所以relatedtarget就是b。

    如果我们从b的角度来看,在b的身上发生了mouseover事件,鼠标到了b身上,那么这个事件对象创建,它的path路径的target是b, 和它关联的目标 relatedtarget则是a,因为鼠标从a过来的。

    请注意,relatedtarget 也遵守 影子DOM 的重定位规则。 从影dom外面看,如果 relatedtarget 指向的是 影DOM 内部的元素,它也会被替换为 Host

    • 用处:
    1. 在调用监听器时提供上下文(比如判断鼠标是从哪里移进来的)。
    2. 作为事件触发的裁决依据:浏览器会对比重定位后的 targetrelatedtarget。如果在某一层级,两个变成了同一个对象(例如都变成了 host),浏览器会认为没有发生实质性的交互,从而阻止该事件在这一层的触发。

    上面第3个 shadow-adjusted target (Shadow 修正目标)

    shadow-adjusted target

    • 含义:对于当前项 来说的目标,请注意,这个target是path中的一个字段,要和在event事件对象的内部插槽中,还有一个静态的原始目标对象相区别  不要搞混。通俗的解释:在影dom中   path中的target始终是发起事件的那个真正目标。  而跨出影dom后   该target变为host,  以host代替影dom内真正的事件发起目标,以实现不让外人偷窥到影dom内部情况的效果。也就是说,如果不存在影dom,则该target 始终都是事件的实际发起元素。 而在影dom中的项目上,也是事件的实际发起元素,但是越过root,在host这一项上,该target变为host,并且一直保持到window项。
    请注意

    对于被插槽进影dom的光dom元素,因为它依旧归属光dom,所以在影内  影外  host上,它的该target值都为真正的光dom本身。

    • 用处:在该项上的监听器中读取 event.target时要显示的对象。

    当该项表示的事件对象为被插槽进影dom中的时候,无论在何处  其值为真正本体。

    当事件源是影dom内部元素,该项位于影dom外时,显示host,位于影dom内时 显示真正事件发起目标, 位于host时,显示host。   

    经过前面的大段铺垫,现在开始学习传播路径构建 这次是真的真的了。

    之所以执着于大段的讲path的字段和事件对象的内部插槽,主要是我个人认为,js层面的公开的api,只是对内部数据的整合和包装,只学习他们,无法真正准确的了解事件传播路径的算法和之后的事件传播及处理,以及这些内部数据和标志位之间的配合所带来的对于外部来说 比较不好理解的现象。 当然  限于能力,写的比较散乱,从开始到现在  一大半都算超纲注水,所以要赶快回归。  观看时跳过上面的这一大部分就好了。

    事件传播路径的构建,是一个算法,它是以当前DOM结构为基础的,不需要JS参与的行为。完全依靠dom结构,加上影dom的边际规则,逐步构建出来一条物理路径。所以  直到最后路径构建完成,我们都看不到监听等js的一根毛。 就类似于 传说中的低耦合,甚至是解耦,没耦。 路径的构建,基本上是浏览器引擎的活,  至于以后监听什么的,和路径没关系, 不管你听不听  路就在那里。不管你走不走,路也就在那里。 以后,js的监听和动态的设置活动,属于逻辑上的, 而现在构建出来的路径,属于物理上的。只需要注意一点   当前节点的状态,有可能是由之前节点创建时的js层面参与决定的, 但是也仅此而已,路径的创建,js一直是靠边站的。反正,就是这么个意思吧。

    其实吧 写到这里  有点沮丧。。。 也就是一个遍历算法,做了那么多的铺垫,早点直接说不就得了吗。然后我又想到前几天刷到的小片段:   入职以后前三个月每月工资2000,第四个月开始4000, 大智慧的朋友说  那你等到第四个月再去入职。

    继续码字

    这里就不得不引出两个比较重要的概念:

    • 合成/组成树  扁平树( Composed Tree   flat tree )

    虽然名字是两个 但都是指的同一个东西,组成树的意思主要突出它的来源并不单一,比如光dom 影dom slot 等,然后根据规则组成的一棵树。

    扁平树的意思是从组成以后是一个整体的角度来讲的, flat表示消除了原本的影dom和光dom的隔阂,把正确的slot的内容投影拍扁进影dom槽中,表示是一棵单一的连续的树。

    注意   归属并没有改变。

    扁平树并不是一棵完整的或者部分的真实存在的树,它是一种规范中抽象定义,在实现中,逻辑存在,在需要时,动态计算出来的一种逻辑树。

    从名字 从存在形式 都已经说了,那么 它的内容是什么呢

    扁平树是以整棵 DOM 树为基础,但在遇到宿主host时,会使用其 影dom结构来替代原本的内容,同时将光 DOM 中被选中的节点“投影”进 影DOM 的插槽中,最终形成的一棵树。

    那么这里要特别注意,在物理上,并没有什么变化,host下依旧是一棵光dom 一棵影dom,  扁平树是一种抽象的逻辑树,按照规则  把它需要的东西  提取出来。物理上 原来怎么样  现在还是怎么样。

    ---------为了说清这扁平树 我可费了老大劲,改了好多遍。

    • 渲染树

    渲染树是基于扁平树,使用css规则,生成的用于布局和绘制的树。

    好像暂时用不到渲染树,先不详细说了,后面讲到渲染再说。

    dom树  物理存储结构,只有原始的层级。

    扁平树  抽象出来的 , 打通了光dom和影dom的隔阂   有逻辑层次结构 。

    渲染树  扁平树加css规则  有视觉呈现结构。

    事件传播路径的构建算法,大约有百分之七八十的内容,都以零散的方式在前面介绍过了,还剩一个系统性的算法描述作为总结,但是有点犹豫,因为虽然算法很简单,但是牵扯到的字段和标志位比较繁琐,需要比较大的篇幅来讲述,而这部分内容  在前端开发中,百分之八九十的可能性是用不到的。但是在写组件写库写shadowdom以及排除一些bug的时候,却是神兵利器。所以打算放在第三部分事件的传播和处理部分再详细介绍。

    现在我们来思考一下,就是真的只用脑子思考,在写这整篇文章的时候  有些知识点 ,甚至在有些地方多次反复的强调它所处的阶段 所在的位置等等,就是不断的试图在读者的脑中  构建出一个完整的事件模型,说到流程,你可以想到主要步骤, 说到光dom影dom 你可以想到一棵树。

    一棵dom树,某个节点被挂上了新的树,于是  这个节点成了host,下面有了两棵树 一棵光dom 一棵影dom。 这里有个问题,就是以哪棵树为着眼点,不少朋友认为,光dom是正宗嫡系,当然要以光dom为着眼点,关注上面有没有被slot等等, 其实是不恰当的。 要以影dom为主,以影dom的角度来看, 影dom被挂上来,就是接管 代替了光dom,我就是老大 看我的。 你光dom想干点啥,必须投影到我这里,你现在就是我的备件仓库/展销厅, 我给你机会,你才能出现。

    这是一种思考模型,从物理dom树 到扁平树的转变,虽然光dom始终是host的子树,是物理存在的,但是在思考时 使用扁平树的角度, 因为扁平树和渲染树对光dom是默认忽视的。

    那么 我们再稍微延申一下, 假如光dom树  没有被slot, 然后  我在js层面手动派发事件,会出现什么情况? 我在以前刚开始学习时,曾误以为,传播路径有两条  一条物理的 一条扁平的,后来才纠正过来,始终就是一条路,依靠算法来决定怎么走。

    依旧是路径构建算法,算法规定,A node’s get the parent algorithm, given an event, returns the node’s assigned slot, if node is assigned; otherwise node’s parent.  就是没有被slot的节点找到的父亲是物理链路上的父亲。

    也就是说 事件会传播到host 然后继续传播到document。 js可以对它像对其他元素一样 进行操作。包括监听 派发  允许slot等等等。  唯一的问题  就是因为它没有进扁平树 也就进不了渲染树,在视觉上是不在的。 看不到  所以除了设置slot加入扁平树的操作以外, 其他的操作 要慎重,避免各种bug的产生。

    影子dom也挺有趣的,给事件传播路径上增添了很多色彩,影子哥和事件传播路径的构建有关的内容,好像也差不多了。如果后续其他部分里还牵扯到Shadow DOM的内容,到时候想起来再讲吧。

    这第二部分马上写完了,最后总结升华一下

    记得在第一部分,提到了观察者模式,我们再再再的把它具体到事件上来。

    事件的发生,不管是因为什么原因,它的本质,就是 期待关注 。

    我有事,我有事啊,我说我有事了,这是事件的发生,我不需要知道谁会处理它,也不知道什么时候会被处理。

    三个阶段,捕获 目标 冒泡 提供了时间上的选择权 和处理的策略  

    捕获是拦截和预处理

    目标是现场自己的处理  

    冒泡是兜底和总结。

    传播路径,提供了空间和层次上的哨位  路径上不同的哨位,有着自己的不同的职责和环境,他们看待经过自己的事件,是用自己所在的哨位职责来观察的,同一个点击事件,在buttnn上看,是用户点按钮了, 在form上看, 是用户可能在提交表单, 在document上看,是用户还活着  没噶呢。   就是说 你可以选择  在具体哪个抽象层级上来处理这个业务洛基。

    而三个阶段和传播路径的结合,就是对于事件处理的从时间到空间到层次上的结合,择优选择,比如事件委托该放在哪里? 时间上,当然是选择到最后的冒泡了,空间和层次上  当然是选择越高越好了  大内总管那岗位厉害。而每个哨位  也有自己的小权力 比如这事归我管,不往上送了,比如这事只归我管 不给周围同事了。

    最后  我们进入玄幻模式: 事件流为我们提供了时间空间层次三维立体的选择权。

三、 事件的传播和处理

简介

我们将介绍事件的传播和处理, 这部分的内容,相关的介绍 文章  帖子 可谓是汗牛充栋栋栋栋栋。。。但是 基本上都是先讲三个阶段  然后扔出几个api 然后几段示例代码  然后总结一下  完事。  相信有很多朋友  看过以后  感觉是看了一些什么,但是仔细想想   又似乎是什么都没看,毫无获得感。其实这就是因为很多文章 都是知识的罗列,就像 教你说  哎 小明 你看  你按一下这个开关  灯就亮了  再按一下开关  灯就灭了。  但是  只有罗列  没有知识之间的桥梁  没有构建出一个合适的思考模式,只是浮于表面的认知。 你按一下开关  灯没亮 哎呀  咋回事嘞? 或者按一下开关  灯没灭  或者你按一下开关 砰的一声 灯炸了  只能夸它炸的响 不知道它为什么炸 。我尽量不走寻常路,从其他的角度,我们一起来学习事件的传播和处理。风格依旧跟第一部分和第二部分一样, 没图没码 网文风格,但是对描述和表达的准确性  依旧值得信赖,我会力求表达准确 符合规范  贴合实现。

复习EventTarget

第一部分讲了事件的一些重要知识点,第二部分讲了事件的完整生命周期,并且用了大量篇幅介绍了传播路径的构建和shadow dom 以及事件对象。

这是第三部分

前面两部分是纯练内力,这部分有点内力外放的意思。现在我们一起修炼吧。

一切的源头是 eventtarget,前面已经多次提到,想具备事件能力,必须实现eventtarget接口。

eventtarget是一个接口  一种能力 一个对象。 从对象角度来说,  dom事件中的那些节点,基本上都是以原型链的形式默认继承了eventtarget这个对象。

在前面第一部分, 我们曾给出了一个js对象的新的定义:

js对象的本质 = 非原始值 + 属性记录集合 + 原型链继承 + 由内部槽/内部方法决定行为

eventtarget作为对象,那就必然可以用这个定义来解释。

一个实现了eventtarget接口的对象,具备了事件能力,那么将它用上面的定义来解释:

  • 非原始值:它当然是个对象引用。

  • 原型链继承:打开 F12,在控制台里敲入一行命令:console.dir(document.createElement('div'))  回车之后,会得到一个纯净的 div 对象。

    顺着它的 [[Prototype]](或者 __proto__)一层一层往上翻   从 HTMLDivElementHTMLElement,再到 ElementNode   再然后,就看到我们的主角了  EventTarget

  • 属性记录集合:当然可以随便添加属性。

  • 现在还剩下的就是最重要的了 ------ 由内部槽/内部方法决定行为

EventTarget 内部,有一个js层面看不到的内部槽位。 规范中它的名字叫 Event Listener List事件监听器列表)。

正是因为有了这份列表,它才从普通的 JS 对象,进化成了一个拥有事件能力的对象。

因为它是对象内部的一个属性/槽位, 可以这样表示 eventlistenerlist

从名字就可以看出,它是一份列表,由每一条列表项组成。

下面我们介绍一下每份表项的构成,你就会明白了。

事件监听列表的组成

跟全文的第一部分和第二部分一样,依旧延续哨位这个比喻。

事件监听列表,每个表项 ,由7个字段构成。

1。. type

  • 类型:字符串 (String)
  • 含义:这个表项具体负责哪块业务?是 click 组的,还是 keydown 组的?
  • 作用:这是最基础的索引。比如当类型为click时,只有 type 为 "click" 的表项会被调出。

2。. callback

  • 类型:函数 或 对象 (EventListener Object)
  • 含义:具体干活的回调函数。
  • 细节:通常我们传的是一个 JS 函数。规范里讲的,也支持传一个对象,似乎很少使用。

3。. capture

  • 类型:布尔值 (Boolean)
  • 含义这是最重要的身份标记之一。
    • true:属于捕获组。事件从 Window 往下传的时候,就要注意了。
    • false:属于冒泡组。事件从 Target 往上冒的时候,就要注意了。
  • 核心规则:它是“去重复算法”的三大要素之一。同一个函数,如果分别注册了捕获和冒泡,那是两条完全独立的表项。

4。. passive

  • 类型:布尔值 (Boolean)
  • 含义:这是一个关于性能的字段。
    • true:该表项签署承诺书,保证在执行过程中绝不调用 preventDefault()(不拦路)。
    • false:保留拦路的权力。
  • 有默认:浏览器为了保证移动端滚动的丝滑,对于 touchstartwheel 这种高频事件,会在Window、Document 等顶层对象上默认帮你勾选为 true,以便合成器线程能直接渲染滚动帧。

5。. once

  • 类型:布尔值 (Boolean)
  • 含义一次性用品。
    • true:干完这一票就走人。
  • 机制:当这个回调被执行之后,哨位会自动把这份表项从列表中物理删除。

6。. signal

  • 类型:AbortSignal 对象
  • 含义:这是 AbortController 带来的新机制。
    • 机制:你把一个遥控器(Signal)交给哨位。以后你想离职,不用专门跑一趟(调用 removeEventListener),你只需要在外面按一下遥控器(调用 abort()),哨位里对应的列表项就会自动销毁。
    • 细节:如果你递交申请的时候,手里的遥控器显示“已引爆”(aborted),是根本不会受理你的注册请求的。

7。. removed

  • 类型:布尔值 (Boolean)
  • 含义这是唯一的内部专用字段,开发者不可见。
    • 作用:解决同一事件中的并发问题。
    • 场景:当在同一个事件派发中,在当前正在工作的哨位上,如果前一个表项中的回调,把后面的表项移除了,此字段起作用。
    • 逻辑:为了不打乱正在进行的循环索引,哨位不会立刻移除表项,而是悄悄在这个字段打个勾(removed: true),类似于软删除。等轮到被打勾的表项的时候,直接跳过,既不执行也不报错。

看到这些字段,是不是感觉特别熟悉? 注意  这些字段  现在是在eventtarget中的内部槽位中,仅限内部使用, 那么,它们是怎么被外面js层改变设置的呢?

eventtarget的对外窗口

前面反复的说  eventtarget是事件机制的源头,必须实现这个接口 才能具备事件的能力。dom事件中的节点,都是默认通过原型链继承了eventtarget对象。 那么,我们在js层面,如何使用呢?

eventtarget提供了三个api给我们,这也是它的核心功能,注册  销毁 触发。

前面讲过,一个事件  之所以能成为事件, 要具备三个特质:

一是遵循观察者模式,二是携带现场数据  三是可观察的变化。

那么eventtarget提供的这三个api,之所以是核心能力,就是因为用这3个api,实现了观察者模式。

addeventlistener注册 添加观察者

removeeventlistener退订 移除观察者

dispatchevent触发 发布者发布

这三个api,加上event,构成了大部分前端开发者的事件机制的知识体系,那么  假如再加上 事件监听列表   。。。。。。你就功力大涨,凝聚金丹进阶了。

1. addEventListener

给元素添加事件,经历过三个时期:

一,HTML 属性绑定 (Inline Event Handlers)

这是最早期的 web 开发形态,直到现在,依然可以在很多老旧系统或者为了图方便的 demo 中看到它的身影。

这种方式, 虽然看起来简单,直接把代码写在标签里,但它背后发生的事情其实非常不科学。

比如 <div onclick="console.log(id)"> ,浏览器并不是直接运行这段代码。 浏览器引擎在解析 HTML 时,会把onclick属性里的字符串console.log(id)提取出来,然后动态创建一个函数。它通常使用了一个在现代 JS 中已经被强烈建议不再使用的 with 语法,强行扩展了作用域链。

浏览器生成的代码逻辑大致如下(伪代码):

  function(event) {
      with(document) {
          with(this.form) { // ...
              with(this) {
                  // 自己的代码被包裹在这里了
                  console.log(id); 
              }
          }
      }
  }

这就是为什么这种方式,可以直接在html里使用event  , id,document的console的原因。

这种方式是强耦合的典型,HTML 和 JS 逻辑死死纠缠在一起。而且,因为 with 语法的存在,变量查找路径变得极其复杂,极易引发性能问题和意想不到的 Bug。而且,这种内联脚本,经常会因为安全问题,被禁止运行。

二,DOM0 级事件处理 (DOM0 Event Handlers)

随着 JS 的地位提升,还想再提升 再提升  于是就希望能把逻辑从 HTML 中剥离出来。于是出现了 DOM0 级绑定。

  btn.onclick = function() {
      console.log('你好了吧');
  }

这种方式的本质,是对 DOM 对象上的一个属性进行赋值

(重要)这两种方式的总结

现在我们来撸一下思路,事件被包装成任务,放入宏任务队列, 然后被取出执行,精确命中,创建事件对象,构建传播路径, 此时,就进入调度阶段。  此时  我们把目光放在传播路上的某一个节点/元素/eventtarget 上面,它的内部 有一个自它出生就有的一个事件监听列表,该列表初始为空, 而此节点,作为一个对象,一个元素,它本身是有自己的属性的,比如 src属性  onclick属性,等等。。。以onclick为例,它是节点对象元素标签的一个属性,它在事件监听列表中,拥有一个单独的席位,初始为空,并不实际占有位置。 按照注册顺序来排列监听列表。 比如首先 add了几个回调, 然后又以btn.onclick=fn的方式注册了onclick, 那么 onclick是排在最后的。  又比如,首先以html的写法onclick="console.log(id)"的方式内联注册了onclick,那么在html解析时,该属性就被注册了,它就排列在事件监听列表的首位。

还有一个重要的地方,就是 onclick是作为节点的一个固有属性存在的,它的值只能有一个,多次赋值会被覆盖。

而后面将要讲的add的方式添加的,是附加的方式,可以添加多个。

最后再次总结一下:

对于 HTML 属性绑定和 DOM0 (btn.onclick) 绑定,它们在浏览器内部,其实共享同一个内部槽位: 它们会在事件监听列表中寻找(或者创建)一个带有特殊标记的表项。

  • 唯一性: 这个表项,对于同一种事件类型(比如点击),只能有一个
  • 独占性: 无论你赋值多少次 btn.onclick = fn,浏览器做的不是“添加”,而是“原地换人”。它找到那个表项,把里面的 callback 字段擦掉,填入新的函数。这就是为什么 onclick 永远只能绑定一个处理函数,因为它霸占了这个唯一的列表项。
  • 生命周期: 如果你把 btn.onclick 设为 null,浏览器就会把这个 表项从列表中物理移除

三,DOM2级事件监听addEventListener

随着 Web 应用越来越复杂,组件化开发成为主流,如果一个按钮既要发送统计数据,又要执行业务逻辑,还要触发 UI 动画,用 onclick 就会互相打架。于是,addEventListener 诞生了。

它的逻辑和以前完全不同。dom0是独占和唯一,那么 addEventListener 就是 “追加”

  btn.addEventListener('click', fn1);
  btn.addEventListener('click', fn2);

调用这个 API 时,浏览器它只会做一个动作:Append(追加)。 它创建一个新的表项,填好 typecallback,然后直接把它挂在列表的末尾

它的优势:

  1. 无限叠加:你可以添加无数个监听器,它们和平共处。当事件触发时,浏览器会按照列表中的顺序(也就是注册的顺序),依次执行它们。

  2. 精细化控制:这是 DOM0 做不到的。

    • 你可以控制是在捕获阶段触发还是冒泡阶段触发(通过 capture 选项)。

    • 你可以控制它是否只执行一次(once: true)。

    • 你可以承诺不阻止默认行为以提升滚动性能(passive: true)。

    • 你可以随时用信号终止它(signal)。

    那么,是不是真的可以无限叠加任何监听呢?并不是。

    为了防止你因为代码逻辑混乱或者一时糊涂手抖而重复注册同一个函数,浏览器在追加之前,会有一个严格的查重机制

    这道机制只认三个字段:

  3. type(事件类型)

  4. callback(回调函数引用)

  5. capture(捕获状态)

    请注意,只有这三个! passiveoncesignal 这些后来加入的参数,不参与去重判断。

    就是说  如果你先注册了一个 { passive: true } 的点击事件,然后又注册了一个一模一样的函数,但是参数变成了 { passive: false }。 浏览器会对照字段:

Type 一样吗?一样 (click)。
Callback 一样吗?一样 (同一个函数引用)。
Capture 一样吗?一样 (默认都是 false)。

结果判定为重复人员! 浏览器会直接忽略第二次的注册请求。列表里依然只有第一次的那条记录。尤其要注意capture这个字段,前两个字段一样,第三个,真和假 可以同时存在于监听列表中。

还有一点

addEventListener的第二个参数,也可以是一个对象,这个对象里面,必须实现一个handleEvent方法:

  const myObj = {
      message: 'Hello World',
      handleEvent: function(event) {
          // 这里的 this,自动指向 myObj 对象本身
          console.log(this.message); 
          console.log(event.type);
      }
  };

  // 传入的是对象,而不是函数
  btn.addEventListener('click', myObj);

这种方式一是有利封装 二是不用绑定this,三是移除方便 。但这种传对象的方式我们平时使用不多。

2. removeEventListener

有注册就有注销,addeventlistener是往事件监听列表里添加观察者,removeeventlistener 就是用来把观察者从列表中请出去的。 它的工作很简单,就是使用上面提到的那三个字段去列表里找人:

  1. type

  2. callback

  3. capture

    符合条件,就请出去了。

这也是红宝书上说的,必须符合三个条件的原因,因为添加的时候,用这三个条件判断是否是重复添加, 所以用这三个字段,可以唯一表示事件监听列表里的某一项,那么在移除时,依旧是使用这三个字段来寻找。

那么问题来了,记得不要使用箭头函数当回调。因为,回调函数,如果是匿名的,你在注册时,它是一个对象,有一个内存地址, 你在移除时,写的回调,虽然和注册时是一样的内容,但是它是另一个不同的对象,有另一个不同的内存地址, 移除时并不是比对内容,而是比对的内存地址。地址不同,当然移除不掉的。

// 注册
btn.addEventListener('click', () => { console.log('猜猜我是谁') });
// 试图移除
btn.removeEventListener('click', () => { console.log('猜猜我是谁') });

还有一点  要特别注意capture 是必须要匹配的!

在浏览器的眼中,捕获阶段的监听器冒泡阶段的监听器,是完全不同的,

// 注册了一个捕获阶段的监听器
btn.addEventListener('click', handler, { capture: true });

// 试图移除一个冒泡阶段的监听器
btn.removeEventListener('click', handler, { capture: false }); // 失败嘞

虽然函数一样,类型一样,但一个是捕获阶段,一个是非捕获阶段,浏览器认为它们不是同一个列表项。 要想移除上面那个,就必须显式地写上 { capture: true }

关于事件监听列表种的第7个字段removed

还记得我们在前面介绍监听列表的7个字段时,提到的那个 内部专用字段 removed 吗? 我们在这里略为介绍一下。

想象一下,假如有一个按钮,练功走火入魔了,居然注册了 10 个点击事件监听器。 当点击发生时,浏览器开始在一个 for 循环 中遍历这 10 个监听器,依次执行。 假设执行到第 3 个监听器时,它的代码里调用了 removeEventListener,把第 4 个监听器给删了。如果浏览器直接把第 4 个项从数组里 物理删除,数组长度这就变短了,后面的元素下标全部前移。 原来的第 5 个变成了第 4 个。 而循环的索引 i 此时加到了 4。   后果就是 ,原来的第 4 个被删了,原来的第 5 个被跳过了。

为了避免这种遍历中修改所带来的索引bug,浏览器采用了 “软删除” 策略。

当调用 removeEventListener 时:

  1. 浏览器找到了对应的表项。
  2. 不会立即把它从内存里删除。
  3. 它只是悄悄地把该表项的 removed 标志位设为 true

在事件派发的循环中: 当轮到这个表项时,浏览器会先看一眼:“哎呀 removed 是 true?”   然后  直接跳过不执行,继续下一个。

等到这一轮事件循环彻底结束,或者在未来的某个空闲时刻,浏览器才会真正地回收这些“被标记的僵尸”,释放内存。 这就是为什么说 removeEventListener 是一个逻辑上的删除,而不是物理上的立即消灭。这就是这个removed字段的用途。

那么  问题又来了, 哎呀,这么麻烦丫,删点东西 又要这 又要那的,有没有更先进的办法呢? 这就是事件监听列表中  第6个字段signal 出现的意义了。

在前面,我们特别的讲了,不要传匿名函数进去当回调,因为想移除的时候,会匹配不到。那么现在有了signal的加持,匿名函数也能支楞几下了。

const controller = new AbortController();
// 注册时,把销毁信号传进去
btn.addEventListener('click', () => { console.log('你们逮不到我'); }, { signal: controller.signal });

// 想移除时,不需要知道函数是谁,直接按下引爆器---砰
controller.abort(); 

AbortController 是一个构造函数, 使用new AbortController() 实例化出一个控制器对象。

这个对象很简单,包含一个signal属性,一个abort方法

这个对象是宿主环境提供的

AbortController 的出现,就是为了提供一种通用的取消机制。

使用 removeEventListener 时,必须使用回调函数的引用。但是用 AbortController,不需要管回调函数是谁,只需要控制那个信号。

而且,可以一对多的控制,可以把同一个 signal 传给 10 个不同的 addEventListener,甚至传给几个 fetch 请求。当调用一次 controller.abort() 时,这 10 个事件监听器和那几个网络请求,会同时停止。一键清理,厉害大了。

3. dispatchevent

dispatchevent的执行,和内部的派发过程是一样的,可以认为,它是内部的派发算法给js层面提供的一个接口。具体的执行,在后面会有超大的篇幅来讲

在这部分 我们主要讲一下自定义event

在前面的第一部分讲解event的时候,我们说  自己创建event对象,需要使用对应的构造函数,因为内部槽位有通用的 也有专用的。

new Event()

const evt = new Event('boom');

这种,就纯粹是个消息通知,听个响而已,派发它,只能用于通知,看到通知,就回调。

new CustomEvent()

DOM 规范专门提供了:CustomEvent。 它是我们日常开发中最常用的方式。

  const payload = {
      username: '阿祖',
      action: '收手吧 外面全是成龙'
  };

  // 第二个参数是配置对象
  const evt = new CustomEvent('police-arrive', { 
      detail: payload 
  });

  document.addEventListener('police-arrive', (e) => {
      console.log(e.detail.username); // 阿祖
  });

detail里面可以放任意类型的内容,使用非常方便。

使用 EventInit  可配置对象

对于上面这两种 event和customevent,还可以使用配置对象对他们进行配置。

实际上,这种配置,是对于event内部插槽的修改,对于这两种属于基类的,只能配置

三个功能:  是否可冒泡bubbles   是否可取消cancelable   是否可跨影dom边界composed,他们初始默认都为假。

对于一般使用,以customevent加detail加三个配置项 居多。

继承 Event 类

使用 class myEvent extends Event {}

这种深度定制,可定制事件类型 可定制高内聚的逻辑。

但是写起来比较麻烦。

可能有新手朋友会有疑问  我new event 然后自己添加,和我使用extends event继承,有什么区别吗? 不都是要自己添加吗?  对于特别简单的,当然可以new以后添加,但是稍微复杂点的,尽量使用继承,new加上添加,会有不可预知的安全问题,强类型,封装性  ,安全性,可固化配置。。这些优势,足够驱使选择继承的方式了吧。

那么 我想精确的造一个点击事件怎么办

这就需要拥有特定专用内部槽位的子类出场了,点击事件是

MouseEvent

  const perfectClick = new MouseEvent('click', {
  //下面的配置项目,就相当于修改event对象中的内部槽位
  //每种子类,拥有通用内部插槽, 也必须有自己的专用内部槽

      // 1. 基础配置 通用槽(继承自 EventInit)
      bubbles: true,       // 必须为 true,否则父元素收不到冒泡
      cancelable: true,    // 必须为 true,否则无法 preventDefault
      composed: true,      // 穿透 Shadow DOM

      // 2. 视觉上下文(继承自 UIEventInit)
      view: window,        // 绑定当前窗口

      // 3. 物理信息 这是鼠标事件的专用内部槽(MouseEventInit 特有)
      clientX: 100,        // 鼠标相对于视口的水平坐标
      clientY: 200,        // 鼠标相对于视口的垂直坐标
      screenX: 100,        // 相对于屏幕的坐标
      screenY: 200,

      // 4. 按键详情 依旧是鼠标事件专用内部槽
      button: 0,           // 0: 左键, 1: 中键, 2: 右键
      buttons: 1,          // 当前按下的键的位掩码 (1 代表左键被按下)

      // 5. 修饰键 配合键盘使用
      ctrlKey: false,
      altKey: false,
      shiftKey: true,      // 假装用户同时按住了 Shift
      metaKey: false,      

      // 6. 关联目标   这个内部槽位的详细说明  请参见本文的第一部分
      relatedTarget: null  // mouseover/out 时有用
  });

  // 开车喽~~~
  btn.dispatchEvent(perfectClick);

这部分内容,是event的创建, 因为dispatchevent派发 就必须讲到这部分。所以就放在这里了。

关于dispatchevent,下面专门详细的介绍。

事件的派发和处理

梳理线索  整理思路

现在,我们来快速梳理一下我们已经学过并掌握的知识脉络

第1,事件对象

 事件的三个特质 ,1是遵循观察者模式,这样才能发布-订阅-移除-处理 ,2是携带事件的现场数据,这就是event对象,事件的传播以它为主, 3是可观测的发生活改变,这个就不用说了。

 事件对象event的创建是在什么时候?回忆一下第二部分的流程,以点击事件为例,物理信号-操作系统路由-进程间通信给到渲染器-合成线程接收进行预先独立合成-合成器进行一次大致的命中测试-事件路由决策-被封装成任务进入宏任务队列-取出开始执行-深度命中测试找出目标-创建js层event-构建事件传播路径

 (实现上以v8/blink为例)

 通常 在创建js层事件对象 构建事件传播路径  甚至包括调度部分  明显的界限不好区分,因为有浏览器的实现差别和优化策略的不同,但是并不影响我们理解。

event是贯穿全程的唯一信物。它是一个底层  C++ 对象,内部包含了大量的内部插槽,JS 层的 event 对象只是它的一个浅层包装壳/代理。

身份信息

  • [[type]]:事件类型(如 "click", "mousedown")。

  • [[isTrusted]]true(浏览器生成)或 false(用户脚本生成)。

  • [[timeStamp]]:高精度时间戳(事件创建那一刻的时间)。

  • [[target]]原始目标。即精确的命中测试(Hit Test)找到的最精确的 DOM 节点。注意:这个值永远不变,但在传播过程中对外暴露的 event.target 属性会骗人---因为有可能存在影dom的情况。

  • [[relatedTarget]]:(仅限 mouseover/out 等具有关联对应节点的情况)相关的那个节点(原始值)。

    静态配置

  • [[bubbles]]:布尔值。决定是否允许进入冒泡。

  • [[cancelable]]:布尔值。决定 preventDefault() 是否生效。

  • [[composed]]:布尔值。决定事件是否能穿透 Shadow DOM 边界传播。

    动态控制标志位  初始状态均为关闭,随 JS 代码执行动态变化。

  • [[stop propagation flag]]封路标记。若为 true,当前节点执行完后,停止传播。

  • [[stop immediate propagation flag]]熄火标记。若为 true,当前节点剩余监听器不执行,且停止传播。

  • [[canceled flag]]撤销标记。若为 true(即调用了 preventDefault),后续将阻止默认行为或触发 UI 回滚。

  • [[in passive listener flag]]静默标记。标识当前是否处于 passive 监听器中(此时忽略 preventDefault)。

  • [[dispatch flag]]运行标记。标识该事件是否正在派发中(防止同一个 Event 对象被重复 dispatch)。

    极其重要的内部槽位

  • [[Path]]传播路径列表

    传播路径列表存储在 Event 对象的 Path 插槽里。 它是静态的。一旦派发开始前计算完成,它就锁死了。即使你在某个回调里把父元素删了,事件传播依旧会沿着已经计算好并岁锁死的路径传播。

    列表中的每一项  不是简单的 DOM 节点,而是一个结构体,包含以下7个字段:

    1. item (当前哨位)

      具体的 DOM 对象(Window, Document, Element, ShadowRoot 等)。 这是 currentTarget 在当前的真实指向。

    2. target (Shadow 修正目标)

      关键数据。这是算法预先计算好的、在当前的哨位应该对外暴露的 event.target

      逻辑:如果当前哨位是 Shadow Host,这里就是 Host;如果是在 Shadow DOM 内部,这里就是真实的内部节点。(为了封装性而撒的谎)。

    3. relatedTarget (Shadow 修正关联目标)

      同上。预先计算好的、对外显示的 event.relatedTarget

    4. touch target list: (仅限 Touch 事件)

      经过 Shadow DOM 边界修正后的触点列表。

    5. root-of-closed-tree

      布尔值。标记该路径项是否是一个 closed 模式的 Shadow Root。用于隐私保护。

    6. slot-in-closed-tree

      布尔值。用于处理复杂的 Slot 分发场景。

    7. invocation-target-in-shadow-tree

      布尔值。标记当前哨位是否位于 Shadow DOM 树内部。

    第2,节点上的监听列表 (The Listener Lists)

    虽然它们是即时读取的,但它们客观存在于每一个 DOM 节点上。

  • 持有者:每一个实现了EventTarget 接口的dom对象。

  • 数据结构:事件监听器列表。

  • 每个列表项包含字段

    • type (事件类型)
    • callback (函数或对象)
    • capture (捕获标记)
    • passive (性能标记)
    • once (一次性标记)
    • signal (引爆销毁信号)
    • removed (软删除标记 - 初始为 false)
  1. 派发与回调调用

    经过上面的快速梳理 ,我们已经知道,有三样最重要的东西    事件对象  传播路径表   传播路上的每个节点的监听列表。

    现在我们开始发车吧,开启一段有趣的旅程。

    嘀嘀嘀 喇叭响了,浏览器引擎启动了主循环,这辆车,要跑两个半程。

    1 capture 去程, 从window向下,达到事件目标核心target

    2 bubble 回程, 从目标核心 target浮起,一路冒泡到window。

    现在我们把车子放慢 再放慢, 停在某一站

    第一步 伪装与身份切换 retargeting

    车门还没开,浏览器引擎先搞搞伪装,它必须修改event中的数据,以便符合自己在此站点/节点的身份,也为了欺骗此地哨位。

    • 锁定现场 (currentTarget):

      浏览器引擎将 event.currentTarget 指针,锁向当前这一站的 DOM 节点。确定当事人。

    • 撒一个完美的谎 (target 重定向):

      这里涉及到 Shadow DOM 的机密。引擎迅速读取event对象中的path内部槽位中的当前结构中的 shadow-adjusted target内容,覆盖了 event.target。

      从之前的学习中,我们知道这个值是根据影dom修正过的值,此时直接覆盖。

      shadow-adjusted target的值  针对当前的节点  始终都是正确的,这个覆盖的步骤,是必做的一步。也是每经过一个哨位,都必做的一步。

    第二步 精确的时间段控制

    • 捕获阶段 1    车还在去程的路上,离终点还远呢

    • 冒泡阶段 3    车已经返程,快完事了

    • 目标阶段 2  这是最忙碌的换向站点。

      实际上  车会两次经过这里, 捕获阶段到达,引擎让捕获组的来,即找出 capture: true

    ​                                                    冒泡阶段到达,引擎让冒泡组的来,即找出 capture: false

    ​       尤其是在目标阶段 ,目标元素上既会执行 capture:true 的监听器,也会执行 capture:false 的监听器;根据最新的规范:通常 capture 监听器先执行,然后再执行非捕获监听器(除非 stopImmediatePropagation() 等标志打断)。

    第三步 提取与快照

    此时,引擎敲开当前哨位的门,索要该节点的事件监听列表。

    • 哨位给出原始事件监听列表
    • 引擎拍个照片,形成快照,依据快照进行后续操作。

    那么 假如某个回调使用add添加了几个监听,新加的几个  会正常附加在原始事件监听列表尾部,

    但是因为引擎是根据 快照  来执行,所以本轮派发没有新添加的份。

    假如 某个回调  把它后面的回调移除了,原始事件监听列表中的回调,就真的被移除了,同时移除操作还会将该被移除的回调的removed字段设置为true。看到这里 你可能有疑问,不是被移除了  怎么还能设置它的字段? 实际上, 不管是原始列表  还是快照, 都是使用的指针, 指向的真正的本体。原始列表中  该字段被标记为软删除,操作的是本体上的该字段,然后移除原始列表中的指针, 本体仍然健在,因为还有快照中的引用在指向它,不能销毁。

    另外 快照是按照事件类型匹配后的完整监听器列表,并不是完整的原始事件监听列表。

    规范中规定,先取得完整的事件监听列表的快照,然后进行包括type在内的各项比对,

    但是在浏览器的实际实现中,已经预先使用了按照事件类型分组 或者其他便捷的组织方式,

    所以得到的快照,直接便是按照事件类型匹配好了的列表。

    其它条件capture/bubble、once、removed、abort、passive 都在执行阶段对快照 中的每一项逐条检查。

    第四步 内部循环

    现在  浏览器引擎拿着快照,开始点名核对

    • 指纹核对

      type核对(一般在取得快照时,得到的是已经匹配过当前事件类型的列表了)

      phase  阶段核对,浏览器引擎 根据自己的一套规则,确定当前的所处阶段,以此来过滤回调。

    • 状态检查

      removed?  引擎发现这个名字上有removed标记,直接跳过。

      aborted?   引擎看了眼abortsignal,标志为真?直接跳过。

      关于这个信号,再详细介绍一下,依旧是  监听项本体在堆内存中,Signal 对象 (Controller)也在堆内存里,监听项本体保存对signal的引用。当有js代码调用 controller.abort()时,JS 引擎找到内存里的 Signal 对象,把它的 aborted 字段从 false 改为 true。另外  在abort()被调用的时候,原始事件监听列表中的该项,也即时被删除  如果还在派发中,则快照上依然保留该项,以防索引bug,但是被标记为软删除 。 实际上,在signal对象内部,也被浏览器注册了一个回调函数,用于主动清理工作,这个内容太超纲了  略过。

      当引擎按快照里的顺序,开始检查核对该项时,检查到aborted字段,由快照指针 找到监听项本体,顺着其持有的signal对象的指针,找到signal对象,发现状态为aborted: true ,则直接跳过。

    • once 机制

      浏览器引擎看到once为真的标记,立即把该项从原始事件监听列表中移除。

      现在只在快照里了,只能执行这一次。

    • passive机制

      看到 passive: true,浏览器引擎给 Event 对象打了个钢印:“忽略反对意见”。

      此时你在回调里无论怎么 preventDefault(),都是没用的,浏览器甚至还会在控制台贴一张警告条:“别喊了,你就算喊  破喉咙,也没用的。”

    • 执行回调与异常抵抗

      终于,js引擎出场,调用回调函数,开始执行。

      突然,异常出现,某个回调函数崩了,抛出error,

      浏览器进行记录,显示在控制台上,

      然后开始快照里的下一条监听项的核查比对。

    • 检查与制动

      每一个回调执行完,浏览器引擎都会检查event事件对象中的各种标志位,js代码刚才有没有搞小动作?

      检查 [[stop immediate propagation flag]]

      如果为真,直接散伙,循环中断,转去判断是否执行默认。

      检查 [[stop propagation flag]]

      如果为真,干完这票就收工。快照上的监听项依次干完, 然后转去判断是否执行默认。

    • 默认行为的处理

      无论是顺利跑完了全程,还是半路被停止或者是干脆原地散伙,JS 的逻辑阶段都宣告结束。

      此时,浏览器引擎会做最后的清算(注意:停止传播不等于取消默认行为):

      1. 返回值生成dispatchEvent 会返回一个布尔值。
      • 当且仅当事件可取消(cancelable: true) 且至少有一个监听器调用了 preventDefault() 时 返回 false
      • 否则   返回 true
      1. 默认行为
      • 引擎只看 [[canceled flag]]

      • 哪怕传播在第一站就停止了,只要没人反对(调用 preventDefault),浏览器依然会执行默认行为(如跳转链接、提交表单)。

    此时,同步的 dispatchEvent 调用栈清空并返回。

    微任务开始了。

一些重要知识点详解

  1. 在某个节点,浏览器是如何知道当前所处的阶段?

    当事件传播来到某个  哨位/节点/标签/实现了eventtarget接口的对象/dom元素  , 当前哨位里,是有原始的事件监听器列表,并没有当前事件动态走向的所处阶段,那么浏览器是怎么得到这个阶段呢?

    很多朋友会说:引擎当然知道  它就是boss 啥都知道,咱只要知道它知道就行了。

    话是不错,可但是,我们还是有必要了解一下的。

    在事件传播时随身携带的event对象中,内部插槽path存着计算好的路径,每条路径,都是一个列表,里面有7个字段。

    引擎使用这种存储方式    Path[0] = target   Path[last] = window     来存储需要走的半程

    • 捕获循环
      • 引擎设置 iPath.length - 1 (Window) 开始,递减到 1 (Target 的父亲)。
      • 只要循环在这个范围内,引擎就强行把 eventPhase 设为 CAPTURING-PHASE (1)
      • 只要没走到索引 0,且我在倒着走,那我就是在捕获阶段
    • 目标循环 :
      • 引擎设置 i = 0
      • 只要 i 是 0,引擎就强行把 eventPhase 设为 AT_TARGET (2)
      • 我踩在终点上了。
    • 冒泡循环
      • 引擎设置 i1 (Target 的父亲) 开始,递增到 Path.length - 1 (Window)。
      • 只要循环在这个范围内,引擎就强行把 eventPhase 设为 BUBBLING_PHASE (3)
      • 我已经离开索引 0 了,且我在正着走,那我就是在冒泡。

    so  并不是 phase 决定了怎么走,而是 “怎么走”决定了 phase。

    引擎使用Path,通过控制遍历的起点、终点和方向,从而精准地定义了当前的“时空状态”,这就是它为什么在某一节点,能用自己知道的 所处阶段,去和节点内部的原始事件监听列表的快照进行对比核查的原因。

    即使在Shadow DOM存在的情况下,path依然正确有效。

    比如需要确定target时

    event.target = Path[i].shadow_adjusted_target

    • 如果 i 在影内,修正目标字段里存的就是内部节点。
    • 如果 i 在影外,修正目标字段里存的就是 Host。

    总结就是

    浏览器引擎确定状态的方式,不是“动态感知”,而是“读取预设”

    • 所处阶段:由循环索引决定。
    • 当前哨位所能看到的目标:由 Path 里的预存字段决定。
    • 当前节点:由 Path 里的 item 字段决定。

    这就是为什么派发算法如此高效——因为它不需要思考,只需要查表

  2. 在某哨位 对比核查事件监听器列表时,是全部核查完毕,然后依次执行,还是核查出来一个,就执行一个?

    这是严格按照,揪出来一个 就执行一个的方式。

    这里有一个极易产生的误解。很多朋友认为浏览器是先把快照里的所有人都撸了一遍,挑出合格的,组成一个新的待执行队列,然后一口气执行完。这是错的。

    浏览器的执行逻辑,是严格的 “揪出来一个,处理一个”串行模式。

    for 循环的每一次迭代中,引擎做的事情是完整的闭环:

    1. 点名:根据索引 i,从快照里指向第 i 个监听器。
    2. 立即核查
      • “ 你现在被 removed 了吗?” (检查 removed 标记)
      • “ 你的 signal 炸了吗?” (检查 aborted 状态)
      • “ 你是这个阶段的吗?” (检查 capture/phase)
    3. 立即执行
      • 如果核查通过,立刻、马上、同步调用你的回调函数。
      • 之所以说是 串行,是因为 回调函数的执行,是控制权的移交,必须由js引擎来干活了。浏览器引擎先去抽根烟了。
      • 注意:此时,第 i+1 个监听器还在队列里等着,所有人都不知道它合不合格。
    4. 后果
      • 正因为是“执行完一个”才去“找下一个”,所以当前这个回调函数里的操作,能直接决定后续监听器的命运。
      • 比如你在第 i 个回调里调用了 stopImmediatePropagation(),引擎在准备进入 i+1 循环之前一检查:“欸,熄火标记亮了?”   duang的一声,循环直接 break,第 i+1 个监听器连核查的机会都没有,大家直接散伙。

    总结就是: 浏览器不是“批处理”,而是严格的“单步迭代”。 快照保证了“人员名单”不许变(后面新来的进不来),但“生存状态”是每一次迭代时实时核查的。

  3. 在某个节点上,是 1 对 1 还是 1 对 N?

    假如在某个子元素(比如按钮 B)上发生了一个点击事件。事件一路火花带闪电,来到了顶层节点(比如容器 S)。 此时,容器 S 上注册了好几个 click 类型的监听器:有的负责挖坑,有的负责埋雷,有的负责点火,但他们都属于click类型。 那么问题来了:当事件传播到 S 时,是“精准命中”某一个回调执行?还是所有相关的回调都会被执行?

    很多朋友会脱口而出,当然是 1 对 1:“我明明是点的按钮 B,浏览器应该很聪明,只执行那个我当初注册的那个处理 B 的回调吧?”

    正确的答案是 浏览器引擎执行的是 1对 N

    还不是很明白的朋友,可以先看一下前面的  派发与回调调用  这一部分内容。

    当事件传播的车开到顶层节点 S 时,浏览器引擎拿出 S 的监听器列表(快照),开始选人干活。 它的筛选标准非常简单粗暴:

    1. Type 对吗? (事件是 click,你监听的也是 click 吗?对。)
    2. Phase 对吗? (我是冒泡过来的,你是监听冒泡的吗?对。)
    3. Flag 正常吗? (没被 remove 吧?signal 没炸吧?正常。)

    只要这三条符合,不管你回调函数里写了什么,统统揪出来干活

    它的策略就是:全部唤醒,依次执行

    那么  怎么办呢? 当然是在回调函数里判断了,除了有些业务逻辑需要来着不拒,比如访客点击,每个点击都要记录,不需要加判断,除此以外,第一行代码都是身份判断  因为如果不判断,作为回调函数来讲,不管谁的点击事件来了, 它都得执行一遍。

    而作为事件本身来说,它只希望自己期望的回调被执行,其他的回调必须拒绝它。

    对于基于事件委托的业务逻辑来说,第一行代码永远都是身份判断,

    所以,回调函数里的身份判断,万万少不得。

    这里我们再引入一个狠角色 stopImmediatePropagation

    一个点击事件,可能会有几个点击事件监听项在等着,当某个监听项调用了stopImmediatePropagation, 好了  都别等了  立刻散伙收工。那么问题又来了,假如有好几个监听项在排队, 我不能精确的保证 该在何处调用这个api?这又是一个问题,所以  要保证你所期待的那个监听项是排在第一 或者是你可以明确的知道  应该在哪里调用

    比如 两个点击事件项 A是校验 B是提交   你校验不过,可以直接祭出大杀器stopImmediatePropagation,立即阻止了B的排队执行。

    其实这个函数通常在第三方库里使用,因为那些库的初始化  都是先于用户代码,所以库在初始化时会抢先注册监听,通过在适当的时候 使用stopImmediatePropagation来一票否决,实现自己的判断 校验 安全拦截等类似功能。

这是全篇文章的第三部分,这部分内容,我觉得还是比较容易理解的,尤其是前半部分,一般新手朋友,读两三遍,应该能收获不少。事件监听器列表,只要花几个小时,了解一下这个表,对于实际开发中的不少问题,就能心中有数,不知为什么 基本上没有人讲解。

第四篇是事件的循环和异步, 我们下一篇再见。

参考列表:

  • developer.mozilla.org

  • dom.spec.whatwg.org

  • html.spec.whatwg.org

  • tc39.es

  • developer.chrome.com

  • w3.org

四、 事件的循环和异步

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

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

一 事件的综述

二 事件的完整生命周期

三 事件的传播和处理

四 事件的循环和异步

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


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


[TOC]

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. 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 变成多线程语言,也没有真的让函数“睡死在那里”,而是把原本一口气跑到底的过程,切成了前半段和后半段两次完成。前半段先让出控制权,后半段通过微任务机制重新接回。整个过程非常像协程:有挂起点,有恢复点,有连续的叙事外观,但底层仍然是单线程调度。

看这段代码:

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 并挂起时,它真正做的是把主线程的控制权交还回去

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

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

这里的 console.log('全局同步结束') 会先打印出来,因为 upload()await 处已经把控制权交还给了外层。
upload 函数本身在逻辑上还活着,只是它当前那一半被挂起了,并没有继续占用主线程。

从这个角度看,async 函数更像一个“会分两次上场”的执行体:
前半场先跑,后半场等 Promise 结算后再回来继续。


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

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

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

这一步可以理解成:
前半段已经把“后续执行现场”保存好了,现在只是等一个规范允许的时机,把它重新接回来。


  1. 恢复时从断点处继续

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

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 黑洞

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

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 循环:

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

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

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

const results = await Promise.all(userIds.map(fetchUser));

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

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

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

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

const results = userIds.map(async (id) => {
    return await fetchUser(id);
});

console.log(results);

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

原因很简单:map 只是做映射,不负责等待。
而你传进去的回调又是 async,所以它的返回值天然就是 Promise。
于是 map(async ...) 的结果,不是“已经拿到的数据”,而是“还在路上的承诺”。

正确的方式

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 不要把它当成万能答案。

const results = await Promise.all([
    fetchUser(1),
    fetchUser(2),
    fetchUser(3)
]);

这段代码的特点是:并发发起,统一等待
它很快,因为所有请求几乎是同时出去的,但是,只要其中任意一个 Promise rejected,整个 Promise.all 就会直接失败。

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

更适合容错批处理的方案

const results = await Promise.allSettled([
    fetchUser(1),
    fetchUser(2),
    fetchUser(3)
]);

Promise.allSettled 会等所有 Promise 都结束,然后返回每一项的最终状态。
这在批量请求、批量上报、批量任务收集里特别实用。

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

四、try/catch 的使用限制

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

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呢?

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

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,就自动跟着停下来。

例如:

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 放在循环里,既可能是合理的,也可能是错误的,关键看你到底要什么。

串行场景很合理

for (const id of userIds) {
    await fetchUser(id);
}

这里的意思很明确:
前一个完成,再做下一个。如果本来就需要这种顺序,那它完全正确。

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

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

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

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

const results = await Promise.all(userIds.map(fetchUser));

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

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


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

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

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 的结果展开:

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

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

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

这也是为什么 await 往往会让人感觉错误“突然出现在某一行”。
它不是制造了错误,而是把原本藏在 Promise 里的失败,搬到了你眼前。


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

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

串行

const a = await taskA();
const b = await taskB();

这种写法的含义是:
taskA 完成后,再做 taskB

并发

const [a, b] = await Promise.all([taskA(), taskB()]);

这种写法的含义是:
两个任务一起发起,最后一起等结果。

很多异步问题,表面上看像语法错,实际上都是时序理解错
你以为自己在并发,结果写成了串行;你以为自己在串行,结果写成了并发。
async/await 只是把这件事写得更像线性代码,但它并不会替你决定并发还是串行。


十、不要让 Promise 悬空

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

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

这里 doSomethingAsync() 返回了一个 Promise,但你既没有 await,也没有 .catch(),也没有把它交给统一收口逻辑。
结果就是:这个 Promise 可能被“扔在半空中”,后面的错误没人接,后续的逻辑也可能以为它已经完成了。

这类问题的本质是:
Promise 被创建了,但没有被认真接住。

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

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

千万不要“无意中忽略”。


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

很多人对 await 会有一种心理负担,觉得它是不是“很慢”。
其实真正拖慢代码的,通常不是 async 这个语法本身,而是你把本来可以并行的事情写成了串行,或者把不必要的等待叠加在了一起。

例如:

const a = await taskA();
const b = await taskB();

如果 taskAtaskB 根本没有依赖关系,这样写就把它们强行串起来了。
更合适的方式往往是:

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 调用栈清空(即当前宏任务的同步代码执行完毕)后,且在下一个宏任务执行前,被集中清空。

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

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


参考列表:

  • developer.mozilla.org

  • dom.spec.whatwg.org

  • html.spec.whatwg.org

  • tc39.es

  • developer.chrome.com

  • w3.org


免费评分

参与人数 3威望 +1 吾爱币 +22 热心值 +3 收起 理由
涛之雨 + 1 + 20 + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
k1n0cv6 + 1 + 1 谢谢@Thanks!
junjia215 + 1 + 1 谢谢@Thanks!

查看全部评分

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

沙发
yuexingz 发表于 2026-6-8 10:42
现在还有对web站的逆向工程吗。。。
3#
nauyisu022 发表于 2026-6-8 14:07
4#
fzlte0 发表于 2026-6-8 22:17
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-6-9 06:54

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表