跳至主要內容

浏览器工作原理(下)

Mr.Dylin...大约 26 分钟HTMLB_HTML2.HTML

浏览器工作原理(下)

渲染进程的渲染流程

首先要了解的概念:

  • 渲染引擎:它是浏览器最核心的部分是 “Rendering Engine”,不过我们一般习惯将之称为 “浏览器内核”

  • 渲染引擎主要包括的线程:

  • 各个线程主要职责

    • GUI 渲染线程:GUI 渲染线程负责渲染浏览器界面,解析 HTML,CSS,构建 DOM 树和 RenderObject 树,布局和绘制等。当界面需要重绘(Repaint)或由于某种操作引发回流(Reflow)时,该线程就会执行。
    • JavaScript 引擎线程: JavaScript 引擎线程主要负责解析 JavaScript 脚本并运行相关代码。 JavaScript 引擎在一个 Tab 页(Renderer 进程)中无论什么时候都只有一个 JavaScript 线程在运行 JavaScript 程序。需要提起一点就是,GUI 线程与 JavaScript 引擎线程是互斥的,这也是就是为什么 JavaScript 操作时间过长,会造成页面渲染不连贯,导致页面出现阻塞的原理。
    • 事件触发线程:当一个事件被触发时该线程会把事件添加到待处理队列的队尾,等待 JavaScript 引擎的处理。 通常 JavaScript 引擎是单线程的,所以这些事件都会排队等待 JS 执行。
    • 定时器触发器: 我们日常使用的 setInterval 和 setTimeout 就在该线程中,原因可能就是:由于 JS 引擎是单线程的,如果处于阻塞线程状态就会影响记时的准确,所以需要通过单独的线程来记时并触发响应的事件这样子更为合理。
    • Http 请求线程: 在 XMLHttpRequest 在连接后是通过浏览器新开一个线程请求,这个线程就 Http 请求线程,它 将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件放到 JavaScript 引擎的处理队列中等待处理。

以上来自阿宝哥总结: 你不知道的 Web Workers (上)[7.8K 字 | 多图预警]open in new window

有了上述的概念,对接下我们讲渲染流水线会有所帮助

简略版的渲染机制

很久之前就把浏览器工作原理读完了,看了很多博客,文章,当时简简单单的梳理一些内容, 如下👇

简略版渲染机制一般分为以下几个步骤

  1. 处理 HTML 并构建 DOM 树。
  2. 处理 CSS 构建 CSSOM 树。
  3. 将 DOM 与 CSSOM 合并成一个渲染树。
  4. 根据渲染树来布局,计算每个节点的位置。
  5. 调用 GPU 绘制,合成图层,显示在屏幕上。

接下来大概就是这么说:

在构建 CSSOM 树时,会阻塞渲染,直至 CSSOM 树构建完成。并且构建 CSSOM 树是一个十分消耗性能的过程,所以应该尽量保证层级扁平,减少过度层叠,越是具体的 CSS 选择器,执行速度越慢。

当 HTML 解析到 script 标签时,会暂停构建 DOM,完成后才会从暂停的地方重新开始。也就是说,如果你想首屏渲染的越快,就越不应该在首屏就加载 JS 文件。并且 CSS 也会影响 JS 的执行,只有当解析完样式表才会执行 JS,所以也可以认为这种情况下,CSS 也会暂停构建 DOM。

说完这些,记下来就讲几个面试常常会提起的,会问你的知识点👇

Load 和 DOMContentLoaded 区别

Load 事件触发代表页面中的 DOM,CSS,JS,图片已经全部加载完毕。

DOMContentLoaded 事件触发代表初始的 HTML 被完全加载和解析,不需要等待 CSS,JS,图片加载。

图层

一般来说,可以把普通文档流看成一个图层。特定的属性可以生成一个新的图层。不同的图层渲染互不影响,所以对于某些频繁需要渲染的建议单独生成一个新图层,提高性能。但也不能生成过多的图层,会引起反作用。

