浏览器工作原理
> Last Format Time:6/15/2026 22:32:48
https://juejin.cn/post/7039036362653171742
1 进程架构
首先明确:浏览器是"多进程"架构(每个进程有独立内存空间,互不干扰),而每个进程内部包含多个"线程"(共享进程内存,协同工作)。
核心进程
而对于Chrome浏览器,最新(并非)架构如下图:
| 进程 | 作用 |
|---|---|
| Browser | 浏览器进程,控制应用程序的“chrome”部分,包括地址栏、书签、后退和前进按钮。还处理 Web 浏览器的不可见的特权部分,例如网络请求和文件访问。 |
| Renderer | 渲染器进程,控制显示网站的选项卡内的任何内容。 |
| Plugin | 插件进程,控制网站使用的任何插件,例如 flash。 |
| GPU | 图形处理进程,独立于其他进程处理 GPU 任务。它被分成不同的进程,因为 GPU 处理来自多个应用程序的请求并将它们绘制在同一个表面上。 |
下图为不同进程指向浏览器UI的不同部分:
当然也还有更多的进程,比如扩展进程和实用程序进程。。。 Chrome 正在经历架构更改,以将浏览器程序的每个部分作为一项服务运行,从而可以轻松地拆分为不同的进程或聚合为一个进程。
当 Chrome 在强大的硬件上运行时,它可能会将每个服务拆分为不同的进程以提供更高的稳定性,但如果它在资源受限的设备上,Chrome 会将服务合并到一个进程中以节省内存占用。在此更改之前,已在 Android 等平台上使用了类似的方法来合并进程以减少内存使用量。
核心线程
渲染进程是前端最关注的,内部包含 5个核心线程(各司其职,协同工作):
- 渲染进程有一个核心主线程,它既是 "JS 引擎线程" 也是 "GUI 渲染线程",二者互斥运行而非并行
- 渲染流水线还包括合成线程和栅格线程等辅助线程,负责渲染的后期阶段,与主线程协同工作
| 线程名称 | 作用 | 关键特性 |
|---|---|---|
| JS 引擎线程(V8 线程) | 执行 JS 代码(同步代码)、管理调用栈。 | 单线程!同一时间只能执行一个任务(避免 DOM 冲突),与 GUI 渲染线程互斥。 |
| GUI 渲染线程 | 执行渲染流程(解析 HTML/CSS、生成渲染树、布局、绘制)。 | 与 JS 引擎线程互斥:JS 执行时 GUI 会暂停,GUI 执行时 JS 会暂停(避免样式和 DOM 不一致)。 |
| 事件触发线程 | 管理"事件队列"(如点击、键盘、定时器、AJAX 回调)。 | 独立于 JS 引擎线程,当事件触发时,将回调函数放入事件队列,等待 JS 引擎空闲时执行。 |
| 定时器线程(setTimeout/setInterval) | 管理定时器的计时和触发。 | 独立于 JS 引擎线程(避免 JS 阻塞导致计时不准),计时结束后将回调放入事件队列。 |
| HTTP 请求线程 | 处理 AJAX/fetch 网络请求。 | 独立线程,可同时发起多个请求(浏览器限制同域名最大并发数,一般 6 个),请求完成后将回调放入事件队列。 |
浏览器中的进程
浏览器整体:进程数 ≈ 渲染进程 * n(同域名合并)+ 主进程、GPU、插件进程:
- 主进程
- 渲染进程(一个Tab页面)
- JS引擎
- GUI
- 事件线程
- 定时器
- HTTP
- GPU进程
- 插件进程
单个渲染进程:固定包含 5 个核心线程(JS 引擎、GUI、事件触发、定时器、HTTP 请求),可额外通过 Web Worker 创建新线程(见下一个问题)。
Tab页中的线程
一个页面(对应一个渲染进程)的线程数 = 核心线程数 + 自定义线程数
核心线程
即渲染进程内的核心线程(JS 引擎、GUI、事件触发、定时器、HTTP 请求),无论页面是否复杂,这 5 个线程都存在。
自定义线程
- Service Worker 线程:用于离线缓存、推送通知,属于独立于渲染进程的线程(关联页面,但在后台运行);
- Shared Worker 线程:多个同域名页面可共享的 Worker 线程(跨页面通信)。
原理:JS 引擎是单线程,但浏览器允许通过 new Worker() 创建 后台线程(Web Worker),用于执行耗时操作(如大数据计算、复杂逻辑处理)。
关键限制:
- 不能操作 DOM/CSS(无
document、window对象),只能通过postMessage与主线程通信; - 不能跨域加载脚本(Worker 脚本必须和页面同域名);
- 数量限制:浏览器对单个页面的 Worker 线程数有上限(一般 20-50 个,避免占用过多资源)。

