跳转到内容

浏览器渲染原理

1. 浏览器的渲染流程

1.1 渲染时间点

浏览器的网络进程开启线程接收 HTML 文档,产生一个渲染任务并传递给渲染主线程的消息队列,在事件循环机制作用下,渲染主线程取出消息队列中的渲染任务,开启渲染流程

渲染时间点

1.2 渲染流水线

整个渲染流程分为多个阶段,分别是

  • HTML 解析
  • 样式计算
  • 布局
  • 分层
  • 生成绘制指令
  • 分块
  • 光栅化

每个阶段都有明确的输入输出,即上一阶段的输出会成为下一阶段的输入,综上整个渲染流程形成了一套组织严密的生产流水线

渲染进程流水线2

browser-render-full-flow

来自 google 的图:

渲染进程流水线

1.3 Parse 阶段:解析 HTML

解析过程遇到 CSS 解析 CSS,遇到 JS 解析 JS,为了提高解析效率,浏览器在开始解析前会启动一个预解析线程,率先下载 HTML 中的外部 CSS 和 JS 文件

  • 如果主线程解析到 link 位置,此时外部 CSS 文件还没有下载解析好,主线程不会等待,继续解析后续的 HTML(下载和解析 CSS 的工作在预解析线程中进行)- CSS 不会阻塞 HTML 解析
  • 如果主线程解析到 script 位置,会停止解析 HTML,转而等待 JS 文件下载好,并将全局代码解析执行完成后,才能继续解析 HTML(JS 代码的执行过程可能会修改当前的 DOM 树,故 DOM 树的生成必须暂停)- JS 会阻塞 HTML 解析

最终得到 DOM 树和 CSSOM 树, 浏览器的默认样式、内部样式、外部样式和行内样式均会包含在 CSSOM 树中,树的每个节点都对应 JS 对象

DOM 树(文档对象模型)

Bytes => Characters => Tokens => Nodes => DOM

  1. 转换 Conversion:浏览器从磁盘或网络中读取 HTML 原始字节,并根据文件的指定编码(如 UTF-8)将它们转换为各个字符
  2. 令牌化/分词 Tokenizing:浏览器根据 HTML 规范将字符串转换为不同令牌(如<html><body>)以及用尖括号括起来的其他字符串
  3. 词法处理/语法分析 Lexing:上一步产生的标记被转换为“对象”,从而定义其属性和规则
  4. DOM 构建:由于 HTML 标记定义了不同标签之间的关系,因此创建的对象链接在一个树型数据结构中,并捕获原始标记中定义的父子、兄弟关系

CSSOM 树(CSS 对象模型)

重复上面 DOM 树流程,针对 CSS

StyleSheetList(样式表集合) => 若干个样式表 CSSStyleSheet => CSSStyleRule => 选择器 + style

样式表分类:

  • 头部<style>样式表
  • 头部<link...>样式表
  • 内联样式表 <div style="color: #fff">
  • 浏览器默认样式表(user agent stylesheet)

除了浏览器默认样式,其他样式 js 都可以操作,比如 内联样式通过 dom.style 更改, 前两个样式可通过 document.styleSheets[0].addRule('div', 'border: 1px solid red')处理

Bytes => Characters => Tokens => Nodes => CSSOM

parsing1

https://web.dev/articles/critical-rendering-path/constructing-the-object-model?hl=zh-cn

CSSOM 和 DOM 并行构建,构建 CSSOM 不会阻塞 DOM 构建。因为 JS 可能会操作样式信息,故 CSSOM 会阻塞 JS 执行

虽然 CSSOM 不会阻塞 DOM 构建,但进入下一阶段之前,必须等待 CSSOM 构建完成,即 CSSOM 会阻塞渲染

HTML 解析过程遇到 CSS/JS 代码(次级资源加载)

一个网页可能会有多个外部资源,如图片、js、css、字体等。主线程在解析 DOM 过程中遇到这些资源后会一一请求