通过以下几个常用属性可以生成新图层

  • 3D 变换:translate3dtranslateZ
  • will-change
  • videoiframe 标签
  • 通过动画实现的 opacity 动画转换
  • position: fixed

重绘(Repaint)和回流(Reflow)

重绘和回流是渲染步骤中的一小节,但是这两个步骤对于性能影响很大。

  • 重绘是当节点需要更改外观而不会影响布局的,比如改变 color 就叫称为重绘
  • 回流是布局或者几何属性需要改变就称为回流。

回流必定会发生重绘,重绘不一定会引发回流。回流所需的成本比重绘高的多,改变深层次的节点很可能导致父节点的一系列回流。

所以以下几个动作可能会导致性能问题:

  • 改变 window 大小
  • 改变字体
  • 添加或删除样式
  • 文字改变
  • 定位或者浮动
  • 盒模型

很多人不知道的是,重绘和回流其实和 Event loop 有关。

  1. 当 Event loop 执行完 Microtasks 后,会判断 document 是否需要更新。因为浏览器是 60Hz 的刷新率,每 16ms 才会更新一次。
  2. 然后判断是否有 resize 或者 scroll ,有的话会去触发事件,所以 resizescroll 事件也是至少 16ms 才会触发一次,并且自带节流功能。
  3. 判断是否触发了 media query
  4. 更新动画并且发送事件
  5. 判断是否有全屏操作事件
  6. 执行 requestAnimationFrame 回调
  7. 执行 IntersectionObserver 回调,该方法用于判断元素是否可见,可以用于懒加载上,但是兼容性不好
  8. 更新界面
  9. 以上就是一帧中可能会做的事情。如果在一帧中有空闲时间,就会去执行 requestIdleCallback 回调。

以上内容来自于 HTML 文档open in new window

减少重绘和回流

  • 使用 translate 替代 top

    <div class="test"></div>
    <style>
    	.test {
    		position: absolute;
    		top: 10px;
    		width: 100px;
    		height: 100px;
    		background: red;
    	}
    </style>
    <script>
    	setTimeout(() => {
            // 引起回流
    		document.querySelector('.test').style.top = '100px'
    	}, 1000)
    </script>
    
  • 使用 visibility 替换 display: none ,因为前者只会引起重绘,后者会引发回流(改变了布局)

  • 把 DOM 离线后修改,比如:先把 DOM 给 display:none (有一次 Reflow),然后你修改 100 次,然后再把它显示出来

  • 不要把 DOM 结点的属性值放在一个循环里当成循环里的变量

    for(let i = 0; i < 1000; i++) {
        // 获取 offsetTop 会导致回流,因为需要去获取正确的值
        console.log(document.querySelector('.test').style.offsetTop)
    }
    
  • 不要使用 table 布局,可能很小的一个小改动会造成整个 table 的重新布局

  • 动画实现的速度的选择,动画速度越快,回流次数越多,也可以选择使用 requestAnimationFrame

  • CSS 选择符从右往左匹配查找,避免 DOM 深度过深

  • 将频繁运行的动画变为图层,图层能够阻止该节点回流影响别的元素。比如对于 video 标签,浏览器会自动将该节点变为图层。


这不是我想梳理的内容

上面的渲染流程是我一年前就在我笔记中存在的内容,我还记得当时学习的方式是囫囵吞枣式的,上面☝简略版的渲染流程,我印象中是在 GitHub 上面某博看看的,当时直接 Copy 下来的,当时觉得这个渲染原理这块有了别人梳理好的结论,自己多看看,会记住的,事实上,面试的时候,提起这部分的时候,深度明显不够,自然就被问倒了

下来梳理了一份详细的版本,坦白说,作为一个学者,自然是站在巨人的肩膀上,去总结梳理知识,我认为这是对我最有效的学习方式

让我带着你🚗重温一般渲染流程吧


详细版的渲染机制

较为专业的术语总结为以下阶段:

  1. 构建 DOM 树
  2. 样式计算
  3. 布局阶段
  4. 分层
  5. 绘制
  6. 分块
  7. 光栅化
  8. 合成