多进程架构的优势包括:
- 隔离性:单个标签页崩溃不影响其他页面
- 安全性:渲染进程在沙盒中与操作系统隔离开来
- 性能优化
2渲染原理
渲染进程在==文档加载==、样式==变化==、用户==交互==时触发
从输入URL地址到渲染出页面的过程
graph TD
A[用户输入URL] --> B[浏览器主进程]
B --> C[网络进程]
C --> D[DNS解析]
D --> E[建立TCP连接]
E --> F[发送HTTP请求]
F --> G[接收HTML响应]
G --> H[渲染进程接收数据]
H --> I[HTML解析/DOM构建]
I --> J[遇到JavaScript/暂停解析]
J --> K[执行JavaScript]
K --> I
I --> L[CSS解析/CSSOM构建]
L --> M[渲染树构建]
M --> N[布局Layout/重排]
N --> O[分层Layer]
O --> P[绘制Painting/重绘]
P --> Q[合成Compositing]
Q --> R[GPU进程处理]
R --> S[屏幕显示]
网络请求部分
- DNS解析:将域名转换为IP地址
- TCP连接:与服务器建立可靠连接(三次握手)
- HTTP请求:发送请求头和数据
- 响应处理:接收服务器返回的HTML、CSS、JS等资源
- 数据传输:将响应数据传递给渲染进程进行解析
- 预解析器:提前下载CSS和JavaScript资源
DOM树与CSSOM树
HTML解析:
- 令牌化 (Tokenization):将HTML标签转换为令牌
- 树构建:根据标签嵌套关系构建DOM树
- 预解析优化:主解析器工作时,预解析器提前扫描和下载外部资源
CSS解析与HTML解析并行进行 JavaScript对渲染流程的影响:
<!-- 同步脚本阻塞解析 -->
<script src="app.js"></script>
<!-- 异步脚本不阻塞 -->
<script async src="app.js"></script>
<!-- 延迟执行脚本 -->
<script defer src="app.js"></script>
阻塞机制:
- 解析阻塞:同步脚本会暂停HTML解析
- 渲染阻塞:CSS和JavaScript都可能阻塞渲染,CSS不会阻塞HTML解析,但会阻塞页面渲染
CSS 真的和 HTML “并行”解析
- 下载阶段:遇到
<link rel="stylesheet">,浏览器会立刻并行下载 CSS 文件,同时继续往下解析 HTML。所以网络层面是并行的。 - 解析阶段:
- 浏览器一边解析 HTML,一边构建 DOM 树。
- CSS 文件下载完就会被解析成 CSSOM 树,这个解析过程不会卡住 DOM 的构建。
- 渲染阶段:浏览器要画出页面,必须同时拥有 DOM 和 CSSOM。只要 CSSOM 还没好,哪怕 DOM 已经完整了,画面也出不来。这就是“CSS 不阻塞 HTML 解析,但阻塞渲染”的精确含义。
> 于是你会看到页面白屏,但 DOM 节点其实已经在后台生成好了(可以通过脚本验证)。
三种 script 对解析的影响
同步脚本 <script src="app.js"></script>
- 行为:浏览器看到它就立刻停下手头的 HTML 解析,去下载(如果没有缓存)并立即执行这个 JS。
- 阻塞了什么?
- 阻塞 HTML 解析:执行完脚本之前,后面的 DOM 节点都不会被构建。
- 阻塞渲染:因为解析都停了,新的 DOM 和 CSSOM 没法合成,自然也无法渲染。
- 额外依赖:如果脚本想读取元素样式,它还必须等排在它前面的 CSS 加载完(否则样式信息没准备好),这会导致脚本执行延后,进一步延长解析阻塞时间。
异步脚本 <script async src="app.js"></script>
- 行为:下载过程完全异步,不阻塞 HTML 解析。但一下载完就立刻执行,此时解析可能还没结束。
- 阻塞了什么?
- 执行时阻塞 HTML 解析:如果在执行那一刻解析还在进行,就会暂停解析。
- 执行顺序不可控:多个 async 脚本谁先下载完谁先执行,适合独立无依赖的脚本(比如统计脚本)。
- 渲染影响:因为执行会打断解析,也就间接推迟了渲染时机。
延迟脚本 <script defer src="app.js"></script>
- 行为:下载完全异步,和 async 一样不阻塞解析。但执行被推迟到HTML 解析完全结束之后、
DOMContentLoaded事件之前。 - 阻塞了什么?
- 不阻塞 HTML 解析。
- 渲染会等到 DOM 完全构建好之后再进行(通常这时 CSSOM 也已就绪),而 defer 脚本又在渲染之前执行,所以仍然可能影响首屏渲染的时机,但不会在解析中途卡住。
- 顺序保证:多个 defer 脚本严格按在文档中的顺序执行。
CSS 对 JS 的“隐性阻塞”
你前面提到“CSS 不会阻塞 HTML 解析”,这没错。但如果中间夹了一个脚本,情况就变了:
<link rel="stylesheet" href="style.css">
<script src="app.js"></script>
app.js如果是同步脚本,浏览器在执行它之前,会检查前面的 CSSOM 是否已构建好。- 因为脚本可能会读取样式(例如
element.style.color),如果 CSSOM 没准备好,这个读操作就不准确,所以浏览器必须等前面的 CSS 加载并解析完,才执行这个脚本。 - 结果就是:CSS 虽然不直接阻塞 HTML 解析,但它通过阻塞脚本执行,间接阻塞了 HTML 解析。
所以严格来说,存在这样的情况:CSS 会阻塞同步脚本的执行,进而阻塞 HTML 解析。
思维速记图
最后帮你把关系压缩成一张逻辑链,下次分不清的时候回想这个顺序:
- 浏览器从上往下扫描 HTML。
- 看到 CSS → 开启下载,同时继续解析 HTML(不阻塞 DOM 构建)。
- 看到同步 script → 立即停下,等该 script 下载 + 执行;如果前面还有未完成的 CSS,得等 CSS 先完成。
- 看到 async script → 继续解析,但脚本一下载完就执行,此时可能打断解析。
- 看到 defer script → 继续解析,等 DOM 全完再执行。
- 只有当 DOM 树完成 + CSSOM 树完成,并且没有阻塞渲染的脚本还在跑,浏览器才开始渲染像素。
最佳实践
CSS 的推荐位置
最佳实践:放在 <head> 里,尽早加载。
- 虽然 CSS 不阻塞 HTML 解析,但它阻塞页面渲染。把 CSS 放在头部,浏览器可以立刻下载并解析,CSSOM 树能尽快构建好。
- 如果放到
<body>末尾,浏览器要先渲染一个“无样式”的 DOM,等读到 CSS 后再重绘,导致页面闪烁(FOUC),用户体验极差。
进阶优化:
- 内联关键 CSS:把首屏必需的样式直接写在
<style>标签里,减少一次网络往返,彻底消除对首屏渲染的阻塞。 - 异步加载非关键 CSS:用
media="print" onload="this.media='all'"或rel="preload"+ 切换,让次要样式不阻塞初始渲染。
<!-- 关键样式:内联 -->
<style>/* 首屏必须的样式 */</style>
<!-- 次要样式:异步加载,不阻塞渲染 -->
<link rel="preload" href="non-critical.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="non-critical.css"></noscript>
同步脚本(无属性)的推荐位置
最佳实践:移到 <body> 末尾,</body> 之前。
- 同步脚本会阻塞 HTML 解析。放在末尾时,它前面几乎完整的 DOM 已经构建好了,脚本可以安全操作节点,同时不会再阻挡后续内容。
- 如果放在
<head>里且没有 defer/async,页面会长时间白屏,直到脚本下载执行完才能继续解析,严重影响体验。
<body>
<!-- 页面所有内容 -->
<script src="library.js"></script>
<script src="app.js"></script>
</body>
defer / async 脚本的推荐位置
如今大多数脚本都应该用 defer 或 async 来解除阻塞。
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<link rel="stylesheet" href="main.css">
</head>
<body>
<script defer src="index2.js"></script>
<div id="root">这里是根节点</div>
<script src="index.js"></script>
</body>
</html>
图中可以看到,index2.js先加载完了,但是没有执行,就是defer的原因 
| 类型 | 何时使用 | 推荐放置位置 |
|---|---|---|
| defer | 需要操作 DOM,且依赖脚本执行顺序(如框架代码、工具库、页面初始化逻辑) | 放在 <head> 里,可提前并行下载,不阻塞解析,执行顺序有保证 |
| async | 完全独立、不依赖 DOM 也不被其他脚本依赖的模块(如统计、广告、独立小工具) | 同样放在 <head>,下载完立刻执行,可能在任何时刻插入执行 |
特别提醒: ES6 模块脚本 <script type="module"> 默认就是 defer 行为,可以安全地放在 <head> 中。
<head>
<!-- 依赖框架,需保持顺序,用 defer -->
<script defer src="framework.js"></script>
<script defer src="app.js"></script>
<!-- 独立统计脚本,用 async -->
<script async src="analytics.js"></script>
</head>
把上面所有优化结合起来,一个典型的“性能友好”结构如下:
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>性能优化示例</title>
<!-- 1. 关键 CSS:内联,零阻塞 -->
<style>
/* 首屏内容的核心样式 */
</style>
<!-- 2. 非关键 CSS:使用 preload 异步加载 -->
<link rel="preload" href="full.css" as="style" onload="this.rel='stylesheet'">
<!-- 3. 框架/主逻辑脚本:defer,在 head 提前下载,最后执行 -->
<script defer src="framework.js"></script>
<script defer src="main.js"></script>
<!-- 4. 完全独立的模块:async -->
<script async src="https://third-party.com/tracker.js"></script>
</head>
<body>
<!-- 所有 HTML 内容 -->
<!-- 不再放同步脚本,除非有特殊需求且确认位置没问题 -->
</body>
</html>
渲染树
结合DOM和CSSOM生成渲染树:
- 仅包含可见元素:
display: none的元素不进入渲染树 - 样式计算:为每个元素计算最终样式(层叠、继承)
- 可见性处理:
visibility: hidden的元素仍在渲染树中但不可见
布局Layout(重排)流程
- 布局计算:计算每个元素在视口中的精确位置和大小(重排)
- 重排触发:当布局变化时需要重新计算元素几何属性
- 计算位置大小:确定元素在视口中的确切位置和尺寸
- 重排影响:布局变化会导致整个渲染流程重新执行
分层Layer
浏览器将页面分解为多个图层进行优化渲染:
- 显式分层:
transform,opacity,will-change等CSS属性 - 隐式分层:重叠内容、视频、canvas等特殊元素
- 硬件加速:使用GPU进行图层合成,提升性能
- 层管理:浏览器自动管理图层创建和销毁
绘制Paint(重绘)
将渲染树转换为屏幕像素的过程:
- 绘制列表生成:为每个图层生成绘制命令
- 重绘触发:当元素外观变化但不影响布局时发生
- 绘制优化:仅重新绘制受影响区域
光栅化raster
分块tiles:
- 图块划分:将大图层分割为小图块(通常256x256或512x512)
- 视口优先:优先渲染可视区域内的图块
- 渐进加载:离视口越远的图块优先级越低
- 缓存机制:已光栅化的图块会被缓存复用
光栅化:将矢量图形转换为位图
- 合成器将图层分为图块
- 光栅线程将图块转换为位图
- GPU进程合成最终图像
合成
合成器线程将各个图层合成为最终图像frame:
graph LR
A[图层1] --> D[合成器]
B[图层2] --> D
C[图层3] --> D
D --> E[帧缓冲区]
E --> F[屏幕显示]
[合成优势]:
- 独立合成:图层变化无需重新布局和绘制
- GPU加速:利用显卡硬件能力提升性能
- 60fps目标:每16.67ms完成一次完整的渲染流水线
绘制draw
 [性能优化建议]:
