# 浏览器渲染机制

# 进程/线程

进程(process):启动一个程序的时候,操作系统会为该程序创建一块内存,用来存放代码、运行中的数据和一个执行任务的主线程,我们把这样的一个运行环境叫进程。 线程(thread)是CPU调度和分配的基本单位,可以和同一个进程的其他线程共享进程所拥有的全部资源,线程是不能单独存在的,它是由进程来启动和管理的。

总结来说,进程和线程之间的关系有以下 4 个特点:

  1. 进程中的任意一线程执行出错,都会导致整个进程的崩溃。
  2. 线程之间共享进程中的数据。
  3. 当一个进程关闭之后,操作系统会回收进程所占用的内存。
  4. 进程之间的内容相互隔离。

最新的 Chrome 浏览器包括:1 个浏览器(Browser)主进程、1GPU 进程、1 个网络(NetWork)进程、多个渲染进程和多个插件进程。

TIP

浏览器地址输入一个URL,浏览器根据DNS服务器得到域名的IP地址,这个时候浏览器进程会向这个IP地址发送请求,获取HTML内容。然后将这些内容交给渲染进程。渲染进程解析内容,解析遇到的请求网络的资源又返回来交给浏览器进程进行加载,同时通知主进程(浏览器进程),需要插件进程加载插件资源,执行插件代码。解析完成后,渲染进程得到图像帧,并交给GPU进程,将其转化为图像显示屏幕。四种进程介绍

首先,当我们是要浏览一个网页,我们会在浏览器的地址栏里输入URL,这个时候Browser Process会向这个URL发送请求,获取这个URLHTML内容,然后将HTML交给Renderer ProcessRenderer Process解析HTML内容,解析遇到需要请求网络的资源又返回来交给Browser Process进行加载,同时通知Browser Process,需要Plugin Process加载插件资源,执行插件代码。解析完成后,Renderer Process计算得到图像帧,并将这些图像帧交给GPU ProcessGPU Process将其转化为图像显示屏幕。

# 浏览器进程模式

TIP

Chrome浏览器多进程的好处:

  1. 更高的容错性。当今WEB应用中,HTML,JavaScriptCSS日益复杂,这些跑在渲染引擎的代码,频繁的出现BUG,而有些BUG会直接导致渲染引擎崩溃,多进程架构使得每一个渲染引擎运行在各自的进程中,相互之间不受影响,也就是说,当其中一个页面崩溃挂掉之后,其他页面还可以正常的运行不收影响。
  2. 更高的安全性和沙盒性(sanboxing)。渲染引擎会经常性的在网络上遇到不可信、甚至是恶意的代码,它们会利用这些漏洞在你的电脑上安装恶意的软件,针对这一问题,浏览器对不同进程限制了不同的权限,并为其提供沙盒运行环境,使其更安全更可靠。
  3. 更高的响应速度。在单进程的架构中,各个任务相互竞争抢夺CPU资源,使得浏览器响应速度变慢,而多进程架构正好规避了这一缺点。

为了节省内存,Chrome提供了四种进程模式(Process Models),不同的进程模式会对 tab 进程做不同的处理。

  • Process-per-site-instance (default) - 同一个 site-instance 使用一个进程

当你打开一个 tab 访问 a.baidu.com ,然后再打开一个 tab 访问 b.baidu.com,这两个 tab 会使用两个进程。而如果你在 a.baidu.com 中,通过JS代码打开了 b.baidu.com 页面,这两个 tab 会使用同一个进程。

  • Process-per-site:同一个site一个进程
  • Process-per-tab:每个tab使用一个进程
  • single Process:所有tab共用一个进程