为了加速渲染流程,预加载扫描器(preload scanner)线程并发运行。如果 HTML 中存在 img 或 link 等内容,预加载扫描器会查看 HTML parser 生成的标记,并发送请求到浏览器进程的网络线程获取资源

次级资源加载-css

次级资源加载-css

次级资源加载-js

次级资源加载-js

1.4 Style 阶段:样式计算

主线程会遍历得到的 DOM 树,依次为树中的每个节点计算出最终的样式,即 Computed Style

样式属性计算过程中,很多预设值会变成绝对值,如 red 会变成 rgb(255, 0, 0), 相对单位会变成绝对单位,em 会变成 px

计算完成后会得到一颗带有样式 DOM 树

CSS 引擎处理样式过程:

  1. 收集、划分和索引所有样式表中存在的样式规则,CSS 引擎会从 style 标签,css 文件及浏览器代理样式中收集所有的样式规则,并为这些规则建立索引,方便后续高效查询
  2. 访问每个元素并找到适用于该元素的所有规则,CSS 引擎遍历 DOM 节点,进行选择器适配,为匹配的节点执行样式设置
  3. 结合层叠规则和其他信息为节点生成最终的计算样式,这些样式值可以通过 window.getComputedStyle() 获取

页面样式多的话,即存在大量 CSS 规则,如果为每一个节点都保存一份样式值,会导致内存消耗过大。故 CSS 引擎通常会创建共享的样式结构,计算样式对象一般有指针指向相同的共享结构

1.5 Layout 阶段: 布局

布局完成会得到布局树,收集所有可见的 DOM 节点,以及每个节点的所有样式信息

布局阶段会依次遍历 DOM 树的每一个节点,计算每个节点的几何信息,产出可见节点,包含其内容和计算的样式

大部分场景下,DOM 树和布局树不是一一对应的:

  • 如某些不可见节点(script、head、meta 等)不会体现在渲染输出中,会被忽略
  • display: none 节点没有几何信息,不会对应生成到布局树
  • 如使用了伪元素选择器,虽然 DOM 树中不存在伪元素节点,但是拥有几何信息最终会生成到布局树中(为伪元素创建 LayoutObject)
  • 匿名行盒和匿名块盒都会导致 DOM 树和布局树无法一一对应(为行内元素创建匿名包含块对应的 LayoutObject)

布局计算

计算可见节点和其样式后,需要计算它们在设备视口内节点的宽高、相对包含块的位置,这个过程称为自动重排

  1. 根据 CSS 盒模型及视觉格式化模型,计算每个元素的各种生成盒的大小和位置
  2. 计算块级元素、行内元素、浮动元素、各种定位元素的大小和位置
  3. 计算文字,滚动区域的大小和位置
  4. LayoutObject 类型:
    1. 传统的 LayoutObject 节点会把布局运算的结果重新写回布局树中
    2. LayoutNG (Chrome 76 开始启用)节点的输出不可变,会保存在 NGLayoutResult 中,是一个树状结构,相比 LayoutObject 减少很多回溯计算,提高性能

render-tree1

1.6 Layer 阶段: 分层

主线程会使用一套复杂的策略对整个布局树中进行分层

分层的好处在于,将来某一个层改变后,仅会对该层进行后续处理,提高效率

滚动条、堆叠上下文、transform、opacity 等样式均会影响分层结果,还可以通过 will-change 更大程度影响分层结果

构建 PaintLayer(RenderLayer) 树

构建完成的 layoutObject 树不可以被拿去显示(不包含绘制顺序 z-index),还有一些复杂情况如 3d 变换,页面滚动等,浏览器会对上一步的节点进行分层处理 - 建立层叠上下文

浏览器根据 CSS 层叠上下文规范,建立层叠上下文

  1. DOM 树的 Document 节点对应的 RenderView 节点
  2. DOM 树的 Document 节点的子节点,即 HTML 节点对应的 RenderBlock 节点
  3. 显式指定 CSS 位置的节点(position 为 absolute 或 fixed)
  4. 具有透明效果的节点
  5. 具有 CSS 3D 属性的节点
  6. 使用 Canvas 元素或者 Video 元素的节点