- 减少重排reflow:批量DOM操作,使用文档片段,尽量减少节点的几何信息、DOM结构、宽高、字体大小的频繁修改
- 优化重绘repaint:使用CSS类名切换而非直接样式修改
- 利用合成层:对动画元素使用transform和opacity
- 避免布局抖动:不要在循环中连续读取布局属性
- 减少图层数量:避免不必要的图层创建,防止"层爆炸"
现代渲染引擎优化特性
[补充说明]:现代浏览器还包含以下优化机制:
- 增量布局:只重新计算变化部分的布局
- 懒加载:延迟加载视口外的图片和内容
- 预渲染:预测用户行为提前渲染可能访问的页面
- 缓存机制:复用已光栅化的图块和计算结果
- 并行处理:多个渲染阶段可以并行执行提升效率
这下面的内容是掘金中的,很硬核,很带派,我会将他们整理进去
导航跳转
选项卡之外的所有内容都由浏览器进程处理,也就是 Browser Process。浏览器里的进程里有一些线程,比如绘制 Button 和 Input 的 UI 线程、处理网络堆栈以从 Internet 接收数据的网络线程、控制对文件访问的存储线程等等。
在地址栏中输入 URL 时,输入由浏览器进程的 UI 线程处理。
开始
第一步:处理输入 当地址栏中输入内容时,UI 线程首先询问的是“这是搜索查询还是 URL?”。在 Chrome 中,地址栏也是一个搜索输入字段,因此 UI 线程需要解析并决定是将它发送到搜索引擎,还是发送到请求的站点。
第二步:开始寻找 按下回车键时,UI 线程会发起网络请求以获取站点内容。Loading spinner 显示在选项卡的一角,网络线程通过适当的协议,如 DNS 查找和为请求建立 TLS 连接。
此时,网络线程可能会收到服务器重定向标头,如 HTTP 301。在这种情况下,网络线程会与服务器请求重定向的 UI 线程通信。然后,将发起另一个 URL 请求。
第三步:读取响应 一旦响应的开始进入,也就是请求的 Payload,网络线程会在必要时查看流的前几个字节。响应的 Content-Type 标头应该说明它是什么类型的数据,但由于它可能丢失或错误,因此在这里完成
MIME 类型校验。如果响应是一个 HTML 文件,那么下一步是将数据传递给 GPU 进程,但如果它是一个 zip 文件或其他一些文件,那么它就是一个下载请求,接着他们需要将数据传递给下载管理器。
也正是在这个地方进行安全浏览检查,如果域和相应数据跟恶意网站匹配,网络线程就会发出警报并显示警告页面,而 CORS检查 也发生在这个过程,为了确保敏感跨站点数据不扔给渲染器。
第四步:查找渲染器进程 一旦完成所有检查并且网络线程确信浏览器应该导航到请求的站点,网络线程就会告诉 UI 线程数据已准备就绪。UI线程然后找到一个渲染器进程来进行网页的渲染。
由于网络请求可能需要数百毫秒才能获得响应,因此应用了优化以加快此过程。当 UI 线程在第 2 步向网络线程发送 URL 请求时,它已经知道他们要导航到哪个站点。UI 线程尝试与网络请求并行地主动查找或启动渲染器进程。这样,如果一切按预期进行,当网络线程接收到数据时,渲染器进程已经处于待机状态。如果导航重定向跨站点,则可能不会使用此备用进程,在这种情况下,可能需要不同的进程。
第五步:提交 现在数据和渲染器进程已经准备就绪,一个 IPC 从浏览器进程发送到渲染器进程以提交导航。它还传递数据流,因此渲染器进程可以继续接收 HTML 数据。一旦浏览器进程听到在渲染器进程中发生提交的确认,导航就完成了,文档渲染阶段开始。
此时,地址栏已更新,安全指示器和站点设置 UI 反映了新页面的站点信息。选项卡的会话历史将更新,因此后退/前进按钮将逐步浏览刚刚导航到的站点。为了在关闭选项卡或窗口时促进选项卡/会话恢复,会话历史记录存储在磁盘上。
其他步骤 提交后,渲染器进程会继续加载资源并渲染页面。渲染器进程“完成”渲染后,它会将 IPC 发送回浏览器进程(这是在
onload页面中的所有帧上触发所有事件并完成执行之后)。此时,UI 线程停止选项卡上的 加载小loading。在此之后客户端 JavaScript 仍然可以加载额外的资源并呈现新的视图。
导航到其他站点
如果用户再次将不同的 URL 放入地址栏会发生什么?浏览器进程通过相同的步骤导航到不同的站点。但在此之前,它需要检查当前呈现的站点是否有 beforeunload 事件。
beforeunload 可以创建一个 “离开此站点?” 的事件,当离开或关闭选项卡时发出警报。选项卡内的所有内容(包括 JavaScript 代码)都由渲染器进程处理,因此当新的导航请求传入时,浏览器进程必须检查当前的渲染器进程。
> 注意:不要添加无条件beforeunload处理程序。它会产生更多的延迟,因为需要在导航开始之前执行处理程序。仅在需要时才应添加此事件处理程序,例如,如果需要警告用户他们可能会丢失在页面上输入的数据。
当新导航到达与当前呈现的站点不同的站点时,将调用一个单独的呈现进程来处理新的导航,同时保留当前的呈现进程以处理诸如 unload。有关页面生命周期状态,可以看 这里。
下图为从浏览器进程到新渲染器进程的 2 个 IPC,告诉渲染页面并告诉旧渲染器进程卸载:
Service Worker
首先,Service Worker 允许开发者更好地控制本地缓存的内容以及何时从网络获取新数据。如果 service worker 设置为从缓存加载页面,则无需从网络请求数据。
注意:Service Worker 是在渲染器进程中运行的 JavaScript 代码。
但是当导航请求进来时,浏览器进程又如何知道哪个站点有Service Worker?
注册Service Worker后,Service Worker的作用域将会保留。当导航发生时,网络线程会根据注册的 Service Worker 范围检查域,如果 Service Worker 已为该 URL 注册,则 UI 线程会查找渲染器进程以执行 Service Worker 代码。Service Worker 可能会从缓存中加载数据,从而无需从网络请求数据,或者它可能会从网络请求新资源。
下图为浏览器进程中的 UI 线程启动渲染器进程来处理服务工作者;渲染器进程中的工作线程然后从网络请求数据:
导航预加载
如果 Service Worker 最终决定从网络请求数据,浏览器进程和渲染器进程之间的这种往返可能会导致延迟。Navigation Preloads 是一种通过在 Service Worker 启动的同时加载资源来加速此过程的机制。它用标头标记这些请求,允许服务器决定为这些请求发送不同的内容;例如,只是更新数据而不是完整文档。
渲染
导航过后,浏览器会调用渲染器(UI)进程工作。
渲染器进程处理Web
渲染器进程负责选项卡内发生的所有事情。在渲染器进程中,主线程处理发送给用户的大部分代码。如果使用 Web Worker 或 Service Worker,有部分 JavaScript 由工作线程处理。合成器和光栅线程也在渲染器进程内运行,以高效、流畅地渲染页面。
渲染器进程的核心工作是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页。
解析
构建DOM
当渲染过程接收提交消息用于导航和开始接收HTML数据,主线程开始解析HTML,使之成为一个 DOM。
DOM 是浏览器对页面的内部表示,也是开发人员可以通过 JavaScript 与之交互的数据结构和 API。将HTML文档解析为DOM是由HTML标准定义的,所以有时候写错标签,也会被自动纠正,具体可以查看 解析器中的错误处理。
子资源加载
对于图像、CSS 和 JavaScript 等外部资源,需要从网络或缓存加载。主线程可以在解析构建DOM的过程中找到它们后一一请求,但为了加快速度,“预加载扫描器(preload scanner)” 是并发运行的。如果HTML 文档中有类似<img>或 <link>,预加载扫描器会查看 HTML 解析器生成的 token,并将请求发送到浏览器进程中的网络线程。
JavaScript 可以阻止解析
当 HTML 解析器找到一个<script>标签时,它会暂停 HTML 文档的解析,并且必须加载、解析和执行 JavaScript 代码。为什么?因为 JavaScript 可以使用document.write()改变整个 DOM 结构的东西来改变文档的结构,这里有张图表。
如何加载资源
如果JavaScript 不使用document.write(),可以添加 async 或 defer 属性到<script>标签。然后浏览器异步加载和运行 JavaScript 代码,并且不会阻止解析。浏览器支持的话,当然也可以用 Javascript Module。<link rel="preload">是一种通知浏览器当前导航肯定需要该资源并且希望尽快下载的方式,这里是资源优先级。
样式解析
主线程会解析 CSS 并确定每个 DOM 节点的计算样式。
每个DOM节点都有一个默认样式,这是默认样式表。
布局
到目前为止,渲染器进程知道文档的结构和每个节点的样式。
布局是一个寻找元素几何形状的过程,主线程遍历 DOM 和计算样式并创建布局树,其中包含 xy 坐标和边界框大小等信息。布局树可能与 DOM 树的结构相似,但它只包含与页面上可见的内容相关的信息。如果 display: none 应用,则该元素不是布局树的一部分(但是,具有的 visibility: hidden 在布局树中)。类似地,如果应用了具有类似内容的伪类,p::before{content:"Hi!"} 即使它不在 DOM 中,它也会包含在布局树中。
CSS代表了整个页面的初始布局,如果想多了解一点,看下这个演讲吧!
绘制
到目前为止,有了DOM、样式和布局,但是想要开始绘制需要判断绘制的顺序。例如,z-index 可能会为某些元素设置,在这种情况下,按照 HTML 中编写的元素的顺序绘制将导致不正确的渲染。