TIP

  • site 指的是相同的 registered domain name(如:google.combbc.co.uk)和scheme (如:https://)。比如a.baidu.comb.baidu.com就可以理解为同一个 site(注意这里要和 Same-origin policy 区分开来,同源策略还涉及到子域名和端口)。
  • site-instance 指的是一组 connected pages from the same site,这里 connected 的定义是 can obtain references to each other in script code 怎么理解这段话呢。满足下面两情况并且打开的新页面和旧页面属于上面定义的同一个 site,就属于同一个 site-instance
    • 用户通过<a target="_blank">这种方式点击打开的新页面
    • JS代码打开的新页面(比如 window.open)

# 解析文档

上述有提到渲染进程会解析内容,然后遇到请求网络的资源就会返回给浏览器进程进行加载,那么解析这个过程又是如何进行的呢?

# 构建DOM

渲染进程的主线程开始工作,解析HTML文本并转为DOM

# 子资源加载

当主线程解析遇到一些需要额外加载的资源时,如图片,CSSJavaScript,为了提高请求速度,会向浏览器进程的网络线程发起请求。

# JS阻塞解析过程

HTML解析器遇到script标签时,会暂停HTML解析工作,转而去加载Js代码。因为这部分代码可能会影响文档结构。也会导致CSSOM也阻塞。

# 样式计算

主线程解析计算完CSS产生CSS规则树,才会对文档节点赋予最终的样式。

处理过程:1.把CSS转换成浏览器能够理解的结构--StyleSheets,该结构同时具备了查询和修改功能。2.转换样式表中的属性值,使其标准化。3.计算出DoM树中的每个节点具体样式

  • CSS加载会阻塞页面显示吗?

    • css加载不会阻塞DOM树的解析
    • css加载会阻塞DOM树的渲染
    • css加载会阻塞后面js语句的执行
  • 下载CSS文件阻塞了,会阻塞DOM树的合成吗?会阻塞页面的显示吗?

不会阻塞dom树构建的,因为HTML转化为dom树的过程,发现文件请求会交给网络进程去请求对应文件,渲染进程继续解析HTML。 会阻塞页面的显示,当计算样式的时候需要等待css文件的资源进行层叠样式,资源阻塞了,会进行等待,直到网络超时,network报出错误,渲染进程继续层叠样式计算。

# 布局(初次回流)

主线程遍历DOM并计算样式,创建一个具体横纵坐标以及盒子边界大小数据的布局树(layout tree)。布局树可能与DOM树相似,但它只包含和页面即将呈现的节点相关的信息。如果某个元素设置了display: none,虽然它会呈现在DOM树中但并不会包含于布局树当中;如果有一个伪类元素p::before{ content: 'Hi!' }, 那么它虽然不在DOM树中,但仍然会出现在布局树当中。

# 分层

渲染引擎为特定的节点生成专用的图层,并生成一棵对应的图层树。拥有层叠上下文的元素会被单独提升为一层;需要剪裁的地方会被创建为图层(当超出容器内容被隐藏或出现滚动条均会被提升为单独层)

# 图层绘制

渲染引擎对图层树每个图层进行绘制。渲染引擎会把一个图层的绘制分成很多小的绘制指令,然后把这些指令按照顺序组成一个待绘制列表。当图层的绘制列表准备好之后,主线程会把绘制列表交给合成线程。

# 栅格化操作

将图块转换为位图,而图块是栅格化执行的最小单位。渲染进程维护了一个栅格化的线程池,所有的图块栅格化都是在里面执行的。

# 合成和显示

一旦所有图块都栅格化,合成线程会生成一个绘制图块的命令,然后将该命令提交给浏览器进程。浏览器进程的viz组件接收合成线程发过来的命令。然后根据命令将其内容绘制到内存中,最后再将内存显示在屏幕上

# 性能优化策略

基于上面介绍的浏览器渲染原理,DOMCSSOM 结构构建顺序,初始化可以对页面渲染做些优化,提升页面性能。

  • JS优化: <script> 标签加上 defer属性 和 async属性 用于在不阻塞页面文档解析的前提下,控制脚本的下载和执行。defer属性:用于开启新的线程下载脚本文件,并使脚本在文档解析完成后执行,因此不会阻塞页面的渲染。async属性:HTML5新增属性,用于异步下载脚本文件,下载完毕立即解释执行代码。

TIP

defer特性 换句话说:

  • 具有 defer 特性的脚本不会阻塞页面。
  • 具有 defer 特性的脚本总是要等到 DOM 解析完毕,但在 DOMContentLoaded 事件之前执行。
  • 该特性仅适用于外部脚本,如果 <script> 脚本没有 src,则会忽略 defer 特性。
<script defer src="https://javascript.info/article/script-async-defer/long.js"></script>
<script defer src="https://javascript.info/article/script-async-defer/small.js"></script>
1
2

浏览器扫描页面寻找脚本,然后并行下载它们,以提高性能。因此,在上面的示例中,两个脚本是并行下载的。small.js 可能会先下载完成。但是,defer 特性除了告诉浏览器“不要阻塞页面”之外,还可以确保脚本执行的相对顺序。因此,即使small.js 先加载完成,它也需要等到 long.js 执行结束才会被执行。保证脚本的执行顺序,即按照它们在 HTML 文件中出现的顺序执行。在页面渲染完成后,会按照它们在 HTML 文件中出现的顺序执行脚本,因此适用于需要依赖其他脚本的脚本。

async特性 async 特性意味着脚本是完全独立的:

  • 浏览器不会因 async 脚本而阻塞(与 defer 类似)。
  • 其他脚本不会等待 async 脚本加载完成,同样,async 脚本也不会等待其他脚本。
  • DOMContentLoaded 和异步脚本不会彼此等待:
    • DOMContentLoaded 可能会发生在异步脚本之前(如果异步脚本在页面完成后才加载完成)
    • DOMContentLoaded 也可能发生在异步脚本之后(如果异步脚本很短,或者是从 HTTP 缓存中加载的)

换句话说,async 脚本会在后台加载,并在加载就绪时运行。DOM 和其他脚本不会等待它们,它们也不会等待其它的东西。async 脚本就是一个会在加载完成时执行的完全独立的脚本。异步加载脚本,但不保证脚本的执行顺序,即可能会出现多个脚本同时加载的情况。在脚本加载完成后,会立即执行脚本,不会阻塞页面的渲染,因此适用于不依赖其他脚本的单独脚本。如果有多个脚本需要依赖,可能会出现错误。

动态添加脚本 当脚本被附加到文档 (*) 时,脚本就会立即开始加载。默认情况下,动态脚本的行为是“异步”的。 也就是说:

  • 它们不会等待任何东西,也没有什么东西会等它们。
  • 先加载完成的脚本先执行(“加载优先”顺序)。

如果我们显式地设置了 script.async=false,则可以改变这个规则。然后脚本将按照脚本在文档中的顺序执行,就像 defer 那样。

  • CSS优化:<link> 标签的 rel属性 中的属性值设置为 preload

TIP

  • preload 是一种浏览器预加载技术,用于在页面渲染之前,提前加载一些资源(例如 JavaScriptCSS、字体等),以加速页面加载速度。preload 可以通过 <link> 标签或 HTTP 头来实现,具体实现方式如下:
<link rel="preload" href="style.css" as="style">

// 通过HTTP头实现:在服务器端设置HTTP头,以指定要预加载的资源。例如,以下代码可以预加载名为"script.js"的JavaScript文件:
Link: <script.js>; rel=preload; as=script
1
2
3
4
  • preconnect 则是一种浏览器预连接技术,用于在页面加载之前,预先建立与一些服务器的 TCP 连接,以加速资源的请求和响应速度。preconnect 可以通过<link> 标签或 HTTP 头来实现,具体实现方式如下:
<link rel="preconnect" href="https://example.com">

Link: <https://example.com>; rel=preconnect
1
2
3
  • prefetch 用于在浏览器的空闲时间请求资源

# 渲染过程总结

当浏览器获取HTML文件后,会自上而下加载并在加载过程中进行解析和渲染;加载就是获取资源的过程;如果在加载过程中遇到外部的css文件和图片,浏览器会另外发送一个请求,去获取css文件和图片,这个请求是异步的,并不会影响HTML文件的加载;但如果遇到JavaScript文件,HTML文件会挂起渲染的进程,等待JavaScript文件加载完毕后,再继续进行渲染。

  1. 浏览器解析HTMLCSSJavaScript脚本。等脚本加载后,通过 DOM APICSSOM API 来操作 DOM TreeCSS Rule Tree
  2. 解析完成后,浏览器引擎通过两种树结构来构造渲染树。渲染树只包含可见节点

TIP

  • 构建渲染树的过程,浏览器还做了一些关键的小动作:
    • DOM树的根节点开始遍历,筛选出所有可见的节点;
    • 仅针对可见节点,为其匹配CSSOM中的CSS规则;
    • 发射可见节点,连同其内容和计算样式。
  1. Layout回流:根据生成的渲染树,进行回流,得到节点的几何信息。
  2. 重绘:根据渲染树以及回流得到的几何信息,得到节点的绝对像素
  3. 将像素发给GPU进程,展示在页面上。

# 回流(Reflow,也叫重排)

是指当页面布局和几何属性改变时触发的浏览器行为,浏览器会重新计算并绘制元素的布局和几何属性。重排会导致整个页面的布局发生改变,非常消耗性能,所以要尽量避免频繁地进行页面布局的改变。回流一定会导致重绘

  • 什么情况会回流(为了求一个即时性和准确性)
    • 添加或者删除可见的DOM元素
    • 元素的位置发生变化
    • 元素的尺寸发生变化
    • 内容发生变化
    • 页面一开始的时候
    • 浏览器的窗口尺寸变化
    • 设置style属性的值
    • 计算offsetWidth/offsetHeight/clientWidth/等的属性
    • 调用getComputedStyle/currentStyle

# 重绘(Repaint)

是指当元素的样式发生改变,但不影响元素的布局和几何属性时触发的浏览器行为。在这种情况下,浏览器只需要重新绘制元素的外观而不需要重新计算布局和几何属性,因此比重排的代价小得多。重绘的代价比重排小,但如果频繁进行大量的重绘操作,也会对性能产生影响。重绘不一定会引起回流

TIP

  • 并且很多人不知道的是,重绘和回流其实也和 Eventloop 有关。
    • Eventloop 执行完 Microtasks 后,会判断 document 是否需要更新,因为浏览器是 60Hz 的刷新率,每 16.6ms 才会更新一次。
    • 然后判断是否有 resize 或者 scroll 事件,有的话会去触发事件,所以 resizescroll 事件也是至少 16ms 才会触发一次,并且自带节流功能。
    • 判断是否触发了 media query
    • 更新动画并且发送事件
    • 判断是否有全屏操作事件
    • 执行 requestAnimationFrame 回调
    • 执行 IntersectionObserver 回调,该方法用于判断元素是否可见,可以用于懒加载上,但是兼容性不好
    • 更新界面
    • 以上就是一帧中可能会做的事情。如果在一帧中有空闲时间,就会去执行 requestIdleCallback 回调。

# 减少重绘与回流

  • 合并多次对DOM和样式的修改,然后一次性处理掉
  • 使用transform替代topcss3的硬件加速,不会引起重绘回流。还有opacityfilters,Will-change这些属性)