你可以想象一下,从 0,1 字节流到最后页面展现在你面前,这里面渲染机制肯定很复杂,所以渲染模块把执行过程中化为很多的子阶段,渲染引擎从网络进程拿到字节流数据后,经过这些子阶段的处理,最后输出像素,这个过程可以称为渲染流水线 ,我们从一张图上来看👇

那接下来就从每个阶段来梳理一下大致过程。

构建 DOM 树

这个过程主要工作就是讲 HTML 内容转换为浏览器 DOM 树结构

文档对象模型 (DOM)

<html>
  <head>
    <meta >
    <link href="style.css" rel="stylesheet">
    <title>Critical Path</title>
  </head>
  <body>
    <p>Hello <span>web performance</span> students!</p>
    <div><img src="awesome-photo.jpg"></div>
  </body>
</html>
复制代码

我们先看看数据是怎么样转换的👇

大概过程:

  1. ** 转换:** 浏览器从磁盘或网络读取 HTML 的原始字节,并根据文件的指定编码(例如 UTF-8)将它们转换成各个字符。
  2. ** 令牌化:** 浏览器将字符串转换成 W3C HTML5 标准open in new window规定的各种令牌,例如,“”、“”,以及其他尖括号内的字符串。每个令牌都具有特殊含义和一组规则。
  3. ** 词法分析:** 发出的令牌转换成定义其属性和规则的 “对象”。
  4. DOM 构建: 最后,由于 HTML 标记定义不同标记之间的关系(一些标记包含在其他标记内),创建的对象链接在一个树数据结构内,此结构也会捕获原始标记中定义的父项 - 子项关系:HTML 对象是 body 对象的父项,bodyparagraph 对象的父项,依此类推。

我们把上述这样子的过程就叫做是构建 DOM 树过程

样式计算

这个子阶段主要有三个步骤

格式化样式表

我们拿到的也就是 0,1 字节流数据,浏览器无法直接去识别的,所以渲染引擎收到 CSS 文本数据后,会执行一个操作,转换为浏览器可以理解的结构 -styleSheets

如果你很想了解这个格式化的过程,可以好好去研究下,不同的浏览器可能在 CSS 格式化过程中会有所不同,在这里就不展开篇幅了。

通过浏览器的控制台document.styleSheets可以来查看这个最终结果。通过 JavaScript 可以完成查询和修改功能, 或者说这个阶段为后面的样式操作提供基石。

标准化样式表

什么是标准化样式表呢? 先看一段 CSS 文本👇

body { font-size: 2em }
p {color:blue;}
span  {display: none}
div {font-weight: bold}
div  p {color:green;}
div {color:red; }
复制代码

有些时候,我们写 CSS 样式的时候,会写font-size:2em;color:red;font-weight:bold, 像这些数值并不容易被渲染引擎所理解,因此需要在计算样式之前将它们标准化,如em->px,red->rgba(255,0,0,0),bold->700等等。

上面的代码标准后属性值是什么样子呢👇

计算每个 DOM 节点具体样式

通过格式化标准化,接下来就是计算每个节点具体样式信息了。

计算规则:继承层叠

继承:每个子节点会默认去继承父节点的样式,如果父节点中找不到,就会采用浏览器默认的样式,也叫UserAgent样式

层叠:样式层叠,是 CSS 一个基本特征,它定义如何合并来自多个源的属性值的算法。某种意义上,它处于核心地位,具体的层叠规则属于深入 CSS 语言的范畴,这里就补展开篇幅说了。

不过值得注意的是,在计算完样式之后,所有的样式值会被挂在到window.getComputedStyle当中,也就是可以通过 JS 来获取计算后的样式,非常方便。

这个阶段,完成了 DOM 节点中每个元素的具体样式,计算过程中要遵循 CSS 的继承层叠两条规则,最终输出的内容是每个节点 DOM 的样式,被保存在 ComputedStyle 中。

