本文从浏览器的多进程架构入手,介绍浏览器从地址栏输入 URL,到最终页面完整呈现,这背后的工作流程。
之所以选择chrome浏览器,是因为 Chrome、微软的 Edge 以及国内的大部分主流浏览器,都是基于 Chromium 二次开发而来;而 Chrome 是 Google 的官方发行版,特性和 Chromium 基本一样,只存在一些产品层面差异;再加上 Chrome 是目前世界上使用率最高的浏览器,所以 Chrome 最具代表性。
在开始之前,我们一起看下,Chrome 打开一个页面需要启动多少进程?你可以点击 Chrome 浏览器右上角的“选项”菜单,选择“更多工具”子菜单,点击“任务管理器”,这将打开 Chrome 的任务管理器的窗口。
和 Windows 任务管理器一样,Chrome 任务管理器也是用来展示运行中的 Chrome 使用的进程信息的。从图中可以看到,Chrome 启动了多个进程,你也许会好奇,只是打开了 1 个页面,为什么要启动这么多进程呢?
单进程架构
其实在 2007 年之前,市面上浏览器都是单进程的。顾名思义,单进程浏览器是指浏览器的所有功能模块都是运行在同一个进程里,这些模块包含了网络、插件、JavaScript 运行环境、渲染引擎和页面等。
如此多的功能模块运行在一个进程里,是导致单进程浏览器不稳定、不流畅和不安全的一个主要因素。
- 稳定性极差: 单进程浏览器会把所有功能模块(浏览器内核、渲染引擎、JavaScript 引擎、插件、标签页、扩展程序)都塞进同一个进程里运行。只要其中任意一个模块出现问题(比如某个网页的 JS 代码卡死、某个插件崩溃),就会导致整个浏览器卡死或直接崩溃,所有打开的标签页或者卡死或者瞬间关闭。
- 安全性薄弱: 所有功能模块共享同一个进程的内存空间和系统权限,没有隔离边界。如果某个恶意网页通过漏洞执行了恶意代码,它可以直接访问浏览器的所有数据(如密码、Cookie、历史记录),甚至借助浏览器的权限去攻击操作系统。
- 性能瓶颈明显
- 资源抢占严重:浏览器的各个模块(渲染页面、执行 JS、播放视频、加载插件)会争夺同一个进程的 CPU、内存资源。比如一个网页在执行复杂的 JS 计算时,会导致其他标签页的渲染卡顿,甚至浏览器的 UI 界面(如菜单、地址栏、前进后退等)都无法响应操作。
- 内存泄漏影响全局:单个网页的内存泄漏会持续占用整个浏览器进程的内存,随着打开的网页增多,内存占用会越来越高,最终导致浏览器变慢甚至卡死。
- 扩展程序的风险放大: 单进程浏览器中,扩展程序和浏览器主程序运行在同一进程中。一个写得不好的扩展程序(比如内存泄漏、无限循环),会直接拖慢整个浏览器;恶意扩展则可以轻易获取浏览器的所有敏感信息。
多进程架构
现代浏览器采用 多进程架构,很好地解决了以上的问题。
- 高稳定性: 一个标签页崩溃不会影响其他标签页或整个浏览器。
- 更强的安全性
- 不同标签页分配独立的渲染器进程,恶意代码的影响范围会被严格限制在单个进程内。
- 渲染器进程运行在沙箱中,限制对文件系统、网络等敏感资源的访问。
- 站点隔离(Site Isolation)可防止跨站数据窃取。
- 更好的响应性
- 每个页面的渲染进程独立,即使某个页面卡死,用户仍可操作浏览器其他部分和访问其它页面。
- 扩展进程隔离
- 每个扩展单独分配独立的沙箱进程,即使某个扩展崩溃或陷入死循环,也不会导致整个浏览器无响应或崩溃。
- 扩展代码运行在高度受限的沙箱中,无法直接访问文件系统、本地网络或其他进程。
浏览器主要进程
| 进程 | 职责 |
|---|---|
| Browser进程 | 浏览器主进程,只有一个。负责用户界面、地址栏、书签、标签管理、前进后退按钮,以及协调其他进程。它处理网络请求和文件访问。 |
| Render进程 | 每个标签页通常对应一个。它负责标签页内的所有事情,如解析HTML/CSS、执行JavaScript、排版和绘制。排版引擎 Blink 和 JavaScript 引擎 V8 都是运行在该进程中。出于安全考虑,渲染进程运行在沙箱中,无法直接访问系统资源。 |
| GPU进程 | 只有一个。负责独立的图形任务,特别是UI的绘制和3D CSS效果,处理 3D 图形加速、合成(compositing)等。 |
| Network进程 | 统一处理所有网络请求(Chromium 67+ 后独立) |
| Utility / Plugin / Extension 进程 | 插件、扩展、工具类任务| |
一般来说,一个标签页会分配一个渲染进程,但是也不绝对,浏览器有一些优化机制:
- 1.如果页面里有iframe,且iframe加载的页面不是同一站点(同一协议+同一主域名) 的话,iframe也会运行在单独的进程中。如果是同一站点,则和父标签页共用一个渲染进程。
- 2.如果从a页面中用“window.open()”打开b页面,或者a页面中通过设置了
target="_blank"和rel="opener"的a标签打开b页面,那么b页面可以通过window.opener来访问a页面的window对象。上面的情况中,如果a页面和b页面是同一站点,则他们会共用一个渲染进程(复用当前标签页的渲染进程)。否则打开的新页面会新开一个新的渲染进程。- 3.譬如打开多个空白标签页后,会发现多个空白标签页被合并成了一个进程。
另外:
- 如果页面里有插件,同样插件也需要开启一个单独的进程。
- 如果你装了扩展的话,每个扩展也会占用一个进程。
上面提到的可以通过 opener 来建立连接的标签页,他们之间是有联系的。在 WhatWG 规范(developer.mozilla.org/zh-CN/docs/…)中,把这一类具有相互连接关系的标签页称为浏览上下文组 ( browsing context group)。
通常情况下,我们把一个标签页所包含的内容,诸如 window 对象,历史记录,滚动条位置等信息称为浏览上下文。这些通过脚本相互连接起来的浏览上下文就是浏览上下文组。
Chrome 浏览器会将浏览上下文组中属于同一站点的标签分配到同一个渲染进程中,这是因为如果一组标签页,既在同一个浏览上下文组中,又属于同一站点,那么它们可能需要在对方的标签页中执行脚本或者共享一些数据。因此,它们运行在同一渲染进程中会具有更好的渲染效率和数据访问效率。
虽然 Chrome 会让有连接且属于同一站点的标签页运行在同一个渲染进程中,不过如果 A 标签页和 B 标签页属于同一站点,却不属于同源站点(协议、域名、端口都相同),那么你依然无法通过 opener 来操作父标签页中的 DOM,会受到浏览器的同源策略的限制。
两个不同源页面在同一渲染进程中:
- 内存隔离:V8 引擎为不同源维护独立的执行上下文(Execution Context);
- 对象代理:
opener对象被浏览器代理化,只暴露安全的属性和方法;- 安全检查:每次访问都会触发同源检查。
进程模型与 IPC(Inter-Process Communication)设计
Chromium 的 IPC(进程间通信)架构是星型拓扑:
- Browser 进程是中心枢纽。
- 所有跨进程数据流必须经由 Browser 中转。
- 比如,Renderer 和 Network 进程不能直接通信(出于安全和稳定性考虑)。
📌 注:虽然技术上可以建立 Renderer ↔ Network 的 IPC 通道,但 Chromium 故意禁止这种直连,以强化安全边界。
Chrome 浏览器(基于 Chromium 架构)加载一个页面的过程是一个高度协同、多阶段的流水线,涉及 Browser 进程 和 Renderer 进程 的紧密配合。整个过程可划分为以下几个关键阶段:
🌐 一、导航启动阶段(Navigation Start)
触发条件:用户在地址栏输入 URL 并回车、点击链接、JS 调用 location.href = ... 等。
主要工作(由 Browser 进程的 UI 线程完成):
- 解析输入内容,确定目标 URL;
- 触发建议查询(Autocomplete Controller)
- 判断输入是搜索关键词还是URL,如果是关键词,则使用默认搜索引擎组合成URL
- 创建 Navigation Request;
- Navigator 模块 是 Browser 进程中负责处理页面跳转逻辑的核心组件,它会创建一个 C++ 对象
NavigationRequest - 这个对象会携带目标URL、Referrer、触发方式等导航相关信息
- Navigator 模块 是 Browser 进程中负责处理页面跳转逻辑的核心组件,它会创建一个 C++ 对象
- NavigationRequest按顺序触发各个检查阶段:
- 安全检查(如 Safe Browsing),如果判定为钓鱼、恶意软件站点,会拦截导航并显示警告页
- 权限策略判断
- 是否允许跳转(如弹窗拦截)
- 决定使用哪个 Renderer 进程(复用或新建)
- Navigation Request 的逻辑控制和生命周期由 Browser 进程管理
- 决定使用哪个 Renderer 进程(复用或新建);
- 向 Renderer 发送导航 IPC 消息。
📡 二、网络请求与响应阶段(Network Fetch)
执行者:Network Service Process(独立进程)
关键步骤:
- DNS 解析:将域名解析为 IP; 浏览器缓存 → 系统 hosts → DNS 服务器
- TCP 连接:建立到服务器的连接;
- TLS 握手(HTTPS):完成加密通道建立;
- 发送 HTTP 请求;
- 导航请求(主文档):由 Browser 进程发起(通过
NavigationRequest→URLRequest)。 - 子资源请求(JS/CSS/图片/XHR/fetch):由 Renderer 进程发起,通过 IPC 通知 Browser 进程,再转交 Network Service。
- 导航请求(主文档):由 Browser 进程发起(通过
- 接收响应头(Response Headers);
- 根据响应头浏览器可以知道内容类型(
Content-Type)、是否重定向、缓存策略等; - 如果状态码是
301、302等重定向,会根据Location头重新开始导航。
- 根据响应头浏览器可以知道内容类型(
⚠️ Browser进程在确认响应是HTML内容后,会
- 更新地址栏、安全状态(如 HTTPS 锁图标)
- “提交导航”(commit navigation),通知 Renderer 准备接收 HTML
- 流式接收响应体(Response Body)。
- Network Service 将响应数据通过 共享内存(Shared Memory) 传递给 Browser 进程。
- Browser 进程再通过 Mojo IPC 将共享内存的句柄(handle) 转发给目标 Renderer 进程。
- Renderer 直接从共享内存读取数据,无需多次拷贝。
- Renderer 的 主线程 接收字节流,并交给 HTML 解析器(HTMLParser);
- HTML 解析器增量构建 DOM,无需等待整个 HTML 下载完成。
🌟 这就是为什么你能在页面还在“转圈”时就看到部分内容——流式解析 + 渐进式渲染。
协作图
sequenceDiagram
participant Server
participant Network as Network Service Process
participant Browser as Browser Process
participant Renderer as Renderer Process
Server->>Network: 发送 HTTP 响应(头 + 体)
Network->>Browser: IPC: “响应头已就绪”
Browser->>Browser: 解析响应头
Browser->>Renderer: IPC: “准备接收 text/html”
Network->>Network: 解压(如 gzip)
Network->>Network: 数据块写入共享内存
Network->>Browser: IPC: 共享内存句柄
Browser->>Browser: MIME 嗅探(如需要)
Browser->>Renderer: IPC: 共享内存句柄
loop 流式接收响应体
Network->>Network: 解压(如 gzip)
Network->>Renderer: 通过 Data Pipe 发送数据块
Renderer->>Renderer: HTML 解析器增量构建 DOM
end
Network->>Network: 异步写入缓存(如可缓存)
🖼️ 三、提交导航 & 渲染进程接管(Commit Navigation)
关键事件:Renderer 进程确认此次导航合法、安全,可以加载该页面(通过 Navigation Commit)。
流程:
- Browser 进程通知 Renderer:“你将要加载这个 URL”;
- Renderer 创建新的 RenderFrame(或复用);
RenderFrame是 Renderer 进程中代表一个可渲染的文档上下文(Document Context),负责 DOM、JS、布局、绘制等前端工作。- 虽然
RenderFrame是内部实现,但它直接影响 Web 行为:
| Web 行为 | 底层对应 |
|---|---|
window.location = "..." | 触发 RenderFrame 发起导航请求 |
<iframe src="..."> | Browser 为 iframe 创建新的 RenderFrameHost/RenderFrame |
postMessage() 跨 frame 通信 | 通过 RenderFrame ↔ RenderFrame IPC 路由 |
| CSP / Permissions Policy | 由 RenderFrameHost 下发,RenderFrame 执行 |
- Renderer 回复 Browser:“我已准备好,可以接收 HTML”;
- Browser 将 HTML 流通过 IPC 传递给 Renderer;
- UI线程更新界面
- 地址栏 URL 更新(即使页面未完全加载);
- 前进/后退历史记录更新。
- 标签页标题变为“正在加载...”
- 安全指示器(锁图标)根据 HTTPS 状态更新
- 停止按钮激活,刷新按钮禁用
🔧 此时页面还是空白,但“舞台已搭好”。此阶段完成后,页面“归属”于该 Renderer 进程。
🧱 四、解析与构建阶段(Parsing & Building)
执行者:Renderer 进程的主线程(也称 Compositor Thread / Main Thread) 简单地说,就是:html,css,JS 变成页面的过程。
整个流程:
1. HTML 解析(HTML Parsing)
- 将字节流解码为字符;
- 构建 DOM 树(Document Object Model);
JavaScript 是如何影响 DOM 生成的?
1)看以下这段代码:
<html>
<body>
<div>1</div>
<script>
let div1 = document.getElementsByTagName('div')[0]
div1.innerText = 'time.geekbang'
</script>
<div>test</div>
</body>
</html>
当解析到<script>标签时,渲染引擎判断这是一段脚本,此时 HTML 解析器就会暂停 DOM 的解析,因为接下来的 JavaScript 可能要修改当前已经生成的 DOM 结构。
这时候 HTML 解析器暂停工作,JavaScript 引擎介入,并执行 script 标签中的这段脚本,因为这段 JavaScript 脚本修改了 DOM 中第一个 div 中的内容,所以执行这段脚本之后,div 节点内容已经修改为 time.geekbang 了。脚本执行完成之后,HTML 解析器恢复解析过程,继续解析后续的内容,直至生成最终的 DOM 树。
2)再看以下代码:
<html>
<body>
<div>1</div>
<script type="text/javascript" src='foo.js'></script>
<div>test</div>
</body>
</html>
当执行到 <script> 标签时,暂停整个 DOM 的解析,执行 JavaScript 代码,不过这里执行 JavaScript 时,需要先下载这段 JavaScript 代码。因为 JavaScript 文件的下载过程会阻塞 DOM 解析,而通常下载又是非常耗时的,会受到网络环境、JavaScript 文件大小等因素的影响。
不过 Chrome 浏览器做了很多优化,其中一个主要的优化是预解析操作。当渲染引擎收到字节流之后,会开启一个预解析线程,用来分析 HTML 文件中包含的 JavaScript、CSS 等相关文件,解析到相关文件之后,预解析线程会提前下载这些文件。
由于引入 JavaScript 文件会阻塞 DOM 树构建,可以考虑一些相关的策略来优化,比如使用 CDN 来加速 JavaScript 文件的加载,压缩 JavaScript 文件的体积等。
另外,如果 JavaScript 文件中没有操作 DOM 相关代码,就可以将该 JavaScript 脚本设置为异步加载,通过 async 或 defer 来标记代码。
- 使用 async 标志的脚本文件加载过程不阻塞DOM树的构建,一旦加载完成会立即执行,由于加载完成的时机不可控,所以多个async脚本的执行顺序不一定按它们在HTML中出现的顺序。执行过程会阻塞DOM树的构建,
- 使用了 defer 标记的脚本文件,加载是异步的,不阻塞HTML解析。等到整个 HTML 文档解析完成(即
</html>闭合),DOM 树构建完毕后,在 DOMContentLoaded 事件之前执行。多个defer脚本会严格按照它们在 HTML 中出现的顺序执行。
2. CSS 解析与样式计算(Style Calculation)
- HTML解析器遇到
<link rel="stylesheet">会发起 CSS 请求(但不阻塞 DOM 构建,只阻塞渲染) - 下载并解析 CSS
- 构建 CSSOM 树(CSS Object Model)
- 将 DOM + CSSOM 合并为 Render Tree(仅包含可见元素)
2.1 CSSOM树构建
CSS 样式来源有3种:
- 通过 link 引用的外部 CSS 文件
<style>标记内的 CSS- 元素的 style 属性内嵌的 CSS
步骤1:“读懂”CSS。渲染引擎接收到 CSS 文本时,会执行一个转换操作,将 CSS 文本转换为浏览器可以理解的结构——styleSheets(document.styleSheets可以查看所有样式)。
渲染引擎会把获取到的 CSS 文本全部转换为 styleSheets 结构中的数据,并且该结构同时具备了查询和修改功能,这会为后面的样式操作提供基础。
步骤2:转换样式表中的属性值,使其标准化。如 2em、blue、bold,这些类型数值不容易被渲染引擎理解,所以需要将所有值转换为渲染引擎容易理解的、标准化的计算值,这个过程就是属性值标准化。
CSSOM 不是像 DOM 那样“每个元素一个节点”的树,更像一个“规则索引 + 继承图”,是 CSS 规则的扁平列表。
const styles = getComputedStyle(document.querySelector('p'));
console.log(styles.color); // "blue"
📌
getComputedStyle()返回的是基于 CSSOM 计算后的结果,不是原始 CSS。
2.2 计算出 DOM 树中每个节点的具体样式
这就涉及到 CSS 的继承规则和层叠规则了。
CSS 继承就是每个 DOM 节点都会从父节点继承样式。比如,
✅ 可继承的属性(常见):
colorfont-family,font-size,font-weighttext-alignline-heightvisibility
❌ 不可继承的属性:
backgroundwidth/heightmargin/paddingdisplayposition
样式计算过程中的第二个规则是样式层叠。层叠是 CSS 的一个基本特征,它是一个定义了如何合并来自多个源的属性值的算法。权重计算(!important > 内联 > ID > Class > Tag)。
只有会进入 Render Tree 的节点才需要完整样式计算。display: none 的元素和<script>, <meta> 等非可视节点不会计算样式,被 visibility: hidden 隐藏的元素会计算样式,因为它仍占用空间,只是不可见,会进入Render Tree。
- DOM 节点(
Element) ↔ 渲染节点(LayoutObject) 是 1对1 关联(对可见元素) ComputedStyle挂在LayoutObject上,而非Element上,所以JS不可见- JS 可通过
getComputedStyle()返回只读包装对象
何时触发样式计算?
| 事件 | ComputedStyle 行为 |
|---|---|
| 页面首次加载 | 在 Render Tree 构建时为每个可见节点创建 |
| DOM/CSS 修改 | 触发 Style Recalc(样式重计算),仅更新受影响子树 |
| 元素被移除 | 对应的 LayoutObject 和 ComputedStyle 被销毁 |
JS 调用 getComputedStyle() | 若已存在则返回缓存;否则触发计算 |
2.3 优化建议
前面提到CSS解析只阻塞渲染,不阻塞DOM的构建,所以,
- 使用媒体查询分离非关键 CSS(比如,
<link rel="stylesheet" media="print">仅打印时需要的样式),不阻塞屏幕渲染。
特殊地,虽然说CSS解析不阻塞DOM构建,但若是CSS代码后面有JS代码,由于JS代码有可能修改CSS,所以JS代码要等CSS解析完再执行。而JS代码会阻塞DOM构建,于是CSS解析也就变相地阻塞了DOM的构建。
3. 布局(Layout / Reflow)
- 基于前面算出的Render Tree,计算每个元素的几何位置和尺寸(基于盒模型);
- 输出 Layout Tree(Layout Tree和Render Tree是同一棵树,只是多了布局信息)。
// Blink 伪代码
class LayoutObject {
ComputedStyle* style_; // 来自样式计算
LayoutRect frame_rect_; // ← 布局阶段写入
void layout(); // 布局方法, `layout()` 执行后,`frame_rect_` 被赋值
};
- 这个计算阶段就是回流。
3.1 布局计算的本质
布局 = 递归求解一个受 CSS 约束的几何方程组
输入:样式 + 内容 + 容器空间
输出:每个元素的 (x, y, width, height)
| 关键点 | 说明 |
|---|---|
| 基于 Render Tree | 只处理可见元素 |
| 深度优先遍历 | 父 → 子 → 孙 |
| 盒模型为核心 | margin/border/padding/content |
| 不同 display 不同算法 | block/inline/flex/grid/table |
| 可能多轮计算 | table、flex 内容依赖 |
| 性能敏感 | 避免强制同步 layout |
3.2 什么会触发布局计算?
| 操作 | 是否触发 Layout |
|---|---|
✅ 读取 offsetTop, clientWidth, getBoundingClientRect() | 是(强制同步 layout) |
✅ 修改 width, height, padding, margin,border-width | 是 |
✅ 修改 font-size, display, position | 是 |
❌ 修改 background-color, color | 否(只触发 Paint) |
❌ 修改 transform, opacity | 否(由 Compositor 线程处理) |
window.getComputedStyle(el) | 若样式已变,会触发布局 |
el.scrollIntoView() | 可能触发布局以确定位置 |
document.body.offsetHeight | 读取即触发(同 offsetTop) |
💥 频繁触发 layout(尤其在循环中)会导致 “Layout Thrashing”,严重卡顿。
【问题】为什么读取 offsetTop, clientWidth, getBoundingClientRect()会触发Layout计算?
首先,几何信息是“按需计算 + 缓存”的。
class LayoutObject {
// 样式信息
scoped_refptr<const ComputedStyle> style_;
// 几何信息(布局结果)
LayoutRect m_frameRect; // x, y, width, height
int m_clientWidth; // 可能缓存,也可能动态计算
// ... 其他布局相关字段
};
💡offsetTop 等属性通常不缓存,而是基于 m_frameRect 和父链动态计算得出。
offsetTop= 元素顶部到 offsetParent 的距离offsetParent本身可能变化(如父元素position改变)- 因此,即使本元素未变,父链变化也会使
offsetTop失效 → 浏览器必须重新遍历布局树来计算最新值。
💡类似地,getBoundingClientRect() 需要相对于视口(viewport)的绝对坐标,需要从当前元素一路向上累加所有偏移(包括滚动、transform 等),相较于每次滚动都计算一次更新缓存,在实际访问时重新计算一次显然更高效。
💡如果 DOM/CSS 没变,多次读取 clientWidth 不会重复布局。
3.3 浏览器如何优化?—— “Layout Caching”
现代浏览器采用 惰性 + 缓存策略:
| 阶段 | 行为 |
|---|---|
| DOM/CSS 修改 | 标记子树为 “dirty”(不立即重排) |
| JS 读取几何属性 | 检查是否 dirty → 若是,则立即暂停 JS 执行,同步执行 Layout。这就是Forced Synchronous Layout(强制同步重排) |
| Layout 完成后 | 将结果写入 LayoutObject,清除 dirty 标记 |
| 下次读取(无变更) | 直接返回缓存值,不触发布局 |
3.4 何时重排?
- 如果没有 JS 主动读取几何信息,浏览器会等到下一帧渲染时再统一处理(每 ~16.6ms,60fps)
- 此时重排是异步、批量、高效的,不会阻塞 JS
🔔 这也是为什么:
el.style.left = '10px'; el.style.top = '20px'; // ... 多次修改只触发一次重排 —— 浏览器会合并所有变更,在下一帧统一计算。
3.5 最佳实践
- 使用 css3 硬件加速,可以让
transform、opacity、filters这些动画不会引起回流重绘 - 批量 DOM 操作(如用
DocumentFragment) - 对于那些复杂的动画,对其设置
position: fixed/absolute,尽可能地使元素脱离文档流,从而减少对其他元素的影响 - 避免使用
table布局,table中每个元素的大小以及内容的改动,都会导致整个table的重新计算 - 避免在循环中读取布局属性
- 避免在循环中“写样式 → 读几何”交替操作
避免 Layout Thrashing:
// ❌ 错误:每次循环都强制 layout
for (let i = 0; i < boxes.length; i++) {
boxes[i].style.width = boxes[i].clientWidth + 10 + 'px'; // 写 → 读 → 写 → 读...
}
// ✅ 正确:先读,再写
const widths = boxes.map(box => box.clientWidth); boxes.forEach((box, i) => {
box.style.width = (widths[i] + 10) + 'px';
});
记住:
“写(样式)→ 读(几何)” 的交替是性能杀手, 写样式时不要立刻读尺寸,“批量写 → 批量读” 是高效模式。
4. 图层&合成
- 将 Layout Tree 分解为多个 Paint Layers;
- 合成决策
- 生成 Display Items(绘制指令)。
Paint Layer:绘制层,用于组织哪些元素一起绘制,就好比,决定哪些东西画在同一张纸上。
4.1 层叠上下文(Stacking Context)
用于决定 HTML 元素在 Z 轴(垂直于屏幕方向)上的绘制顺序。
简单来说: Stacking Context 就像一个个“独立的绘画画板”,每个画板内部的元素按规则排序,而画板之间也按规则叠加。
🧩4.1.1 为什么需要 Stacking Context?
如果没有层叠上下文,所有元素都平铺在一个全局平面上,直接用z-index 比较各元素的层叠关系。
但现实中:
- 元素可以嵌套;
- 有透明、变换、裁剪等复杂效果;
- 需要局部控制层级,避免全局干扰。
因此,CSS 引入了 “分组隔离” 的思想 —— 每个 stacking context 是一个独立的 z-index 比较域。
📐 4.1.2 Stacking Context 的创建条件
以下任一情况会创建一个新的 stacking context:
| 条件 | 示例 |
|---|---|
| 根元素 | <html> 总是创建根 stacking context |
position值为 absolute或 relative且 z-index值不为 auto 的元素 | position: relative; z-index: 0(注意:z-index: auto 不会创建!) |
position值为 fixed(固定定位)或 sticky(粘滞定位)的元素 | position: fixed |
opacity < 1 | opacity: 0.9 |
transform 非 none | transform: translateX(0) 或 rotate(0deg) |
filter 非 none | filter: blur(2px) |
will-change 指定可创建的属性 | will-change: transform, opacity |
perspective 非 none | perspective: 1000px |
isolation: isolate | 显式创建隔离层叠上下文 |
flex容器的子元素,且 z-index值不为 auto | 在flex和grid布局中,只要子元素的z-index值不是auto,那么它们就会创建一个新的层叠上下文。这意味着这些元素不仅可以在其直接父级的层叠上下文中指定堆叠顺序,而且还可以作为包含自己的层叠上下文的根元素,影响其子元素的堆叠顺序。 |
grid容器的子元素,且 z-index值不为 auto | 见上 |
<canvas> | 其内容由 Canvas API 绘制,与主文档渲染隔离。即使 canvas 没有设置 z-index,它仍能正确参与层叠排序,且内部绘制不受外部 z-index 干扰 |
<video> | 和canvas一样,属于原子级内联元素,浏览器将其视为“不可分割的整体”,并为其创建独立的层叠上下文。 |
<iframe> | 独立文档上下文,强制创建新层叠上下文 |
⚠️ 关键点:仅设置
z-index而不设置position(或其他定位属性)不会创建 stacking context!
🎨 4.1.3 Stacking Context 内部的绘制顺序(7 层规则)
在一个 stacking context 内部,元素按以下固定顺序从底到顶绘制(即使 z-index 相同):
- 背景和边框(background & border of the stacking context root)
- 负 z-index 的子 stacking contexts(
z-index: -1等) - 普通流内块级元素(无定位、无 z-index)
- 浮动元素
- 普通流内行内元素(如文本)
z-index: auto或z-index: 0的定位元素- 正 z-index 的子 stacking contexts(
z-index: 1,2...)
✅ 这就是为什么有时候
z-index: 9999的元素仍被覆盖 —— 它可能在父级 stacking context 的底层,而另一个元素在更高层的 stacking context 中。
🌐 4.1.4 经典问题示例
❌ 问题:z-index 失效
<div class="parent" style="position: relative; z-index: 1;">
<div class="child" style="position: absolute; z-index: 100;">Should be on top</div>
</div>
<div class="sibling" style="position: relative; z-index: 2;">
This covers the child!
</div>
🔍 原因分析:
.parent创建了一个 stacking context(因为z-index: 1);.child的z-index: 100只在 parent 内部有效;.sibling的 stacking context(z-index: 2)整体盖在 parent 的 stacking context(z-index: 1)之上;- 所以
.child无法突破父容器的层叠上下文。
✅ 解决方案:
- 提升
.child到更高层(如用position: fixed); - 或移除
.parent的z-index,让它不创建 stacking context; - 或给
.sibling更低的z-index。
4.2 绘制层
通常满足下面两点中任意一点的元素就可以被提为单独的一个图层。
第一点,拥有层叠上下文属性的元素会被提升为单独的一层。
页面是个二维平面,但是层叠上下文能够让 HTML 元素具有三维概念,这些 HTML 元素按照自身属性的优先级分布在垂直于这个二维平面的 z 轴上。
注意:“提升”在这里不是性能术语,而是“分离绘制上下文”的意思。
第二点,需要剪裁(clip)的地方也会被创建为图层。
比如div内的文字比较多,超出容器的大小,出现裁剪情况的时候,渲染引擎会为文字部分单独创建一个层,如果出现滚动条,滚动条也会被提升为单独的层。参考下图:
🔍 核心规则:
“If an element establishes a stacking context, it gets its own Paint Layer.”
(如果一个元素建立了层叠上下文,它就会获得自己的 Paint Layer。)
这是 Blink 引擎的标准行为,目的是隔离层叠上下文的绘制范围。
4.3 合成决策
Compositing Layer(合成层):是浏览器为了“画得更快”而实现的优化。
📌 关键规律:
只有那些“能从 GPU 加速中受益”的层叠上下文,才会被提升为合成层。
4.3.1 哪些CSS属性会触发“提升”?
| CSS 属性 | 是否创建层叠上下文? | 是否提升为合成层? | 原因 |
|---|---|---|---|
position: relative; z-index: 1 | ✅ 是 | ❌ 通常不会 | 无硬件加速需求 |
position: fixed | ✅ 是 | ✅ 会 | 避免滚动时的重绘 |
opacity: 0.9 | ✅ 是 | ✅ 会 | GPU 可高效处理透明度 |
transform: translateX(0) | ✅ 是 | ✅ 会 | GPU 擅长矩阵变换 |
filter: blur(2px) | ✅ 是 | ✅ 会 | 滤镜需离屏渲染(offscreen) |
will-change: transform | ✅ 是(隐式) | ✅ 会 | 显式提示浏览器提前提升 |
<video>, <canvas> | ✅ 是 | ✅ 会 | 内容由独立引擎生成 |
4.3.2 合成层的内存开销
每个合成层在 GPU 显存(或系统内存)中都存储为一张完整的位图(纹理)(Bitmap / Texture)
为什么需要完整位图?
因为合成层的核心优势是:
“一旦画好,后续只需移动/混合,无需重绘”
为了实现这一点,必须满足:
| 要求 | 说明 |
|---|---|
| 离屏渲染(Offscreen Rendering) | 在独立缓冲区中绘制整个内容 |
| GPU 可直接操作 | 纹理必须是连续的像素块,不能是“指令” |
| 支持任意变换 | transform、opacity 需要完整图像数据 |
→ 所以必须把整个区域光栅化为一张图,哪怕内容很简单(如纯色背景)。
普通绘制:
“每次需要时现场画” → CPU 时间多,内存少合成层:
“提前画好存起来” → 内存多,CPU/GPU 时间少💡 所以: 合成层 = 用内存换时间
4.3.3 Paint Layer vs Compositing Layer
| 特性 | Paint Layer | Compositing Layer |
|---|---|---|
| 目的 | 实现正确的 CSS 层叠顺序 | 性能优化(GPU 合成) |
| 是否必然存在 | ✅ 所有层叠上下文都有 | ❌ 仅部分被“提升” |
| 内存开销 | 较低(主要是指针和元数据) | 较高(需存储纹理) |
| 与主线程关系 | 主线程绘制 | 可 Off-Main-Thread 合成 |
4.4 绘制指令
在决定好有多少个需要绘制的图层后,渲染引擎会对每个图层的绘制拆分成很多小的绘制指令,然后再把这些指令按照顺序组成一个待绘制列表,如下图所示:
你也可以打开“开发者工具”的“Layers”标签,选择“document”层,来实际体验下绘制列表,如下图所示:
5. 绘制(Paint)
5.1 光栅化和 GPU 合成
在这个阶段,Compositor Thread接手工作,负责对各个Composited Layers进行光栅化处理,即将矢量图形转换为像素点。
通常来说,视口只能看到一部份的页面,绘制出所有图层内容会产生不必要的开销。
基于这个原因,合成线程会将图层划分为图块(tile),这些图块的大小通常是 256x256 或者 512x512,如下图所示:
然后合成线程会按照视口附近的图块来优先生成位图,实际生成位图的操作是由栅格化线程来执行的。所谓栅格化,是指将图块转换为位图。而图块是栅格化执行的最小单位。渲染进程维护了一个栅格化的线程池,所有的图块栅格化都是在线程池内执行的。
一旦所有图块都被光栅化,合成线程就会生成一个绘制图块的命令——“DrawQuad”,通过进程间通信提交给 GPU,由 GPU 执行最终绘制并输出到显示器。
由于这部分工作是由Compositor Thread完成的,因此即使主线程繁忙(比如执行JavaScript代码),也不会影响页面的滚动或动画效果。
5.2 完整流程描述:
-
基于 Layout Tree 构建 Paint Layers
→ 为每个层叠上下文、overflow 区域等创建逻辑 PaintLayer。 -
执行 Promotion 决策
→ 将部分 PaintLayer 提升为 GraphicsLayer(合成层)。 -
按 GraphicsLayer 分组执行 Paint
- 主文档(含所有未提升内容)→ 生成 主 Display List
- 每个合成层 → 生成 自己的 Display List
-
光栅化线程将绘制指令转为独立纹理
-
Compositor Thread 将所有纹理组合在一起提交给 GPU
-
GPU 合成最终画面
- 执行合成操作(通常是简单的纹理叠加、矩阵变换);
- 输出最终像素到帧缓冲区(Frame Buffer);
- 显示器从帧缓冲区读取并刷新画面(通常 60Hz)。
5.3 开发者建议
| 场景 | 建议 |
|---|---|
| 需要高性能动画 | 使用 transform + opacity,让元素进入合成层 |
| 避免内存浪费 | 不要滥用 transform: translateZ(0),只在必要时提升 |
| 调试性能问题 | 用 Layers 面板检查是否“层爆炸” |
| 滚动优化 | position: fixed 元素天然受益于合成层 |
合理使用 will-change | 显式告诉浏览器使用合成层,浏览器提前准备OMT资源 |
🎯 最佳实践:
“只为真正需要动画/固定的元素创建合成层。”
📊 总结:Chrome 页面加载四大阶段
| 阶段 | 所属进程 | 关键动作 | 用户感知 |
|---|---|---|---|
| 1. 导航启动 | Browser (UI 线程) | 解析 URL、安全检查、选 Renderer | 地址栏变灰、加载图标出现 |
| 2. 网络请求 | Network Process | DNS → TCP → TLS → HTTP | — |
| 3. 提交导航 | Browser ↔ Renderer | IPC 通信、URL 提交 | 地址栏更新为新 URL |
| 4. 解析构建 | Renderer (主线程) | DOM/CSSOM → Layout → Paint | 页面内容逐步出现 |
理解这四个阶段,不仅能优化 Web 性能(如减少关键路径资源、使用 async/defer),还能深入掌握浏览器的底层工作机制。