浏览器遍历 LayoutObject 树的时候,建立了 PaintLayer 树,LayoutObject 和 PaintLayer 不一定一一对应,即每个 LayoutObject 要么和自己的 PaintLayer 关联,要么和拥有 PaintLayer 的第一个祖先元素的 PaintLayer 关联

并非一一对应

构建 cc::Layer 与 display items

浏览器会继续根据 PaintLayer 树创建 cc::Layer 列表,cc::Layer 是列表状结构,运行在主线程,一个渲染进程内有且只有一个 cc::Layer,代表一个矩形区域内的 UI,layer 里有 DisplayItem 列表,是绘制操作的列表,包含实际的 paint op 指令。将页面分层,可以让一个图层独立于其他的图层进行变换和光栅化处理

  • 合成更新(Compositing update)
    • 依据 PaintLayer 决定分层
    • 这个策略被称为 CompositeBeforePaint,未来会被 CompositeAfterPaint 替代
  • PrePaint
    • PaintInvalidator 进行失效检查,找出需要绘制的 display items
    • 构建 paint property 树,使动画、页面滚动,clip 等变化仅在合成线程运行,提高性能

1.7 Pre-Paint 阶段:生成绘制指令

主线程会为每个层单独产生绘制指令集,用于描述这一层的内容该如何画出来

DisplayItem 列表准备好后,渲染主线程会给合成线程发送commit消息,即将每个图层的绘制信息提交给合成线程,剩余工作将由合成线程完成(渲染主线程任务至此告一段落)

1.8 Tiling 阶段:分块

合成线程先对每个图层进行分块,将其划分为更多的小区域,再从线程池中拿取多个线程来分块工作

分块的原因:

考虑到视口大小,当页面非常大的时候,要滑动很长时间,这样一次性全部绘制是十分浪费性能的,因此需要将图层分块,进而加速页面首屏展示

browser-render-tiling

1.9 Raster 阶段: 光栅化

合成线程将块信息交给 GPU 进程,将每个块变成位图(像素信息-像素点、颜色等),GPU 进程会开启多个线程完成光珊瑚,且优先处理靠近视口的块

browser-render-raster browser-render-raster2

1.10 画

合成线程拿到每个层、每个块的位图后,生成一个个 quad 指引信息

指引会标识出每个位图应该画到屏幕的哪个位置(如考虑旋转、缩放等变形)

变形发生在合成线程,与渲染主线程无关(即 transform 高效率的本质)

合成线程把 quad 提交给 GPU 进程,由 GPU 进程产生系统调用,提交给 GPU 硬件,完成最终的屏幕成像

browser-render-draw

2. 浏览器渲染性能优化

我们在修改元素几何信息时,实际修改的是 CSSOM 或 DOM

browser-render-full-flow

2.1 reflow

重排对应浏览器渲染过程中的 Layout, 其本质是重新计算 layout 树

为了避免连续多次操作导致布局树反复计算,浏览器会合并这些操作,当 js 代码全部完成后再进行统一计算,即改动属性造成的 reflow 是异步完成的,这也就导致了,在 js 获取布局属性时,可能会造成无法获取到最新布局信息的问题。为了解决这个问题浏览器便有了使用 js 获取属性时产生一个同步任务会立即 reflow 的机制(设置属性异步,读取属性同步)

2.2 repaint

重绘对应浏览器渲染过程中的 Paint, 其本质是重新根据分层信息计算绘制指令

改变可见样式后小重新计算,就会触发 repaint

元素的布局信息也属于可见样式,从上面的流程图中我们可以发现 Layout 改变一定会触发 Paint,即重排一定会触发重绘

更具体的内容请参考这篇总结 浏览器的重排和重绘

reference