TIP

opacity单词意思为透明度,直观视觉效果就是颜色变淡了,但最终显示的颜色其实仍然可以用RGB三个通道来表示,从数值运算的角度来看,它实际上表示了它采用一般混合策略和其他颜色进行混合时的比例。显示颜色 = 合入色 x opacity + 底色 x (1 - opacity)

  1. opacity这个属性本身就是用在重叠部分颜色处理的过程中使用的,对于分层的图原来说就可以看作是与图层内容无关的系数,因为合成过程中当前层中所有像素都需要经历上面的颜色混合公式,所以opacity的动画过程既不会影响布局,也不需要重绘。这样图层中保存的RGB像素数据的缓存在动画过程中也就不需要更新了,如果不使用opacity属性的话,每一帧对于变化部分都需要手动重计算RGB颜色值(这也就相当于是重绘了),因为这些区域的像素颜色一直都在变化,缓存也就没有意义。

  2. transform在动画过程中也不需要改变缓存的记录,而在图层合成时遍历当前层的点然后用矩阵matrix计算出对应的新坐标点就可以了,它也可以视作一种与图层内容无关的变换,图层中的元素首次生成的位图信息缓存可以被反复使用。比如一段平移动画,如果使用绝对定位+改变left值的方式来实现,就需要不断计算动画元素的布局并更新它的像素信息,但如果使用translate来实现,动画元素在文档流中的位置并不需要改变,无论后续平移到多远,都可以使用位图缓存中保存的初始位置信息,再加上变换矩阵的影响在层合并时计算出来,同样既不影响布局,也不需要重绘,这就是它高性能的原因。