在绘制步骤中,主线程遍历布局树以创建绘制记录。绘制记录的顺序是:先背景,后文字,再矩形。这个和 <canvas> 的绘制过程有点像。

注意
绘制过程最重要的一点是:绘制的每一步都使用前一操作的结果来创建新数据。如果布局树中的某些内容发生了变化,则需要为文档的受影响部分重新生成绘制顺序。
如果为元素设置动画,则浏览器必须在每一帧之间运行这些操作。我们的大多数显示器每秒刷新屏幕 60 次 (60 fps);在每一帧在屏幕上移动物体时,动画对人眼来说会显得平滑。但是,如果动画错过了中间的帧,则页面将出现“janky”。
即使渲染操作跟上屏幕刷新,这些计算也在主线程上运行,也就是说当应用程序运行 JavaScript 时,它可能会被阻止。
这时候,可以将 JavaScript 操作分成小块,并使用 requestAnimationFrame() 来处理,也可以通过 WebWorker 运行JavaScript以避免阻塞主线程。有关JS执行优化,可以点这里。
合成
光栅化
到现在,浏览器知道了文档的结构、每个元素的样式、页面的几何形状和绘制顺序,开进行真正的绘制,将此过程转换为屏幕上的像素称为 光栅化 。
Chrome第一次发布时,处理光栅化的方式是:只在视窗口内对部分页面进行光栅化,当用户滚动页面,就移动光栅的架子,并通过更多光栅来填充缺失的部分。
然而,在现代浏览器运行着一个更复杂的过程,叫合成。
合成,把页面的各个部分分成多个层,单独光栅化它们,并在合成器线程的单独线程中合并成一个页面。此时如果发生滚动,因为图层已经被光栅化,它所要做的就是合成一个新的框架。动画可以通过移动图层并合成新帧以相同的方式实现。查看页面的图层,可以从控制台的 More tools --> layers 打开。
分层
为了找出哪些元素需要在哪些层中,主线程遍历布局树以创建层树(可以在 DevTools 的 Performance 面板中称为“Update Layer Tree”)。如果页面的某些部分应该是单独的层(如滑入式侧菜单)没有获取到,可以通过使用 will-change CSS 中的属性来提示浏览器。
和每帧光栅化页面的小部分相比,为每个元素都提供层,并且合成会导致操作很慢。
主线程的光栅和合成
一旦创建了层树并确定了绘制顺序,主线程就会将该信息提交给合成器线程。合成器线程然后光栅化每一层。一个图层可能像页面的整个长度一样大,因此合成器线程将它们分成多个图块并将每个图块发送到光栅线程。光栅线程光栅化每个图块并将它们存储在 GPU 内存中。
合成器线程可以对不同的光栅线程进行优先级排序,以便可以首先对视口内(或附近)的事物进行光栅化。一个图层也有多个不同分辨率的平铺来处理诸如放大操作之类的事情。
对切片进行光栅化后,合成器线程会收集称为绘制四边形的切片信息以创建合成器框架。
| 名称 | 说明 |
|---|---|
| 合成器框架 | 代表页面框架的绘制四边形的集合。 |
| 绘制四边形 | 包含诸如磁贴在内存中的位置以及在考虑页面合成的情况下在页面中绘制磁贴的位置等信息。 |
然后通过 IPC 将合成器框架提交给浏览器进程。此时,可以从用于浏览器 UI 更改的 UI 线程或用于扩展的其他渲染器进程添加另一个合成器框架。这些合成器帧被发送到 GPU 以将其显示在屏幕上。如果出现滚动事件,合成器线程会创建另一个合成器帧以发送到 GPU。
合成的好处是它是在不涉及主线程的情况下完成的。合成器线程不需要等待样式计算或 JavaScript 执行。这就是为什么 只合成动画 被认为是获得流畅性能的最佳选择。如果需要重新计算布局或绘制,则必须涉及主线程。
用户输入和合成器
浏览器的input事件
从浏览器的角度来看,输入意味着来自用户的任何事件。鼠标滚轮滚动是一个事件,触摸或鼠标悬停也是一个事件。
当用户在屏幕上进行触摸等手势时,浏览器进程首先接收该手势。但是,浏览器进程只知道该手势发生的位置,因为选项卡内的内容由渲染器进程处理。因此浏览器进程将事件类型(如touchstart)及其坐标发送到渲染器进程。渲染器进程通过查找事件目标并运行附加的事件侦听器来适当地处理事件。
下图为Input事件通过浏览器进程路由到渲染器进程:
非快速滚动区域
如果没有input事件监听器附加到页面,合成器线程可以创建一个完全独立于主线程的新复合框架,如果某些事件侦听器附加到页面上,合成器线程如何确定事件是否需要处理?
由于运行 JavaScript 是主线程的工作,因此在合成页面时,合成器线程会将页面中附加有事件处理程序的区域标记为“非快速可滚动区域”。通过获得这些信息,合成器线程可以确保在该区域发生事件时将input事件发送到主线程。如果input事件来自该区域之外,则合成器线程继续合成新帧,而无需等待主线程。
事件委托
开发中常见的事件处理模式是事件委托。由于事件冒泡,可以在最顶层元素附加一个事件处理程序,并根据事件目标委派任务,比如:
js
体验AI代码助手
代码解读
复制代码
document.body.addEventListener('touchstart', event => { if (event.target === area) { event.preventDefault(); } });
如果需要为所有元素编写一个事件处理程序的话,这种事件委托模式很有吸引力。但是,如果从浏览器的角度来看这段代码,现在整个页面都被标记为非快速可滚动区域。这意味着即使程序不关心来自页面某些部分的输入,合成器线程也必须与主线程通信并在每次输入事件进入时等待它。因此,合成器的平滑滚动能力被打败了。
为了减少这种情况的发生,可以传递属性 passive: true ,这样会向浏览器暗示仍然希望在主线程中侦听事件,但合成器也可以继续合成新帧,比如:
js
体验AI代码助手
代码解读
复制代码
document.body.addEventListener('touchstart', event => { if (event.target === area) { event.preventDefault() } }, {passive: true});
检查事件是否可以取消
有个场景,只有水平滚动,没有垂直滚动。
passive: true 在指针事件中使用选项意味着页面滚动可以平滑,但垂直滚动可能在想要的时候开始preventDefault以限制滚动方向,这时候可以使用event.cancelable方法对此进行检查,比如:
js
体验AI代码助手
代码解读
复制代码
document.body.addEventListener('pointermove', event => { if (event.cancelable) { event.preventDefault(); // block the native scroll /* * do what you want the application to do here */ } }, {passive: true});
或者,可以使用 CSS 规则 touch-action来完全消除事件处理程序,比如:
css
体验AI代码助手
代码解读
复制代码
#area { touch-action: pan-x; }
寻找event.target
当合成器线程向主线程发送输入事件时,首先要运行的是命中以找到事件目标。命中使用渲染过程中生成的绘制记录数据来找出发生事件的点坐标下方的内容。
最小化事件调度到主线程
前面知道了,典型显示器每秒刷新屏幕 60 次,以及我们需要跟上节奏以获得流畅的动画。而对于输入来说。典型的触摸屏设备每秒传递 60-120 次触摸事件,典型的鼠标每秒传递 100 次事件。输入事件的保真度高于我们的屏幕可以刷新的保真度。
如果像touchmove 这样的连续事件每秒发送到主线程 120 次,那么与屏幕刷新的速度相比,它可能会触发过多的命中和 JavaScript 执行:
为了尽量减少对主线程的过多调用,Chrome 会合并连续事件(例如 wheel, mousewheel, mousemove, pointermove, touchmove)并延迟调度直到下一个requestAnimationFrame,可以发现,时间线一样,但事件进行了合并和延迟。
类似的事件,如keydown,keyup,mouseup,mousedown,touchstart,和touchend 被立即执行。
使用 getCoalescedEvents 得到帧内事件
对于大多数 Web 程序,合并事件应该足以提供良好的用户体验。但是,像构建绘图程序和基于 touchmove 坐标放置路径之类的东西 ,绘制平滑线的时候可能会丢失中间坐标。在这种情况下,可以使用 getCoalescedEvents 指针事件中的方法来获取有关这些合并事件的信息。
下图左侧是平滑的触摸手势路径,右侧是合并的有限路径:
js
体验AI代码助手
代码解读
复制代码
window.addEventListener('pointermove', event => { const events = event.getCoalescedEvents(); for (let event of events) { const x = event.pageX; const y = event.pageY; // draw a line using x and y coordinates. } });
参考资料
Inside look at modern web browser
作者:道里
链接:https://juejin.cn/post/7039036362653171742
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。