理解事件循环 副本

查看飞书原文 ↗4,709 字符
谈到 JavaScript 的事件循环,我们常常会想到一个经典的抽象模型:一个执行栈、一个任务队列、一个不断从队列里取任务并执行的循环。这个模型对于理解异步行为至关重要,但它也常常让我们止步于此,忽略了一个更深层的问题:这个循环究竟运行在浏览器的哪一部分?它和渲染、用户交互、网络请求之间,又是如何协同工作的?​
​
要真正掌握事件循环,我们需要跳出纯粹的 JS 语境,站在浏览器整体架构的视角,看看它真正的调度机制。​
​
接下来,我们将从五个层面,层层递进,理解事件循环​
进程与线程的定义 ​
在展开事件循环的位置之前,我们需要先澄清两个基础概念:进程(Process) 和 线程(Thread),这样后面讲浏览器架构时才不会混淆。​
进程是资源分配的最小单位。​
进程是操作系统为运行中的程序分配的一整套独立资源环境。它拥有独立的内存地址空间和必要的系统资源,作为程序执行的基本单位,用来保证不同程序之间彼此隔离、互不影响。​
线程是 CPU 调度的最小单位。​
一个进程内部可以包含多条线程,它们共享进程的内存空间。线程更轻量,负责执行具体的任务。​
在浏览器里:​
•
打开一个新的标签页,会创建一个新的渲染进程;​
•
而渲染进程内部,会包含多条线程:主线程、合成线程、光栅线程等​
​
用浏览器的架构理解事件循环​
​
要理解事件循环,我们首先要理解现代浏览器(如 Chrome)的多进程架构。一个浏览器实例通常包含多个进程,各自承担不同职责, 我们主要关系两个进程:​
•
浏览器进程:这是浏览器的主干,负责管理窗口、标签页、地址栏、网络请求和进程间协调。​
•
渲染进程:每个标签页通常拥有一个独立的渲染进程​
在每个渲染进程中,都有一条至关重要的主线程。这条主线程执行绝大部分工作,包括:​
•
JavaScript 代码的执行(由 V8 引擎负责)​
•
DOM 树的构建与操作​
•
CSS 样式的计算、布局(Layout)及部分绘制(Paint)​
•
以及我们今天的主角: 事件循环的调度逻辑​
​
除此之外,渲染进程内部还包含若干 独立线程,它们与主线程协同工作,以提升整体渲染性能:​
•
合成器线程:负责图层的合成与位置计算,处理如滚动、transform 等无需重新布局的快速操作。在主线程忙于执行 JS 时,它仍能保持页面的部分交互流畅。​
•
光栅化线程:负责将图层内容进行光栅化,把矢量信息转换成位图纹理,为最终绘制做准备。​
​
同时,浏览器中还存在一个与渲染进程密切协作的 GPU 进程:​
•
GPU 进程负责处理最终的图层合成与硬件加速绘制,将光栅化后的内容提交给 GPU 渲染。​
•
对于动画、3D、CSS transform 等需要大量计算和图形处理的场景,GPU 能显著减轻主线程和 CPU 的负担。​
•
独立的 GPU 进程还能提升浏览器稳定性,即使 GPU 驱动发生异常,也不会导致整个浏览器崩溃。​
​
主线程、独立线程与 GPU 进程共同构成了现代浏览器高性能渲染流水线,使页面在结构计算、绘制和合成之间实现真正的并行协作。​
​
因此,我们可以得出一个关键的结论:​
事件循环是渲染进程的主线程上的一种调度算法。 它的核心职责,是决定在何时执行哪段 JS 代码、何时处理微任务、以及何时将控制权交给渲染引擎,从而驱动整个页面的生命周期。​
​
​
画板
​
​
一帧之内,事件循环做了什么?​
为了实现流畅的视觉体验,浏览器致力于达到约 60 FPS 的刷新率,这意味着每一帧的渲染周期大约为 16.6 毫秒。在这短暂的时间里,事件循环处理了一系列复杂的任务。​
我们可以将一帧内主线程的工作流程,简化为以下几个步骤:​
1.
取出一个宏任务​
事件循环会从宏任务队列中,选取最老的一个任务来执行。这些任务的来源多种多样,例如:​
◦
页面加载时执行的全局 <script> 代码。​
◦
setTimeout 或 setInterval 到期的回调。​
◦
用户交互事件(如 click, scroll)的回调。​
◦
网络请求完成后的回调。​
​
2.
执行宏任务中的 JS 代码​
JS 引擎(V8)开始执行这个任务。在执行过程中,可能会产生新的异步任务,例如:​
◦
通过 Promise.then() 或 queueMicrotask() 创建新的微任务。​
◦
通过 setTimeout 安排未来的宏任务。​
◦
修改 DOM 结构或样式。​
​
3.
清空微任务队列​
当上一步的宏任务执行完毕后,事件循环会立即处理所有已排队的微任务。这是一个“清空”操作,意味着如果微任务在执行过程中又添加了新的微任务,也会把当下这批微任务一直处理到完全没有为止,包括执行过程中新增的微任务。​
4.
(可选)执行渲染更新​
在处理完微任务后,浏览器会判断当前是否需要进行页面渲染。这个判断基于多种因素,如屏幕刷新率、页面性能等。如果确定需要渲染,则会依次执行:​
◦
调用所有 requestAnimationFrame (rAF) 的回调,让动画渲染更流畅。​
let box = document.querySelector('.box');
let x = 0;

function move() {
  x += 2;
  box.style.transform = `translateX(${x}px)`;
  requestAnimationFrame(move); // 下一帧继续执行
}