transform动画由GPU控制,支持硬件加速

  • 使用visibility替换display: none,前者只会引起重绘,后者会引起回流(布局改变了)
  • 不要把节点的属性值放在一个循环里当成循环的变量
  • 不要使用table布局
  • 动画速度越快,回流次数越多,可以使用reuqestAnimationFrame
  • CSS避免节点层级过多
  • 将频繁重绘或者回流的节点设置为图层,图层能够阻止该节点的渲染行为影响别的节点
    • 使用will-change生成新图层
    • video/iframe标签
  • DOM一系列修改时,我们可以使元素脱离文档流,对其进行多次修改,然后将元素带回到文档中。因为这个时候修改,元素已经不在渲染树中。脱离文档流方式:
    • 隐藏元素,应用修改,重新显式
    • 使用文档片段(document fragment)在当前DOM之外构建一个子树,再把它拷贝回文档。
    • 将原始元素拷贝到一个脱离文档的节点中,修改节点后,再替换原始的元素。

# 为什么操作DOM慢

因为 DOM 是属于渲染引擎中的东西,而 JS 又是 JS 引擎中的东西。当我们通过 JS 操作 DOM 的时候,其实这个操作涉及到了两个线程之间的通信,那么势必会带来一些性能上的损耗。操作 DOM 次数一多,也就等同于一直在进行线程之间的通信,并且操作 DOM 可能还会带来重绘回流的情况,所以也就导致了性能上的问题。