想了解每个 DOM 元素最终的计算样式,可以打开 Chrome 的 “开发者工具”,选择第一个“element” 标签,比如我下面就选择了 div 标签,然后再选择 “Computed” 子标签,如下图所示:

另外一种说法 CSSOM

如果不是很理解的话,可以看这里👇

跟处理 HTML 一样,我们需要更具 CSS 两个规则:继承层叠转换成某种浏览器能理解和处理的东西,处理过程类似处理 HTML,如上图☝

CSS 字节转换成字符,接着转换成令牌和节点,最后链接到一个称为 “CSS 对象模型”(CSSOM) 的树结构内👇

很多人肯定看这个很熟悉,确实,很多博客都是基于 CSSOM 说法来讲的,我要说的是:

和 DOM 不一样,源码中并没有 CSSOM 这个词,所以很多文章说的 CSSOM 应该就是 styleSheets,当然了这个 styleSheets 我们可以打印出来的

很多文章说法是渲染树也是 16 年前的说法,现在代码重构了,我们可以把 LayoutTree 看成是渲染树,不过它们之间还是有些区别的。

生成布局树

上述过程已经完成 DOM 树(DOM 树)构建,以及样式计算(DOM 样式),接下来就是要通过浏览器的布局系统确定元素位置,也就是生成一颗布局树(Layout Tree), 之前说法叫 渲染树

创建布局树

  1. 在 DOM 树上不可见的元素,head 元素,meta 元素等,以及使用 display:none 属性的元素,最后都不会出现在布局树上,所以浏览器布局系统需要额外去构建一棵只包含可见元素布局树。

  2. 我们直接结合图来看看这个布局树构建过程:

    为了构建布局树,浏览器布局系统大体上完成了下面这些工作:

布局计算

接下来就是要计算布局树节点的坐标位置,布局的计算过程非常复杂,张开介绍的话,会显得文章过于臃肿,大多数情况下,我们只需要知道它所做的工作是什么,想知道它是如何做的话,可以看看以下两篇文章👇

梳理前三个阶段

一图概括上面三个阶段

分层

首先需要知道的就是,浏览器在构建完布局树后,还需要进行一系列操作,这样子可能考虑到一些复杂的场景,比如一些些复杂的 3D 变换、页面滚动,或者使用 z-indexing 做 z 轴排序等,还有比如是含有层叠上下文如何控制显示和隐藏等情况。

生成图层树

你最终看到的页面,就是由这些图层一起叠加构成的,它们按照一定的顺序叠加在一起,就形成了最终的页面。

浏览器的页面实际上被分成了很多图层,这些图层叠加后合成了最终的页面。

我们来看看图层与布局树之间关系,如下图👇

通常情况下,并不是布局树的每个节点都包含一个图层,如果一个节点没有对应的层,那么这个节点就从属于父节点的图层。

那什么情况下,渲染引擎会为特定的节点创建新图层呢?

有两种情况需要分别讨论,一种是显式合成,一种是隐式合成

显式合成

一、 拥有层叠上下文的节点。

层叠上下文也基本上是有一些特定的 CSS 属性创建的,一般有以下情况:

  1. HTML 根元素本身就具有层叠上下文。
  2. 普通元素设置 position 不为 static 并且设置了 z-index 属性,会产生层叠上下文。
  3. 元素的 opacity 值不是 1
  4. 元素的 transform 值不是 none
  5. 元素的 filter 值不是 none
  6. 元素的 isolation 值是 isolate
  7. will-change 指定的属性值为上面任意一个。(will-change 的作用后面会详细介绍)

二、需要剪裁 (clip) 的地方。

比如一个标签很小,50*50 像素,你在里面放了非常多的文字,那么超出的文字部分就需要被剪裁。当然如果出现了滚动条,那么滚动条也会被单独提升为一个图层,如下图

数字 1 箭头指向的地方,可以看看,可能效果不是很明显,大家可以自己打开这个 Layers 探索下。

元素有了层叠上下文的属性或者需要被剪裁,满足其中任意一点,就会被提升成为单独一层。

隐式合成