requestAnimationFrame(move);
◦
重新计算样式、执行布局、绘制,并生成绘制指令,最后将这些信息交给合成器线程。​
​
5.
(可选)执行 requestIdleCallback​
​
如果在一帧的剩余时间内主线程处于空闲状态,浏览器可能会调用 requestIdleCallback 的回调,执行一些非关键的后台任务。​
​
6.
循环往复​
至此,一轮循环结束。事件循环会再次检查宏任务队列,准备开始下一轮的调度。​
​
我们可以将每一帧的主线程逻辑,概括为这样一个节奏:​
​
​
取一个宏任务 → 执行 → 清空所有微任务 →(可能)执行 rAF 与渲染 →(可能)执行 Idle 回调 → 进入下一轮​
​
​
Web API 如何与事件循环协同?​
​
我们常用的 setTimeout、fetch、DOM 事件监听等 Web API,本质上是浏览器提供的“能力接口”,它们并不运行在 JS 引擎内部,而是由浏览器架构中的不同进程与线程负责:​
•
定时器(setTimeout、setInterval):由渲染进程内的 定时器线程 负责计时,到点后将回调封装成宏任务加入主线程的任务队列。​
•
网络请求(fetch、XHR):由 浏览器进程的网络线程 负责实际的网络 I/O;当收到响应时再把任务派发给渲染进程主线程。​
•
DOM 事件(click、scroll):事件首先由操作系统传给 浏览器进程,再根据页面路由至对应的 渲染进程;若绑定了 JS 监听器,会在渲染进程的主线程中创建一个事件宏任务。​
事件循环的角色,就是持续从这些模块产出的任务队列中取出回调并执行。​
​
定时器 (setTimeout, setInterval)​
​
•
当你调用 setTimeout(callback, 1000) 时,你并不是让 JS 引擎 1 秒后执行代码,而是请求浏览器内置的 定时器线程 在 1 秒后,将 callback 封装成一个宏任务,并放入宏任务队列。​
•
事件循环在未来的某个轮次中,发现了这个任务,才会取出并交给 JS 引擎执行。​
•
这就是为什么 setTimeout(fn, 0) 也不会“立即”执行,它只是将任务尽快地放入宏任务队列的队尾,等待下一轮调度。​
​
网络请求 (fetch, XHR)​
​
•
当你发起一个 fetch 请求,实际的网络通信工作由浏览器的网络线程(通常在浏览器进程中,通过专门的 I/O 线程处理)在后台异步执行。​
•
当网络响应到达时,网络模块会将相应的回调(如 then 中的逻辑)包装成一个宏任务,放入宏任务队列。​
•
事件循环在后续轮次中取出该任务,JS 引擎才得以执行你的回调代码。​
​
DOM 事件 (click, scroll)​
​
•
用户的物理输入(如点击鼠标)首先由操作系统捕获,然后传递给浏览器进程。​
•
浏览器进程根据事件发生的位置,将事件信息路由到对应的渲染进程。对于可滚动区域的 scroll 等事件,合成器线程可能直接处理以保证流畅度。​
•
如果该事件绑定了 JS 监听器,一个“事件处理”宏任务就会被创建并放入主线程的任务队列。​
•
事件循环取出这个任务,最终执行你编写的事件回调函数。​
​
所以,事件本身在浏览器的底层机制中产生并排队,最终被转化为一个个标准的“宏任务”,等待事件循环的统一调度。​
​
5. 串联:一次完整的浏览器渲染​
​
现在,让我们把所有碎片拼合起来,跟随一次从输入 URL 到页面异步交互的完整旅程,看看事件循环是如何串联起一切的:​
​
1.
导航与加载(浏览器进程)​
用户在地址栏输入 URL。浏览器进程发起网络请求,获取 HTML 文档。​
​
2.
内容渲染(渲染进程)​
浏览器进程将 HTML 数据流交给一个渲染进程。渲染进程的主线程开始工作:​
◦
事件循环调度了一个初始的宏任务:解析 HTML,构建 DOM 树,解析 CSS,构建 CSSOM。​
◦
遇到 <script> 标签,事件循环会调度另一个宏任务:交由 JS引擎 执行 JS。脚本中可能调用了 fetch、setTimeout,或为 DOM 元素绑定了 click 事件。这些 Web API 调用由相应的浏览器模块接管。​
​
3.
异步与交互​
页面渲染完成,但事件循环远未停止。它在主线程上不断重复着“取任务-执行-渲染”的循环:​
◦
setTimeout 定时器到期,定时器模块将回调放入宏任务队列。​
◦
fetch 请求收到响应,网络模块将回调放入宏任务队列。​
◦
用户点击了按钮,浏览器进程路由事件,一个点击事件回调被放入宏任务队列。​
◦
事件循环从队列中取出其中一个宏任务执行。假设是 fetch 的回调,其中 resolve 了一个 Promise。​
◦
该宏任务执行完毕,事件循环立刻检查并清空微任务队列,执行所有 then 里的回调。​
◦
微任务清空后,若有渲染需求,事件循环会触发 rAF 回调和后续的渲染流程,将最新的页面状态交给合成器线程。​
​
4.
合成与显示(合成器线程与 GPU进程)​
合成器线程接收主线程生成的图层信息和绘制指令,进行光栅化、合成,最终在屏幕刷新信号到来时,将渲染好的帧提交给 GPU,显示在屏幕上。​
​
至此,我们得到一个更清晰的结论:浏览器通过多进程、多线程协同运行,而事件循环作为渲染进程主线程的核心调度机制,以“宏任务 → 微任务 → 渲染”的顺序推动页面逻辑与渲染的持续执行,保证整个 Web 运行时的稳定与流畅。​