前言
文章很长,凡是我觉得好的东西统统都塞进来了。看了很多的文章,有些说法甚至都不统一,所以还动用了AI搜索。总之希望这篇文章能有点用,如有错误,欢迎指正。
浏览器介绍
浏览器的主要组件包括:
- 界面:包括地址栏、后退/前进按钮、书签菜单等。浏览器界面的每个部分,但显示请求网页的窗口除外。
- 浏览器引擎:在界面和渲染引擎之间协调操作。
- 渲染引擎:负责显示请求的内容。例如,如果请求的内容是 HTML,则呈现引擎会解析 HTML 和 CSS,并在屏幕上显示解析的内容。
- 网络:对于 HTTP 请求等网络调用,在平台无关接口后面为不同平台使用不同的实现。
- 界面后端:用于绘制组合框和窗口等基本 widget。此后端公开的接口并非平台专用。在底层,它使用操作系统界面方法。
- JavaScript 解释器。用于解析和执行 JavaScript 代码。
- 数据存储 :这是持久层。浏览器可能需要在本地保存各种数据,例如 Cookie。浏览器还支持 localStorage、IndexedDB、WebSQL 和 FileSystem 等存储机制。
注意:Chrome 等浏览器会运行多个渲染引擎实例,每个标签页一个,每个标签页都在单独的进程中运行。
渲染引擎
不同的浏览器使用不同的渲染引擎:Internet Explorer 使用 Trident、Firefox 使用 Gecko、Safari 使用 WebKit。Chrome 和 Opera(从 15 版开始)使用 Blink ,即 WebKit 的一个分支。
WebKit 是一个开源渲染引擎,最初是 Linux 平台的引擎,后来被 Apple 修改为支持 Mac 和 Windows。
渲染流程
渲染引擎将开始从网络层获取请求的文档内容,通常是分块( 8KB)接收的。之后,渲染引擎的基本流程如下:
现代浏览器的渲染过程,最简单的描述就是解析-构建-布局-绘制。它会经历以下几个主要阶段:
- 解析 HTML 构建 DOM 树 :
- 浏览器从上到下解析 HTML 文档,创建文档对象模型(DOM)树。DOM 树表示了页面的结构和层次关系。
- 加载外部资源 :
- 在解析 HTML 过程中,浏览器会发现对 CSS 文件、JavaScript 文件、图片等外部资源的引用。浏览器会发起额外的请求来获取这些资源。
- 解析 CSS 构建 CSSOM :
- 浏览器解析加载的 CSS 文件,构建 CSS 对象模型(CSSOM)。CSSOM 包含了页面中各个元素的样式信息。
- 构建渲染树 :
- 渲染树是 DOM 树和 CSSOM 的结合,它包含了所有需要渲染的页面元素及其样式信息。这个过程决定了每个元素在页面上的位置和外观。
- 布局(重排) :
- 浏览器根据渲染树计算每个元素的几何信息(如位置、大小),这个过程称为布局或重排。
- 绘制(重绘) :
- 浏览器将渲染树中的元素绘制到屏幕上,包括填充背景色、绘制文本、边框、图片等。
更具体一点的流程是:
- 解析阶段:
- 解析 HTML,生成 DOM 树。
- 解析 CSS,生成 CSSOM 树。
- 合并 DOM 树和 CSSOM 树,生成渲染树。
- 布局阶段:
- 根据渲染树计算每个元素的几何信息(位置和大小)。
- 分层阶段:
- 将渲染树分成多个图层,生成图层树(Layer Tree)。
- 绘制阶段 :
- 绘制每个图层的内容,生成绘制指令。
- 光栅化阶段:
- 将绘制指令转换为位图(Rasterization),通常由 GPU 加速完成。
- 合成阶段:
- 合成线程将所有图层的图块组合成最终的屏幕图像。
- 显示阶段:
- 将合成后的图像呈现到屏幕上。
以上是一个渐进的过程,为了提供更好的用户体验,渲染引擎会尝试尽快在屏幕上显示内容。它不会等到所有 HTML 都解析完毕后才开始构建和布局渲染树。系统会解析并显示部分内容,同时继续处理不断从网络传入的其余内容。
WebKit主要流程示例:
解析
浏览器的渲染流程从解析开始,解析是将 HTML 和 CSS 转换为浏览器可以理解的内部数据结构的过程。解析阶段是整个渲染流程的基础,直接影响后续的布局、绘制和显示。
解析基础:
解析文档意味着将其转换为代码可以使用的结构 。解析结果通常是表示文档结构的节点树。这称为解析树或语法树。
例如,解析表达式 2 + 3 - 1 可能会返回以下树:
语法
解析基于文档遵循的语法规则:文档所用的语言或格式。您可以解析的每种格式都必须具有由词汇和语法规则组成的确定性语法。这种语言称为无上下文语法。
解析器 - 词法分析器组合
解析可以分为两个子过程:词法分析和语法分析。
词法分析是将输入内容拆分为词元的流程。令牌是语言词汇:一系列有效的构建块。用人类语言来说,它包含该语言字典中显示的所有字词。
语法分析是指应用语言语法规则。
解析器通常会将工作分为两个部分:负责将输入拆分为有效令牌的词法分析器(有时称为"分词器"),以及负责根据语言语法规则分析文档结构以构建解析树的解析器。
解析器知道如何移除空格和换行符等无关紧要的字符。
解析过程是迭代的。解析器通常会向词法分析器请求新的令牌,并尝试将令牌与某个语法规则进行匹配。如果匹配到规则,系统会将与令牌对应的节点添加到解析树中,解析器会请求另一个令牌。
如果没有匹配的规则,解析器将在内部存储令牌,并不断请求令牌,直到找到与内部存储的所有令牌匹配的规则。如果未找到任何规则,解析器将引发异常。这意味着该文档无效且包含语法错误。
在许多情况下,解析树不是最终产品。解析通常用于翻译:将输入文档转换为其他格式。例如编译。将源代码编译为机器代码的编译器会先将其解析为解析树,然后将该树转换为机器代码文档。
解析器类型
解析器有两种类型:自上而下(LL)解析器 和自下而上(LR)解析器(又被称"移位-规约"解析器)。
-
自上而下的解析器会检查语法的宏观结构,并尝试找到匹配的规则。按照从左到右的顺序扫描输入,并从左推导生成语法树。它适用于上下文无关文法,通常使用递归下降的方式实现。
示例:plaintextE -> T E' // 含义:E 是一个表达式,由一个 T(项)和一个 E'(表达式的后续部分)组成 E' -> + T E' | ε // 含义:E' 是表达式的后续部分,可以是:一个加号 +,后面跟一个 T(项)和另一个 E'(递归定义);或者是空(ε 表示空产生式,表示没有更多的内容) T -> int // 含义:T 是一个项,表示一个整数。
代码实现:
javascriptclass LLParser { constructor(tokens) { this.tokens = tokens; // 输入的标记列表 this.pos = 0; // 当前标记的位置 } parse() { return this.E(); // 从非终结符 E 开始解析 } E() { // E -> T E' const node = { type: "E", children: [] }; node.children.push(this.T()); node.children.push(this.EPrime()); return node; } EPrime() { // E' -> + T E' | ε if (this.match("+")) { const node = { type: "E'", children: [] }; this.consume("+"); node.children.push(this.T()); node.children.push(this.EPrime()); return node; } else { return { type: "E'", children: ["ε"] }; // 空产生式 } } T() { // T -> int if (this.match("int")) { const node = { type: "T", value: this.consume("int") }; return node; } else { throw new Error("Expected 'int'"); } } match(tokenType) { // 检查当前标记是否匹配 return this.pos < this.tokens.length && this.tokens[this.pos] === tokenType; } consume(tokenType) { // 消耗当前标记并移动到下一个 if (this.match(tokenType)) { const token = this.tokens[this.pos]; this.pos++; return token; } else { throw new Error(`Expected ${tokenType}`); } } } // 示例输入 const tokens = ["int", "+", "int", "+", "int"]; const parser = new LLParser(tokens); const syntaxTree = parser.parse(); console.log(JSON.stringify(syntaxTree, null, 2)); /* 输出:{ "type": "E", "children": [ { "type": "T", "value": "int" }, { "type": "E'", "children": [ { "type": "T", "value": "int" }, { "type": "E'", "children": [ { "type": "T", "value": "int" }, { "type": "E'", "children": [ "ε" ] } ] } ] } ] } */
-
自下而上解析器从输入开始,从低级规则开始逐步转换为语法规则,直到满足高级规则。按照从左到右的顺序扫描输入,并从右推导生成语法树。它适用于更复杂的上下文无关文法,通常使用移入-归约的方式实现。
示例:E -> E + T | T T -> int
代码实现:
javascript// LR(0)分析器 class LRParser { constructor(tokens) { // 初始化输入 token 流(末尾加 $ 结束符) this.tokens = [...tokens, "$"]; // 初始化状态栈 stack,初始状态为 0 this.stack = [0]; // 初始化语法树节点栈 treeStack this.pos = 0; // 初始化当前读取位置 pos this.treeStack = []; } getAction(state, token) { const actionTable = { "0,int": { action: "shift", to: 2 }, "2,+": { action: "shift", to: 3 }, "2,$": { action: "reduce", prod: "E -> int" }, "1,+": { action: "shift", to: 4 }, "1,$": { action: "accept" }, "3,int": { action: "shift", to: 5 }, "4,int": { action: "shift", to: 5 }, "5,+": { action: "reduce", prod: "E -> int" }, "5,$": { action: "reduce", prod: "E -> int" }, "4,$": { action: "reduce", prod: "E -> E + E" }, "4,+": { action: "reduce", prod: "E -> E + E" }, }; return actionTable[`${state},${token}`] || { action: "error" }; } getGoto(state, symbol) { const gotoTable = { "0,E": 1, "3,E": 4, "4,E": 4, }; return gotoTable[`${state},${symbol}`]; } getProductions(prod) { const productions = { "E -> int": { rhsLen: 1, build: "E -> int" }, "E -> E + E": { rhsLen: 3, build: "E -> E + E" } }; return productions[prod]; } // 移进操作:将当前 token 和目标状态压入栈,并为 token 创建语法树叶子节点,推进输入指针 shift(token, to) { this.stack.push(token); this.stack.push(to); // 构造终结符叶子节点 this.treeStack.push({ type: token, value: token }); this.pos++; } // 规约操作:根据产生式弹出相应的栈元素,将非终结符压入栈,并调用 buildTree 构建语法树节点,然后查 GOTO 表压入新状态 reduce(prod) { const p = this.getProductions(prod); if (!p) throw new Error("未知产生式: " + prod); for (let i = 0; i < p.rhsLen * 2; i++) this.stack.pop(); const prevState = this.stack[this.stack.length - 1]; this.stack.push("E"); this.buildTree(p.build); const goto = this.getGoto(prevState, "E"); if (goto !== undefined) { this.stack.push(goto); } else { throw new Error("GOTO错误"); } } // 语法树构建 buildTree(prod) { if (prod === "E -> int") { const intNode = this.treeStack.pop(); this.treeStack.push({ type: "E", children: [intNode] }); } else if (prod === "E -> E + E") { const right = this.treeStack.pop(); const plus = this.treeStack.pop(); const left = this.treeStack.pop(); this.treeStack.push({ type: "E", children: [left, plus, right] }); } } // 解析函数:循环读取 token,查 action 表,执行相应操作 parse() { const maxSteps = 100; for (let step = 0; step < maxSteps; step++) { const state = this.stack[this.stack.length - 1]; const token = this.tokens[this.pos]; const act = this.getAction(state, token); if (act.action === "shift") { this.shift(token, act.to); } else if (act.action === "reduce") { this.reduce(act.prod); } else if (act.action === "accept") { console.log("解析成功!"); return this.treeStack[0]; } else { throw new Error("解析失败"); } } throw new Error("解析步骤过多,可能死循环"); } } // 示例输入 const tokens = ["int", "+", "int"]; const parser = new LRParser(tokens); const syntaxTree = parser.parse(); console.log(JSON.stringify(syntaxTree, null, 2)); // 输出: /* 解析成功! { "type": "E", "children": [ { "type": "int", "value": "int" }, { "type": "+", "value": "+" }, { "type": "E", "children": [ { "type": "int", "value": "int" } ] } ] } */
LL 和 LR 的对比:
特性 | LL解析器 | LR解析器 |
---|---|---|
解析方式 | 自顶向下 | 自底向上 |
适用文法 | 简单的上下文无关文法 | 更复杂的上下文无关文法 |
实现难度 | 相对简单 | 相对复杂 |
解析过程 | 递归下降或预测分析 | 移入-归约或状态机 |
常见实现 | 手写递归下降解析器 | 自动生成的 LR 解析器(如 YACC) |
WebKit 使用两个众所周知的解析器生成器:Flex 用于创建词法分析器,Bison 用于创建解析器(您可能会遇到名称为 Lex 和 Yacc 的解析器)。Flex 输入是包含令牌正则表达式定义的文件。Bison 的输入是 BNF 格式的语言语法规则。
html解析
定义 HTML 的正式格式是 DTD(文档类型定义),它不是无上下文语法。HTML 无法轻松地通过解析器所需的无上下文语法进行定义,上面提到的解析方式(LL和LR)都不适用于它。浏览器会创建自定义解析器来解析 HTML,解析算法一般包含两个阶段:令牌化和树构建。
词元化是词法分析,用于将输入解析为词元。HTML 令牌包括起始标记、结束标记、属性名称和属性值。
分词器会识别令牌,将其传递给树构造函数,并使用下一个字符来识别下一个令牌,以此类推,直到输入结束。
令牌化算法的输出是 HTML 令牌。 该算法以状态机的形式表示。每个状态都会使用输入流中的一个或多个字符,并根据这些字符更新下一个状态。此决策会受到当前的令牌化状态和树构建状态的影响。这意味着,对于正确的下一个状态,相同的已消耗字符会产生不同的结果,具体取决于当前状态。 该算法过于复杂,无法完整描述,因此我们来看一个简单的示例,以便了解其原理。
基本示例 - 对以下 HTML 进行令牌化:
<html>
<body>
Hello world
</body>
</html>
初始状态为"数据状态"。 遇到 < 字符时,状态会更改为"标记处于打开状态"。使用 a-z 字符会导致创建"开始标记令牌",状态会更改为"标记名称状态"。我们会一直保持此状态,直到 > 字符被消耗完为止。每个字符都会附加到新令牌名称后面。在本例中,创建的令牌是 html 令牌。
达到 > 标记后,系统会发出当前令牌,并且状态会恢复为"数据状态"。系统会按照相同的步骤处理 标记。到目前为止,系统已发出 html 和 body 标记。现在,我们返回到"数据状态"。 使用 Hello world 的 H 字符会导致创建并发送字符令牌,此过程会持续到达到 的 <。我们将为 Hello world 的每个字符发出一个字符令牌。
现在,我们回到"代码处于打开状态"。 使用下一个输入 / 会导致创建 end tag token 并移至"标记名称状态"。再次强调一下,我们会一直保持此状态,直到达到 >。然后,系统会发出新的代码令牌,我们会返回到"数据状态"。 系统会将 输入视为前面的示例。
构建DOM树
DOM构建是增量的。
处理 HTML 标记并构造 DOM 树 。HTML
解析包括标记化(令牌化)和树结构构建 。单个 DOM
节点以起始标签标记(startTag)开始,以结束标签标记(endTag)结束。节点包含有关 HTML
元素的(使用标记描述的)所有相关信息。节点根据标记的层次结构连接到 DOM
树中。如果一组起始标签标记和一组结束标签标记之间又有一组起始标签标记和一组结束标签标记,那么就会出现节点内有节点的情况,这就是我们定义 DOM
树层次结构的方法。++HTML 标记包括开始和结束标记,以及属性名和值++ 。如果文档格式良好,则解析它会简单而快速。解析器将标记化的输入解析到文档中,构建文档树。
创建解析器时,系统会创建 Document
对象。在树构建阶段,系统会修改根目录中包含文档的 DOM 树,并向其中添加元素。分词器发出的每个节点都将由树构造函数处理。对于每个令牌,规范会定义哪些 DOM 元素与其相关,以及将为此令牌创建哪些 DOM 元素。该元素会添加到 DOM 树和打开的元素堆栈(此堆栈用于更正嵌套不匹配和未闭合标记)中。 该算法还可描述为状态机。这些状态称为"插入模式"。
我们来看看示例输入的树构建过程:
<html>
<body>
Hello world
</body>
</html>
树构建阶段的输入是来自令牌化阶段的一系列令牌。第一种模式是"初始模式"。收到"html"令牌将导致系统切换到"html 之前"模式,并在该模式下重新处理令牌。这将导致创建 HTMLHtmlElement 元素,该元素将附加到根 Document 对象。
状态将更改为"在 head 之前"。然后,系统会收到"body"令牌。系统会隐式创建 HTMLHeadElement,即使我们没有"head"令牌,它也会被添加到树中。
现在,我们将进入"在头部前面"模式,然后进入"在头部后面"模式。系统会重新处理正文令牌,创建并插入 HTMLBodyElement,并将模式转换为"in body"。
现在,系统会收到"Hello world"字符串的字符令牌。第一个字符会导致创建并插入"文本"节点,其他字符会附加到该节点。
收到正文结束令牌后,系统会转换为"正文后"模式。现在,我们将收到 html 结束标记,这会将我们转换到"body 后"模式。收到文件结束令牌后,解析将结束。
在此阶段,浏览器会将文档标记为交互式,并开始解析处于"延迟"模式的脚本:这些脚本应在文档解析完毕后执行。然后,文档状态将设为"complete",并触发"load"事件。
总结:
DOM 树描述了文档的内容。<html>
元素是第一个标签也是文档树的根 节点。++树反映了不同标记之间的关系和层次结构++ 。嵌套在其他标记中的标记是子节点。DOM 节点的数量越多,构建 DOM 树所需的时间就越长。
当解析器发现非阻塞资源,例如一张图片,浏览器会请求这些资源并且继续解析。当遇到一个 CSS 文件时,解析也可以继续进行,但是对于 <script>
标签(特别是没有 async
或者 defer
属性的)会阻塞渲染并停止 HTML 的解析。尽管浏览器的预加载扫描器加速了这个过程,但过多的脚本仍然是一个重要的瓶颈。
DOM树构建过程主要涉及以下步骤:(简单来说就是:字节 → 字符 → 标记化 → 节点 → 对象模型)
-
字节转换字符 :浏览器通过网络进程从服务器接收 HTML 文件,接收到的数据是字节流 (
Byte Stream
)。再根据HTTP 响应头中的Content-Type
和charset
指定的编码方式(如 UTF-8、ISO-8859-1 等),将字节流解码为字符流 (Character Stream
)(如果没有指定编码,浏览器会尝试自动检测编码方式)。 -
词法分析 :浏览器的 HTML 解析器会对字符流进行词法分析,将其分解为一个个标记(Token),如标签
<div>
、属性class="example"
和文本节点。解析器会识别这些标记的类型(如开始标签、结束标签、文本等)。标记是 HTML 的基本组成部分,包括:- 开始标签(Start Tag): 如
<div>
- 结束标签(End Tag): 如
</div>
- 自闭合标签(Self-closing Tag): 如
<img />
- 文本节点(Text Node): 如
Hello World
- 注释(Comment): 如
<!-- This is a comment -->
- 开始标签(Start Tag): 如
-
标记化(Tokenization) :HTML 解析器会根据 HTML 规范,将字符流转换为标记流。例如:
<div class="example">Hello</div>
会被标记为:<div>
(开始标签)class="example"
(属性)Hello
(文本节点)</div>
(结束标签)
-
语法分析(Syntax Analysis) :浏览器根据 HTML 的语法规则,将标记流解析为节点(Node)。节点是 DOM 树的基本单位,包括:
- 元素节点(Element Node): 对应 HTML 标签。
- 文本节点(Text Node): 对应 HTML 中的文本内容。
- 属性节点(Attribute Node): 对应 HTML 标签的属性。
-
构建 DOM 树 :浏览器会根据标记的嵌套关系,将节点组织成一棵树状结构,即 DOM 树。例如:
javascript<div> <p>Hello</p> </div>
会生成如下dom树:
div
└── p
└── "Hello" -
增量构建:HTML 是流式解析的,浏览器在接收到部分 HTML 内容时就会开始解析并构建 DOM 树,而不是等待整个文件下载完成。这使得页面可以逐步呈现,提高用户体验。
整个过程的最终输出是简单网页的文档对象模型 (DOM
),浏览器会使用该模型对网页进行所有后续处理。 下图为流程示例:
每次浏览器处理 HTML
标记 时,都会完成之前定义的所有步骤:将字节转换为字符、识别令牌、将令牌转换为节点,以及构建 DOM
树 。整个过程可能需要一些时间,尤其是在我们有大量 HTML
需要处理时。
预加载扫描器(preload scanner)
浏览器构建 DOM
树时,这个过程占用了主线程。同时,预加载扫描器会解析可用的内容并请求高优先级的资源 ,如 CSS
、JavaScript
和 web
字体。因为有了预加载扫描器,我们不必等到解析器找到对外部资源的引用时才去请求。它将在后台检索资源,而当主 HTML
解析器解析到要请求的资源时,它们可能已经下载中了,或者已经被下载。预加载扫描器提供的优化减少了阻塞。
javascript
<link rel="stylesheet" href="styles.css" />
<script src="myscript.js" async></script>
<img src="myimage.jpg" alt="图像描述" />
<script src="anotherscript.js" async></script>
在这个例子中,当主线程在解析 HTML
和 CSS
时,预加载扫描器将找到脚本和图像,并开始下载它们。为了确保脚本不会阻塞进程,当 JavaScript
解析和执行顺序不重要时,可以添加 async
属性或 defer
属性。
等待获取 CSS
不会阻塞 HTML
的解析或者下载,但是它确实会阻塞 JavaScript
,因为 JavaScript
经常用于查询元素的 CSS
属性。
构建 CSSOM 树
处理 CSS
并构建 CSSOM 树。CSS
对象模型和 DOM
是相似的。DOM
和 CSSOM
是两棵树。它们是独立的数据结构。浏览器将 CSS
规则转换为可以理解和使用的样式映射 。浏览器遍历 CSS
中的每个规则集,根据 CSS
选择器创建具有父、子 和兄弟关系的节点树。
与 HTML
类似,浏览器需要将接收到的 CSS
规则转换为可处理的格式。因此,它重复了 HTML
到对象的过程,但这次是针对 CSS
。CSS
字节会被转换为字符,然后被转换为令牌,然后被转换为节点,最后它们会关联到一个被称为"CSS
对象模型"(CSSOM) 的树结构:
CSSOM
树包括来自用户代理样式表的样式。浏览器++从适用于节点的最通用规则开始++ (例如 body 的子元素可以应用所有 body 样式),++然后通过应用更具体的规则递归地优化计算的样式。++ 换句话说,它级联属性值(级联指的是多个样式规则同时作用于同一个元素时,根据优先级和特定的规则来确定最终应用的样式)。
其他过程
JavaScript
编译
在解析CSS
和创建CSSOM
的同时,包括JavaScript
文件在内的其他资源也在下载(这要归功于预加载扫描器)。JavaScript
会被解析、编译和解释。脚本被解析为抽象语法树 。有些浏览器引擎会将抽象语法树输入编译器,输出字节码。这就是所谓的JavaScript
编译。大部分代码都是在主线程上解释的,但也有例外,例如在 web worker 中运行的代码。- 构建无障碍树
浏览器还构建辅助设备用于分析和解释内容的无障碍树。无障碍对象模型(AOM
)类似于DOM
的语义版本。当DOM
更新时,浏览器会更新辅助功能树。辅助技术本身无法修改无障碍树。
在构建AOM
之前,屏幕阅读器无法访问内容。
构建渲染树
在解析步骤中创建的 CSSOM
树和 DOM
树组合成一个渲染树(Firefox 将渲染树中的元素称为"帧",WebKit 使用"渲染程序"或"渲染对象"一词),然后用于计算每个可见元素的布局,最后将其绘制到屏幕上。在某些情况下,可以将内容提升到它们自己的层并进行合成,通过在 GPU 而不是 CPU 上绘制屏幕的一部分来提高性能,从而释放主线程。
为构建渲染树,浏览器大致执行以下操作:
- 从 DOM 树的根节点开始遍历每个可见节点。
- 非可视 DOM 元素不会插入到渲染树中(例如脚本标记、元标记等),因为它们不会体现在渲染输出中,所以会被忽略。
- 某些节点使用 CSS 隐藏,因此在渲染树中也会被忽略,显示值被分配为"none"的元素不会显示在树中(而"hidden"可见性的元素会显示在树中)。
- 对于每个可见节点,为其找到适配的
CSSOM
规则并应用它们。渲染树包含所有可见节点的内容和计算样式,将所有相关样式与 DOM 树中的每个可见节点匹配起来,并根据CSS
级联,确定每个节点的计算样式。 - 发出带有内容及其计算样式的可见节点
布局(Layout)
渲染树构建完毕后,浏览器就开始布局 (布局有时也称为"自动重排"或重新流布局。它是是查找元素几何图形的过程,主线程会遍历 DOM 和计算样式,并创建包含 x、y 坐标和边界框大小等信息的布局树 )。
++渲染树标识了哪些节点会显示(即使不可见)及其计算样式,但不标识每个节点的尺寸或位置。为了确定每个对象的确切大小和位置,浏览器会从渲染树的根开始遍历。++
第一次确定每个节点的大小和位置称为布局。随后对节点大小和位置的重新计算称为重排。
++布局是一个递归过程++ 。它从根渲染程序开始,该渲染程序对应于 HTML 文档的 <html>
元素。布局会继续递归地遍历部分或全部帧层次结构,为需要的每个渲染程序计算几何信息。
根渲染程序的位置为 0,0,其尺寸为视口(浏览器窗口的可见部分)。
所有渲染程序都具有"布局"或"重新布局"方法,每个渲染程序都会调用其需要布局的子项的布局方法。
布局是一个相对耗时的操作,特别是在处理大型复杂页面时。为了提高性能,浏览器会使用一些优化策略,例如增量更新、异步布局等。同时,开发者也可以通过优化CSS样式、减少DOM操作和避免频繁修改尺寸等方式来提升页面渲染性能。
脏位系统
为了避免对每项细微更改都进行完整布局,浏览器使用"脏位"系统。更改或添加的渲染程序会将自身及其子项标记为"脏":需要布局。
有两个标记:"脏"和"子项脏",这意味着,虽然渲染程序本身可能没问题,但它至少有一个子项需要布局。
全局布局和增量布局
布局可在整个渲染树上触发 - 这是"全局"布局。造成这种情况的原因可能是:
- 会影响所有渲染程序的全局样式更改,例如字号更改。
- 由于屏幕大小调整
布局可以是增量布局,只有脏渲染程序才会进行布局(这可能会导致一些损坏,需要额外的布局)。
当渲染程序脏时,系统会触发增量布局(异步)。例如,当额外内容从网络传入并添加到 DOM 树后,将新的渲染程序附加到渲染树中时。增量布局 - 仅排列脏渲染程序及其子项示例图:
异步布局和同步布局
增量布局是异步完成的。Firefox 会为增量布局队列"重新流式传输命令",并且调度程序会触发这些命令的批量执行。WebKit 还有一个用于执行增量布局的计时器,它会遍历树并布局"脏"渲染程序。
请求样式信息(例如"offsetHeight")的脚本可以同步触发增量布局。
全局布局通常会同步触发。
有时,由于某些属性(例如滚动位置)发生变化,布局会在初始布局后作为回调触发。
渲染树(render tree)和布局树(layout tree)的区别:
特性 | 渲染树 | 布局树 |
---|---|---|
定义 | 描述页面可见元素及其样式 | 定义每个可见元素的几何位置和尺寸 |
构建过程 | 结合 DOM 树和 CSSOM 树,过滤掉不可见元素 | 基于渲染树,进行几何计算 |
包含的节点 | 包含所有可见的 DOM 元素 | 包含需要进行几何位置和尺寸计算的元素 |
作用 | 计算元素的样式和可见性 | 计算元素的确切位置和尺寸 |
性能影响 | 构建渲染树会受 CSS 的影响,复杂的样式计算会影响性能 | 布局过程(重排)通常比样式计算更耗时,因为它涉及几何计算 |
核心区别:
对比维度 | 渲染树(Render Tree) | 布局树(Layout Tree) |
---|---|---|
作用阶段 | 样式计算后,布局前 | 布局阶段 |
内容组成 | 可见节点 计算样式 | 节点几何信息(位置、尺寸) |
动态性 | 样式变化时重建 | 几何属性变化时重建 |
常见误解澄清 :
-
误区 :"渲染树即布局树"
正解:渲染树是布局树的输入,布局树是渲染树的几何扩展。两者在流程中串联,但功能分离。 -
误区 :"所有样式变化都会触发布局"
正解:仅几何属性(如宽度、位置)触发布局;颜色、透明度等仅触发绘制(Paint)。
总结:
渲染树 负责整合DOM和CSSOM的可视化信息,而布局树专注于几何计算。两者共同构成浏览器渲染的核心流程,但职责分明。理解其差异有助于优化网页性能(如减少不必要的布局操作)。
分层(Layer)
分层是渲染过程中非常重要的一步,它决定了页面的哪些部分需要单独处理。将内容提升到 GPU 上的层(而不是 CPU 上的主线程)可以提高绘制和重新绘制性能,还可以减少不必要的重绘和布局计算,并允许浏览器对每个图层进行独立处理和优化。
浏览器通过以下规则决定是否为元素创建独立图层:
条件 | 示例 | 底层逻辑 |
---|---|---|
层叠上下文(Stacking Context) | position: fixed 、position: sticky 、position: absolute; 、z-index 、opacity < 1 |
确保层叠顺序,避免与其他元素混合计算。 |
需裁剪(Clip)的区域 | 内容溢出容器(如长文本超出overflow: hidden 的div ) |
独立处理裁剪部分,避免污染其他区域。 |
硬件加速需求 | transform: translateZ(0) 、will-change: transform |
强制提升至GPU层,绕过主线程计算。 |
视频/Canvas元素 | Flash、<video> 、<canvas> |
原生需独立解码或绘制,默认分层。 |
示例 :
若一个元素设置position: fixed
,浏览器会为其创建独立图层,滚动时仅移动图层位置,无需重绘内容。
分层的好处:
- 独立更新:分层后,当某个图层的内容发生变化时,浏览器只需重新合成该图层,而不需要重新渲染整个页面。
- 硬件加速:一些图层可以利用 GPU 进行加速渲染,提高性能。
- 简化重排:分层减少了重排的范围,因为每个图层可以独立处理其内部的布局变化。
分层的缺点:每个图层占用额外内存(尤其是大尺寸图层),过度分层可能导致内存瓶颈。
总结:
阶段 | 分层(Layer) |
---|---|
核心目标 | 隔离变化区域,减少重绘范围 |
性能影响 | 图层数量影响内存占用 |
优化手段 | 按需提升、合并冗余图层 |
触发条件 | 层叠上下文、裁剪、硬件加速声明 |
分层的结果是生成一棵 图层树(Layer Tree),它与渲染树类似,但只包含需要单独处理的图层信息。
生成绘制指令(Painting Commands)
分层完成后,主线程为每个图层生成绘制指令列表(Paint Instructions)。绘制指令 是描述如何绘制每个图层内容的操作序列,包含了绘制图形、文本、图像、边框、阴影等的具体步骤。它是光栅化(Rasterization)的输入,被存储在绘制队列中,供后续的光栅化阶段使用。通过生成绘制指令,浏览器可以将页面内容转换为 GPU 或 CPU 可处理的格式。例如:
javascript
// 示例绘制指令
[
{ type: 'rect', x: 0, y: 0, width: 100, height: 100, color: '#ff0000' },
{ type: 'text', x: 10, y: 50, content: 'Hello', font: '14px Arial' }
]
关键特点:
- 非像素操作:仅记录绘制命令,实际光栅化由合成线程完成。
- 层级隔离:各图层的绘制指令互不影响,并行处理。
绘制指令的生成过程:
-
遍历图层树:
- 浏览器遍历每个图层,确定需要绘制的内容
- 例如:背景颜色、文本、边框、图像等
-
生成绘制列表:
- 每个图层的内容被描述为一系列绘制操作,存储在绘制列表中
- 例如:
- 绘制矩形:
drawRect(x, y, width, height, color)
- 绘制文本:
drawText(x, y, font, text, color)
- 绘制图像:
drawImage(x, y, width, height, image)
- 绘制矩形:
-
优化绘制顺序:
- 浏览器会对绘制指令进行优化,减少不必要的绘制操作。
- 例如:合并相邻的绘制区域,避免重复绘制。
优化绘制指令的生成:
- 减少复杂的绘制操作:
- 避免使用复杂的阴影、渐变和剪裁效果。
- 使用独立图层:
- 对频繁变化的元素(如动画)使用
transform
或will-change
,将其提升为独立图层。
- 对频繁变化的元素(如动画)使用
- 避免不必要的重绘:
- 使用
opacity
和transform
创建动画,而不是直接修改布局属性(如width
和height
)。
- 使用
在生成绘制指令后,渲染主线程的工作基本完成,后续的工作由其他线程(如合成线程和光栅化线程)完成:
1. 合成线程(Compositor Thread)
- 职责:
- 合成线程接收渲染主线程生成的绘制指令。
- 将每个图层分割成多个图块(Tiles)。
- 确定哪些图块需要绘制(通常是当前屏幕可见区域的图块)。
- 将绘制任务交给光栅化线程。
- 优势:
- 合成线程独立于渲染主线程运行,可以在主线程忙于处理 JavaScript 或布局时继续工作。
- 这使得滚动和动画更加流畅。
2. 光栅化线程(Rasterization Thread)
- 职责:
- 光栅化线程将合成线程分割的图块转换为位图(Bitmap)。
- 光栅化通常由 GPU 加速完成,以提高性能。
3. GPU 合成和显示
- 职责:
- GPU 将光栅化生成的位图合成到屏幕上。
- 合成线程负责将所有图层的图块组合成最终的屏幕图像。
浏览器页面渲染过程可以总结为:
渲染主线程负责解析
HTML
和CSS
,生成渲染树,计算布局,分层,并为每个图层生成绘制指令集。生成绘制指令后,合成线程接管工作,将图层分块并交由光栅化线程进行光栅化,最终由 GPU 合成图像并显示到屏幕上。
光栅化(Rasterization)
在现代浏览器的渲染过程中,分块(Tiling) 和 光栅化(Rasterization) 是优化绘制性能的重要步骤。这两个过程主要发生在 绘制(Painting) 和 合成(Compositing) 阶段,用于将页面内容高效地转换为屏幕上的像素。
分块(Tiling)
分块是将页面的每个图层(Layer)分割成多个小的矩形区域(称为"图块"或"Tile")。这些图块通常是固定大小的,例如 256x256 像素。
分块原因:
- 提高渲染效率:
- 页面可能包含大量内容,直接处理整个图层的绘制会非常耗时。
- 分块允许浏览器只处理当前屏幕可见区域的图块,而无需绘制整个页面。
- 支持增量更新:
- 当页面内容发生变化时,只需要重新绘制受影响的图块,而不是整个图层。
- 优化内存使用:
- 分块可以将大图层分解为小块,便于在内存中管理和缓存。
分块的过程:
- 图层分割:
- 每个图层被分割成多个固定大小的图块。
- 图块的大小通常由浏览器决定(例如 256x256 像素),以平衡性能和内存使用。
- 图块管理:
- 浏览器会优先处理当前屏幕可见区域的图块。
- 不可见区域的图块可能会被延迟处理,甚至被丢弃以节省内存。
分块的优势:
- 按需绘制: 只绘制当前屏幕可见的图块,减少不必要的绘制工作。
- 并行处理: 图块可以在多个线程或 GPU 上并行处理,提高渲染速度。
- 滚动优化: 当用户滚动页面时,只需要加载新的图块,而无需重新绘制整个页面。
光栅化(Rasterization)
光栅化是将矢量图形(如 HTML 元素、CSS 样式、SVG 等)转换为位图(Bitmap)的过程。位图是由像素组成的图像,最终会被显示在屏幕上。转换过程通过线程池 + GPU加速优化:
plaintext
+-------------------+ +-------------------+
| 图层A | | 图层B |
| - 图块1 (视口内) | | - 图块1 |
| - 图块2 | | - 图块2 (视口内) |
+-------------------+ +-------------------+
↓ ↓
+-------------------------------+
| GPU光栅化队列 |
| - 优先处理视口内图块 |
+-------------------------------+
光栅化原因:
- 屏幕只能显示像素,而 HTML 和 CSS 描述的是矢量图形。
- 光栅化将矢量图形转换为屏幕可显示的像素数据。
光栅化的过程:
- 绘制指令生成:
- 在绘制阶段,浏览器生成绘制指令(Painting Commands),描述如何绘制每个图块的内容。
- 例如:绘制矩形、文本、图像、边框等。
- 光栅化执行:
- 光栅化线程(通常是 GPU)根据绘制指令,将每个图块转换为位图。
- 位图是一个二维像素数组,每个像素包含颜色和透明度信息。
- 存储和传输:
- 光栅化后的位图会被存储在 GPU 的内存中,供合成线程使用。
优化策略:
- 视口优先:优先光栅化用户可见区域(Viewport)的图块。
- 增量更新:仅重绘发生变化的图块,而非整个图层。
合成与显示
在浏览器的渲染过程中,光栅化(Rasterization) 将矢量图形转换为位图后,接下来的步骤是 合成(Compositing) 和 显示(Display) 。这些步骤由 合成线程 和 GPU 协同完成,最终将页面内容呈现到屏幕上。下面是一个简单的合成示例:
plaintext
+----------------+ +----------------+ +----------------+
| 背景图层 | | 内容图层 | | 浮动层 |
| (z-index: 0) | | (z-index: 1) | | (z-index: 2) |
+----------------+ +----------------+ +----------------+
↓ ↓ ↓
+-----------------------------------------------------+
| 最终合成帧(按z-index顺序叠加) |
+-----------------------------------------------------+
性能优势:
- 非阻塞主线程:合成与光栅化在独立线程执行,主线程可继续处理JS或布局。
- 跳过重绘 :若仅图层位置变化(如CSS
transform
),无需重新绘制内容。
总结:
阶段 | 合成(Composite) |
---|---|
核心目标 | 高效合并图层,输出最终帧 |
性能影响 | 合成复杂度影响帧率 |
优化手段 | 减少图层尺寸、优先光栅化视口内容 |
触发条件 | 图层位置/透明度变化、图块更新 |
光栅化完成后,浏览器会将生成的位图数据交给合成线程和 GPU,完成以下步骤:
1. 图块的合成
- 图块(Tiles):
- 光栅化阶段将页面的每个图层分割成多个图块(通常是 256x256 像素)。
- 每个图块都包含光栅化后的位图数据。
- 合成线程的工作:
- 合成线程负责将这些图块组合起来,形成完整的页面图像。
- 合成线程会根据页面的滚动、动画或用户交互,动态调整图块的排列和显示顺序。
2. GPU 的参与
-
GPU 加速:
- 合成线程将图块的位图数据传递给 GPU。
- GPU 使用其强大的并行计算能力,将多个图块快速合成到一个帧缓冲区(Frame Buffer)中。
-
图层的合成:
- 如果页面包含多个图层(Layer),GPU 会按照图层的顺序(如 z-index)将它们叠加起来。
- GPU 还会处理透明度、混合模式、遮罩等效果。
3. 帧的生成
-
生成帧:
- GPU 将合成后的图像存储在帧缓冲区中,形成一帧完整的页面图像。
- 每一帧对应屏幕上的一个静态画面。
-
帧率:
- 浏览器通常以 60FPS(每秒 60 帧)的速率生成帧,以确保页面的流畅性。
- 如果页面内容复杂或主线程被阻塞,帧率可能会下降,导致页面卡顿。
4. 显示到屏幕
- 显示器刷新:
显示器会按照固定的刷新率(通常是 60Hz)从帧缓冲区中读取图像数据,并将其显示到屏幕上。
这一步由操作系统和显示驱动程序完成。 - 垂直同步(VSync):
- 为了避免屏幕撕裂(Tearing),浏览器会与显示器的刷新率同步,确保每次显示完整的一帧图像。
现代浏览器通过以下优化技术,提升合成和显示的性能:
1. 独立图层
-
独立图层的优势:
- 频繁变化的元素(如动画、滚动)被提升为独立图层,避免影响其他图层。
- 这些图层可以单独光栅化和合成,减少不必要的重绘。
-
触发独立图层的条件:
- 使用 CSS 属性 transform、opacity、will-change。
- 使用 position: fixed 或 position: sticky。
2. GPU 加速
-
GPU 的作用:
- GPU 专门用于处理图形计算,能够快速完成光栅化和合成操作。
- 通过 GPU 加速,浏览器可以显著提升动画和滚动的流畅性。
3. 按需绘制
- 可见区域优先:
- 合成线程只处理当前屏幕可见区域的图块,延迟处理不可见区域。
- 这减少了不必要的计算和内存占用。
分层与合成的性能优化实践:
1. 动画优化
-
优先使用
transform
和opacity
: 这两个属性仅触发合成阶段,跳过布局与绘制。css/* 高效动画 */ .box { transition: transform 0.3s; } .box:hover { transform: translateX(100px); }
-
避免
top/left
动画:- 修改
top/left
会触发布局与重绘,性能较差。
- 修改
2. 图层管理
-
按需提升图层:
-
使用
will-change
或transform3d
提升需频繁变化的元素。css.animated-element { will-change: transform; /* 提前告知浏览器优化 */ }
-
-
避免过度分层:
- 通过Chrome DevTools的 Layers 面板监控图层数量,合并冗余图层。
3. 减少合成层尺寸
- 裁剪不可见内容 :
- 对离屏(Offscreen)元素使用
clip-path
或overflow: hidden
,减少光栅化区域。
- 对离屏(Offscreen)元素使用
拓展
处理 JavaScript 和 CSS
在页面渲染过程中,JavaScript 和 CSS 的处理方式有所不同,它们会影响渲染流程:
CSS 处理
- 样式阻塞渲染 :
- CSS 是渲染阻塞资源,它不会直接阻塞 HTML 文档的解析。CSS 是渲染阻塞资源,这意味着浏览器会暂停渲染(构建渲染树)直到 CSS 资源加载和解析完成。这是因为 CSS 直接影响页面的布局和样式,浏览器需要知道元素的样式才能正确地进行布局和绘制。
- 内嵌 CSS :
- 内嵌 CSS(在
<style>
标签中)会在 HTML 文档解析过程中直接解析和应用,并且会直接参与到渲染树的构建中,不会引发额外的网络请求。
- 内嵌 CSS(在
- 外链 CSS :
- 外链 CSS(通过
<link>
标签引入)会引发额外的网络请求。浏览器会暂停构建渲染树,直到 CSS 文件下载并解析完成。
- 外链 CSS(通过
JavaScript 处理
- 脚本阻塞解析 :
- 默认情况下,JavaScript 是解析阻塞的 。当浏览器遇到
<script>
标签时,会暂停解析 HTML 文档,直到脚本下载、解析和执行完成。这是因为脚本可能会动态修改 DOM 或 CSSOM。
- 默认情况下,JavaScript 是解析阻塞的 。当浏览器遇到
- 内嵌 JavaScript :
- 内嵌 JavaScript(在
<script>
标签中)会在 HTML 文档解析过程中直接解析和执行,不会引发额外的网络请求。
- 内嵌 JavaScript(在
- 外链 JavaScript :
- 外链 JavaScript(通过
<script src="...">
引入)会引发额外的网络请求。浏览器会暂停解析 HTML 文档,直到脚本下载、解析和执行完成。
- 外链 JavaScript(通过
- 异步和 defer 属性 :
- 使用
async
属性可以使脚本异步加载和执行,不会阻塞 HTML 文档的解析。 - 使用
defer
属性可以使脚本在文档解析完成后但页面加载完成前执行,也不会阻塞 HTML 文档的解析。
- 使用
内嵌和外链资源的加载方式
内嵌资源
-
内嵌 CSS :
html<style> body { font-family: Arial, sans-serif; } </style>
- 直接在 HTML 文档中定义,不会引发额外的网络请求。
- 在 HTML 文档解析过程中直接解析和应用。
-
内嵌 JavaScript :
html<script> console.log("Hello, World!"); </script>
- 直接在 HTML 文档中定义,不会引发额外的网络请求。
- 在 HTML 文档解析过程中直接解析和执行。
外链资源
-
外链 CSS :
html<link rel="stylesheet" href="styles.css" />
- 引发额外的网络请求来获取 CSS 文件。
- 浏览器会暂停构建渲染树,直到 CSS 文件下载并解析完成。
-
外链 JavaScript :
html<script src="script.js"></script>
- 引发额外的网络请求来获取 JavaScript 文件。
- 默认情况下,浏览器会暂停解析 HTML 文档,直到脚本下载、解析和执行完成。
- 使用
async
或defer
属性可以改变这种行为。
总结
- 内嵌资源:直接在 HTML 文档中定义,不会引发额外的网络请求,但在 HTML 文档解析过程中处理。
- 外链资源 :引发额外的网络请求,CSS 是渲染阻塞资源,JavaScript 是解析阻塞资源,除非使用
async
或defer
属性。
通过合理使用内嵌和外链资源,以及优化资源加载策略(如使用async
和defer
),可以显著提高页面的加载性能和用户体验。
js 的 async 或 defer 属性
在 HTML 中,<script>
标签的 async
和 defer
属性可以控制 JavaScript 脚本的加载和执行方式,它们对于优化页面加载性能非常重要。下面详细介绍这两个属性的区别和用法:
1. async
属性
- 定义 :
async
属性表示脚本应该异步下载和执行。当使用async
时,脚本的下载不会阻塞 HTML 文档的解析,一旦脚本下载完成,它会在 HTML 文档解析过程中被立即执行。 - 执行时机 :脚本会在下载完成后立即执行,具体执行时间不确定,可能在 HTML 文档解析完成之前,也可能在解析完成之后。
async
脚本的执行顺序与它们在 HTML 文档中出现的顺序无关。 - 适用场景 :适用于独立的脚本(如广告代码、分析代码等),这些脚本的执行不依赖于其他脚本,也不会被其他脚本依赖。
2. defer
属性
- 定义 :
defer
属性表示脚本应该在 HTML 文档解析完成后、页面的DOMContentLoaded
事件触发前执行。脚本的下载不会阻塞 HTML 文档的解析,但脚本的执行会被推迟到 HTML 文档解析完成后。 - 执行时机 :脚本的执行顺序与它们在 HTML 文档中出现的顺序一致,确保脚本按顺序执行。
- 适用场景 :适用于需要在 HTML 文档解析完成后执行的脚本,尤其是那些脚本之间有依赖关系的情况。
3. 没有 async
或 defer
属性
- 定义 :默认情况下,脚本会阻塞 HTML 文档的解析,浏览器会暂停解析 HTML 文档,直到脚本下载、解析和执行完成。
- 执行时机 :脚本在下载、解析和执行过程中会阻塞 HTML 文档的解析,导致后续内容无法继续加载。
示例代码
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Script async vs defer</title>
<script src="script1.js"></script>
<!-- 默认,阻塞解析 -->
<script src="script2.js" async></script>
<!-- 异步,不阻塞解析 -->
<script src="script3.js" defer></script>
<!-- 推迟执行,不阻塞解析 -->
</head>
<body>
<h1>Hello, World!</h1>
</body>
</html>
4. 执行顺序
假设有以下脚本:
html
<script src="script1.js"></script>
<script src="script2.js" async></script>
<script src="script3.js" defer></script>
<script src="script4.js" async></script>
<script src="script5.js" defer></script>
执行顺序如下:
script1.js
:立即下载并执行,阻塞 HTML 文档解析。script2.js
和script4.js
:异步下载并在下载完成后立即执行,顺序不确定。script3.js
和script5.js
:按顺序下载,但在 HTML 文档解析完成后、DOMContentLoaded
事件触发前按顺序执行。
总结
async
:脚本异步下载和执行,不阻塞解析,执行顺序不确定。defer
:脚本推迟执行,不阻塞解析,执行顺序与 HTML 中的顺序一致。- 默认:脚本阻塞解析,下载、解析和执行完成后继续解析 HTML。
参考:
浏览器的工作方式
浏览器渲染机制
渲染页面:浏览器的工作原理
解析
关键渲染路径
构建对象模型
如何解释CSS新手的"级联"?
浏览器知识点整理(七)渲染流程