这是一种什么样的情况呢,通俗意义上来说,就是z-index比较低的节点会提升为一个单独的途图层,那么层叠等级比它高的节点都会成为一个独立的图层。

浏览器渲染流程 & Composite(渲染层合并)简单总结open in new window

缺点: 根据上面的文章来说,在一个大型的项目中,一个z-index比较低的节点被提升为单独图层后,层叠在它上面的元素统统都会提升为单独的图层,我们知道,上千个图层,会增大内存的压力,有时候会让页面崩溃。这就是层爆炸

绘制

完成了图层的构建,接下来要做的工作就是图层的绘制了。图层的绘制跟我们日常的绘制一样,每次都会把一个复杂的图层拆分为很小的绘制指令,然后再按照这些指令的顺序组成一个绘制列表,类似于下图👇

从图中可以看出,绘制列表中的指令其实非常简单,就是让其执行一个简单的绘制操作,比如绘制粉色矩形或者黑色的线等。而绘制一个元素通常需要好几条绘制指令,因为每个元素的背景、前景、边框都需要单独的指令去绘制。

大家可以在 Chrome 开发者工具中在设置栏中展开 more tools, 然后选择Layers面板,就能看到下面的绘制列表:

在该图中,** 箭头 2 指向的区域 ** 就是 document 的绘制列表,** 箭头 3 指向的拖动区域 ** 中的进度条可以重现列表的绘制过程。

当然了,绘制图层的操作在渲染进程中有着专门的线程,这个线程叫做合成线程。

分块

  • 接下来我们就要开始绘制操作了,实际上在渲染进程中绘制操作是由专门的线程来完成的,这个线程叫合成线程

  • 绘制列表准备好了之后,渲染进程的主线程会给合成线程发送commit消息,把绘制列表提交给合成线程。接下来就是合成线程一展宏图的时候啦。

你想呀,有时候,你的图层很大,或者说你的页面需要使用滚动条,然后页面的内容太多,多的无法想象,这个时候需要滚动好久才能滚动到底部,但是通过视口,用户只能看到页面的很小一部分,所以在这种情况下,要绘制出所有图层内容的话,就会产生太大的开销,而且也没有必要。

首屏渲染加速可以这么理解:

因为后面图块(非视口内的图块)数据要进入 GPU 内存,考虑到浏览器内存上传到 GPU 内存的操作比较慢,即使是绘制一部分图块,也可能会耗费大量时间。针对这个问题,Chrome 采用了一个策略: 在首次合成图块时只采用一个低分辨率的图片,这样首屏展示的时候只是展示出低分辨率的图片,这个时候继续进行合成操作,当正常的图块内容绘制完毕后,会将当前低分辨率的图块内容替换。这也是 Chrome 底层优化首屏加载速度的一个手段。

光栅化

接着上面的步骤,有了图块之后,合成线程会按照视口附近的图块来优先生成位图,实际生成位图的操作是由栅格化来执行的。所谓栅格化,是指将图块转换为位图。

运行方式如下👇

通常,栅格化过程都会使用 GPU 来加速生成,使用 GPU 生成位图的过程叫快速栅格化,或者 GPU 栅格化,生成的位图被保存在 GPU 内存中。

相信你还记得,GPU 操作是运行在 GPU 进程中,如果栅格化操作使用了 GPU,那么最终生成位图的操作是在 GPU 中完成的,这就涉及到了跨进程操作。具体形式你可以参考下图:

从图中可以看出,渲染进程把生成图块的指令发送给 GPU,然后在 GPU 中执行生成图块的位图,并保存在 GPU 的内存中。

合成和显示

栅格化操作完成后,合成线程会生成一个绘制命令,即 "DrawQuad",并发送给浏览器进程。

浏览器进程中的viz组件接收到这个命令,根据这个命令,把页面内容绘制到内存,也就是生成了页面,然后把这部分内存发送给显卡, 那你肯定对显卡的原理很好奇。

看了某博主对显示器显示图像的原理解释:

无论是 PC 显示器还是手机屏幕,都有一个固定的刷新频率,一般是 60 HZ,即 60 帧,也就是一秒更新 60 张图片,一张图片停留的时间约为 16.7 ms。而每次更新的图片都来自显卡的前缓冲区。而显卡接收到浏览器进程传来的页面后,会合成相应的图像,并将图像保存到后缓冲区,然后系统自动将前缓冲区后缓冲区对换位置,如此循环更新。

这个时候,心中就有点概念了,比如某个动画大量占用内存时,浏览器生成图像的时候会变慢,图像传送给显卡就会不及时,而显示器还是以不变的频率刷新,因此会出现卡顿,也就是明显的掉帧现象。


用一张图来总结👇

我们把上面整个的渲染流水线,用一张图片更直观的表示👇

回流 - 重绘 - 合成

更新视图三种方式

回流

另外一个叫法是重排,回流触发的条件就是: 对 DOM 结构的修改引发 DOM 几何尺寸变化的时候, 会发生回流过程。

具体一点,有以下的操作会触发回流:

  1. 一个 DOM 元素的几何属性变化,常见的几何属性有widthheightpaddingmarginlefttopborder 等等, 这个很好理解。
  2. 使 DOM 节点发生增减或者移动
  3. 读写 offset族、scroll族和client族属性的时候,浏览器为了获取这些值,需要进行回流操作。
  4. 调用 window.getComputedStyle 方法。

一些常用且会导致回流的属性和方法:

  • clientWidthclientHeightclientTopclientLeft
  • offsetWidthoffsetHeightoffsetTopoffsetLeft
  • scrollWidthscrollHeightscrollTopscrollLeft
  • scrollIntoView()scrollIntoViewIfNeeded()
  • getComputedStyle()
  • getBoundingClientRect()
  • scrollTo()

依照上面的渲染流水线,触发回流的时候,如果 DOM 结构发生改变,则重新渲染 DOM 树,然后将后面的流程 (包括主线程之外的任务) 全部走一遍。

重绘

当页面中元素样式的改变并不影响它在文档流中的位置时(例如:colorbackground-colorvisibility等),浏览器会将新样式赋予给元素并重新绘制它,这个过程称为重绘。

根据概念,我们知道由于没有导致 DOM 几何属性的变化,因此元素的位置信息不需要更新,从而省去布局的过程,流程如下:

跳过了布局树建图层树, 直接去绘制列表,然后在去分块, 生成位图等一系列操作。

可以看到,重绘不一定导致回流,但回流一定发生了重绘。

合成

还有一种情况:就是更改了一个既不要布局也不要绘制的属性,那么渲染引擎会跳过布局和绘制,直接执行后续的合成操作,这个过程就叫合成

举个例子:比如使用 CSS 的 transform 来实现动画效果,避免了回流跟重绘,直接在非主线程中执行合成动画操作。显然这样子的效率更高,毕竟这个是在非主线程上合成的,没有占用主线程资源,另外也避开了布局和绘制两个子阶段,所以相对于重绘和重排,合成能大大提升绘制效率。

利用这一点好处:

提升合成层的最好方式是使用 CSS 的 will-change 属性

GPU 加速原因

比如利用 CSS3 的transformopacityfilter这些属性就可以实现合成的效果,也就是大家常说的 GPU 加速

实践意义

参考

你不知道的 Web Workers (上)[7.8K 字 | 多图预警]open in new window

极客时间 - 渲染流程open in new window

从浏览器多进程到 JS 单线程,JS 运行机制最全面的一次梳理open in new window

How Browsers Work: Behind the scenes of modern web browsersopen in new window

史上最全!图解浏览器的工作原理open in new window

W3C HTML5 标准open in new window

浏览器渲染流程 & Composite(渲染层合并)简单总结open in new window

浏览器层合成与页面渲染优化open in new window

浏览器的回流与重绘 (Reflow & Repaint)open in new window

无线性能优化:Compositeopen in new window

CSS GPU Animation: Doing It Rightopen in new window

本文使用 mdniceopen in new window 排版

上次编辑于:
贡献者: zddbic