浏览器的渲染过程

前言

我们都知道在浏览器上打开一个新的选项卡并且输入目标网址,浏览器就会把目标网页呈现在我们面前,那么这段过程到底发生了什么?为了了解浏览器在这个过程中到底做了些什么,我去查了一点资料并且讲学到的知识总结下来。

渲染进程

了解渲染进程和渲染主线程是学习事件循环的前提,我们知道渲染进程启动后会启动一个渲染主线程来完成以下工作:

在网络进程发出HTTP|HTTPS请求到服务器获取到HTMLCSSJavaScript等资源之后,这些资源会被发送到渲染进程。渲染进程负责将这些资源转化为你在屏幕上看到的网页。这个过程包括以下几个步骤:

HTML解析

HTML的解析大概过程如下:

字节流->字符

首先从网络以字节流的形式获取到网页内容,浏览器内部的HTMLParser会将从网络获取的原始字节流依据指定的编码规则转变为字符。

拿到Tokens

当字节流转换字符结束后,浏览器会把这些字符转变为对应的Tokens,你可以理解为就是例如<head><title>以及其他用尖括号包裹的标签。

Tokens都有自己的含义和自己的一组规则,同时又会被分为 StartTag(<div>)EndTag(</div>) ,同时还有元素内部的文本内容

解析并且链接DOM

将Tokens解析为DOM节点,定义其属性和规则,然后将DOM节点添加到DOM树结构上。

在这个过程中,HTMLParser使用了一种名叫开放元素堆栈 的数据结构用来跟踪当前正在构建的元素。

我们上面说过Tokens 包含三种:StartTagEndTag对应元素的内容,下面逐个分析解析的大概流程:

  • StartTag :每当解析器遇到一个StartTag 时,它会将这个StartTag 压入栈中,并且生成相应的节点添加到Dom树上。这个StartTag的父节点就是先他一步弹出栈的相邻节点。
  • 文本内容 :如果碰到的是文本内容,那么会生成一个文本节点放到DOM树上。文本节点不需要放到栈内,他的父节点是当前栈顶的StartTag对应的节点。
  • EndTag :当解析器遇到一个EndTag 时,解析器先去查看栈顶是否是EndTag 对应的StartTag ,如果是的话从堆栈中取出栈顶的StartTag。这样就表明这个元素已经解析完成。

下面就是整个过程的流程图(Constructing the Object Model):

执行JS代码

因为在JavaScript中可能出现会影响到DOM树结构的代码,所以当浏览器遇到<script>标签时会停止HTML的解析,开始下载JavaScript文件。直到JS文件下载完成并且执行完毕才会继续进行HTML的解析。

当然上面的情况不是一定的,我们可以通过在script上挂载asyncdefer来更改默认的设置。
async: 当遇到包含async属性的script标签时,会异步加载js代码并且不会影响HTML的解析流程。但是当js代码请求完毕之后会中断HTML的解析,然后开始执行js代码。当js代码执行完毕之后继续HTML的解析。

async加载无序,所以最好不要添加在有依赖关系的js文件上。

defer: 当遇到包含defer属性的script标签时,同样异步加载js代码但是不会影响HTML的解析,当js代码下载完成之后会等待HTML解析完成 ,并在DOMContentLoaded事件之前执行defer的js代码

具体流程如下图所示:

CSS解析

当浏览器在在文档head部分遇到使用link外链的CSS样式文件时,浏览器会去请求该资源。

和 HTML内容 相同,从网络上获取到的也是0、1的字节流,浏览器无法处理这些字节流。所以在收到字节流之后,浏览器会转换为标准的CSS样式表,大概如下

css 复制代码
body {font-size: 16px;}
p {font-weight: bold;}
span {  color: red;}
....

内容不难理解,但是为了将CSS规则 转换为浏览器能够理解和处理的某种规则,CSS样式 字节会被依次转换为字符、令牌、节点,最后连接到一个被叫做CSSOM的树结构。

css的继承性,子类可以继承父类的某些样式

css的层叠性,多个选择器应用到同一个元素,浏览器会按照优先级规则,将属性值进行合并得到一个最终的样式值

这块内容可以具体了解一下

解析过程中如果遇到link引入CSS文件,会去异步请求下载CSS文件,下载的同时不会阻塞HTML的解析过程。一旦CSS文件下载完成,浏览器将应用这些样式,可能需要重新渲染页面以反映样式更改。

为什么JS和CSS的情况不一样?

因为CSS只是描述了应该怎么样呈现已经存在的HTML元素,它是用于定义网页元素的样式和布局,包括颜色、字体、间距、尺寸等,它的规则不会改变DOM的结构或者内容,所以CSS可以被异步加载。

JS代码有可能更改DOM的结构,比如说通过JS可以动态插入/删除一个HTML元素、处理一些用户的交互数据、动态生成内容插入页面。如果同时执行可能会导致JS尝试操作尚未创建的元素,从而导致错误或不一致的行为。

Render Tree

HTMLCSS 都解析完成之后,浏览器会将两者合并生成Render Tree ,这是一个跟DOM树类似的树状结构。

为了构建渲染树,浏览器大致会执行以下操作:

  • 从 DOM 树的根节点开始,遍历每个可见节点。

某些节点不可见(例如脚本标记、元标记等),因为它们不会反映在渲染的输出中,因此会被忽略。

某些节点通过 CSS 隐藏,渲染树中也会省略它们

  • 对于每个可见节点,会找到适当的匹配 CSSOM 规则并应用这些规则。
  • 发出可见节点以及内容及其计算的样式。

通过上面几步,Render Tree就可以决定呈现在屏幕上的元素和元素的样式。大致流程如下图:

虽然DOM TreeRender Tree 有一定的关联,但它们并不是一一对应。比如上面使用了display:none的元素不会出现在Render Tree

布局

布局阶段会计算渲染树中每个节点在屏幕上的位置和大小。

到这一步时,我们已经知道了网页上有哪些元素以及它们的呈现,但是我们仍不清楚它们在设备窗口的位置以及大小,所以我们需要进行布局

为了弄清楚每个元素的窗口的位置和大小,浏览器会从渲染树的根部遍历得到每个节点以得到位置以及大小数据,并且将所有相对测量值都将转换为屏幕上的绝对像素。

下面的代码是一个简单的例子

xml 复制代码
<!DOCTYPE html>
<html>
  <head>
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <style>
      body{
        background-color: bisque;
      }
      .parent{
        width: 20%;
        background-color: #f1f1f1;
        margin: 240px auto 0;
        height: 36px;
      }
      .child{
        width: 50%;
        background: black;
        color:white;
        height:100%;
      }
    </style>
    <title>hello</title>
  </head>
  <body>
    <div class="parent">
      <div class="child">Child</div>
    </div>
  </body>
</html>

上面网页正文中包含两个div父div的宽度占据窗口总宽的的25%,而子div的宽度又占据父div50%。经过浏览器的运作,我们就可以得到下面的场景 这只是一个简单的demo,仅仅只是让大家对布局过程 有一个认知。

当布局结束之后,我们可以得到一个包含每个节点位置以及节点大小信息的矩形集合,这个矩形集合被称为盒模型

这个集合由4个部分组成,分别是margin(外边距)、border(边框)、padding(内边距)和content(内容区域) 。我们还是用上面的父div来大致说明一下: 在这个盒模型 中,conetnt 因为宽度设置25%,这就导致了content的宽度为窗口宽度的25%也就是205px,又因为marign 设置240px auto 0px,所以content 处在窗口水平居中 并且上外边距=240px,下边距=0 的位置。除此之外borderpadding 均为0

因为页面上元素布局是相对的,当其中一个元素发生了变化(大小、边距),就需要重新计算计算各个节点和CSS得到具体的大小和位置。这个过程被称为回流

比较常见的可以导致回流有窗口大小改变、增加/移除样式表、通过JS操纵DOM、操作style/class、定位/浮动等等。

绘制

Paint()

在这个阶段,浏览器会通过渲染引擎的Paint()方法调用底层图形库对节点进行实际的绘制。Paint()会将节点的样式属性转换为绘制指令,以便将节点绘制为像素形式,由像素组成一个二维图像(位图Bitmap)。
Paint的绘制过程会遵循规定的顺序,这个顺序也称为堆叠顺序(Stacking Order)或层叠顺序(Z-index)。这个顺序决定了在堆叠上下文(Stacking Context)中元素的绘制顺序,从而影响元素在页面上的显示。 下面列出元素的绘制顺序:

  • 背景颜色(Background Color):首先绘制元素的背景颜色,填充整个元素的背景区域。
  • 背景图片(Background Image):接下来绘制元素的背景图片,覆盖在背景颜色之上。
  • 边框(Border):然后绘制元素的边框,边框绘制在背景之上。
  • 子元素(Children):绘制元素的子元素内容,包括文本、内联元素等。子元素的绘制顺序遵循相同的规则,先绘制背景颜色、背景图片和边框,然后绘制子元素的内容。
  • 轮廓(Outline):最后绘制元素的轮廓,轮廓绘制在子元素之上。

合成(Compositing)

浏览器合成阶段是渲染流程中的一个重要环节,在这个过程功能中浏览器会考虑每个节点的层叠关系、透明度和混合模式等,将它们合成为最终的像素结果。

图层(Layers)

合成(Compositing) 首先就需要先考虑图层,浏览器会根据图层的层次结构、位置和透明度等确定如何将不同图层组合形成最终的渲染结果。要知道的事不同图层的合成顺序和叠加方式会影响最终的呈现效果。

默认情况下,所有内容都从属一个图层也就是父节点的图层,但是在实际中浏览器允许将部分内容分离到新的图层中。

为什么浏览器会有这样的操作?这是因为单独的图层可以独立地进行绘制和合成,既可以实现平滑的动画效果也不会影响其他元素的绘制,同时还可以达到对复杂操作的优化。
一举三得 达成(1/1)

下面给大家展示一个比较简单的例子:

xml 复制代码
<!DOCTYPE html>
<html>
<head>
  <style>
    .layered {
      width: 200px;
      height: 200px;
      background-color: red;
      transition: transform 5s;
    }
  </style>
</head>
<body>
  <div class="layered"></div>

  <script>
    // 触发图层分离
    const element = document.querySelector('.layered');
    element.addEventListener('click', () => {
      element.style.transform = 'translateX(300px)';
    });
  </script>
</body>
</html>

内容并不复杂,将这个例子运行在Google Chrome 浏览器,通过开发者工具-找到图层-点击div ,我们就可以看到一个新的图层出现在原有图层外部。如果大家感兴趣可以复制代码试一下

可能有人要问了,那么什么样的操作可以达到分离图层的效果呢?这里就给大家列举几个常见的:

  • transform:为元素设置translatescalerotate等。适用于元素的变换和动画效果。
  • opacity:设置元素的透明度,适用于实现元素的渐变效果或淡入淡出效果。
  • filter:包含滤镜效果,比如模糊、对比度调整、色彩变换等。适用于图像处理和特效效果。
  • will-change:为元素设置 will-change,可以提示浏览器该属性将发生变化,使浏览器提前分配内存空间。
  • backface-visibility:将元素的背面可见性设置为可见 (backface-visibility: visible),仅适用于发生3D变换时会触发图层。
    通过将 backface-visibility 设置为 hidden,浏览器可以将元素重新合并到其所在的图层中,并进行更有效的绘制和合成。这可以减少图层数量,优化性能,并提高动画效果的流畅度。
  • perspectivetransform-style:结合使用这两个属性可以创建 3D 空间,并触发图层分离。适用于复杂的 3D 变换和动画效果。
  • position设置明确的定位属性:为position设置fixedabsolutesticky,通常会将其放置在单独的图层中。适用于创建悬浮元素或固定头部、底部等。
  • videocanvasiframe:这些元素通常会被浏览器自动放置在单独的图层中,以便更高效地进行绘制和合成。
  • clip-path:当元素被裁剪时,浏览器会计算元素的边界和裁剪区域,然后只绘制裁剪区域内的内容。为了避免在每次绘制时都重新计算裁剪区域,浏览器会将裁剪操作放在一个单独的图层中进行处理。
  • mask:通过定义遮罩效果。适用于创建复杂的遮罩效果。

虽然图层可以提高性能,但是有得必有失,过度使用也会牺牲内存,建议谨慎使用。

浏览器会在合成阶段会将页面的不同图层分别进行光栅化 并且最后在合成线程(Compositor Thread) 的单独线程合成为展现在我们面前的页面。

光栅化:浏览器把图层中的图形和图像转换为屏幕上的像素并确定每个像素的颜色的过程。它是浏览器渲染引擎中合成阶段的一部分。这涉及像素的计算、颜色插值、抗锯齿处理和透明度混合等操作,以产生最终的渲染结果。

光栅化过程还可以受到硬件加速的支持,利用GPU的并行计算能力来加速光栅化操作,提高渲染性能。

如果页面发生了滚动,由于图层已经光栅化,它所要做的就是合成一个新帧。可以通过移动图层并合成新帧以相同的方式实现动画。

裁剪

如果一个页面的内容可能会很多很复杂,浏览器无法将网页的内容完整显示在屏幕上,超过屏幕的内容就会被裁剪掉。

当浏览器在合成阶段进行图层的裁剪和可视区域计算时,它会确定每个图层在屏幕上的可见范围。最终确定定了哪些图层的内容需要进行后续的合成和绘制以及它们在屏幕上的位置和尺寸。

这也很好理解,假设我们写下一个div并且在这个div内部写入大量文字,文字会超出div的范围导致页面出现问题,这个时候就需要隐掉多余的文字保证正常显示 我们可以修改一下,比如增加overflow: auto达到下面的效果: 这只是一个简单的例子帮助大家理解,实际开发中应该也没有这样的需求

我们可以看到div旁边出现了滚动条,需要知道滚动条也是一个单独的图层。

裁剪过程主要被分为3个过程,下面我们简单的描述一下:
裁剪区域(Clipping Region) :每个图层都可以定义裁剪区域,用于限制图层内容的可见范围。浏览器会计算每个图层的裁剪区域,以确保图层可以正确的被绘制和合成。
可视区域(Visible Area) :这里说的可视区域指的是图层在屏幕上实际可见的部分,我个人理解为图层叠加之后与视口(viewport)的交集,视口表示浏览器窗口或容器元素的可见区域。超过视口的内容将会被视为不可见区域。
剪裁(Clipping):在经过裁剪区域和可视区域的计算后,浏览器就可以通过计算得到需要被裁剪的内容。这意味着只有在可视区域内的像素才会被保留,超出可视区域的像素将被丢弃。

经过上面3个步骤之后,浏览器可以确定每个图层在屏幕上的可见范围,并且只对可见的部分进行后续操作。

裁剪是为了减少不必要的工作量,毕竟超过可视区域的内容实在没有必要即时渲染。这样做也可以提高渲染性能,并确保只显示用户实际可见的内容。

显示

在上一步得到最终的合成结果后,浏览器会将合成结果进行光栅化 从而得到大量像素点的位置、大小和颜色信息,并且将结果存储在GPU中。

而GPU会将每个像素点的信息通过显示接口发送给显示器转化为可见的图像。显示器的工作是控制每个像素的亮度和颜色,以呈现出完整的图像。

最终经过一系列阶段,包括样式计算、布局、绘制、光栅化和合成,编写好的页面文件将被渲染为Web页面,并由显示器显示在屏幕上。

额外工作

回流/重绘

首先要知道回流和重绘并不是一个概念,两者会造成不一样的效果 回流 :也叫做重排 ,主打的就是元素几何属性 的改变,比如widthheightpositionborder等等。
回流的过程 :如果一个节点的几何属性 发生改变,那么就会触发整个渲染书进行重新渲染。可以理解为先把整个页面空白,然后重新进行渲染也就是从左上角开始一个像素一个像素的填充直到页面右下角的像素填充完毕。这个过程很快并且持续进行,正常情况下现代浏览器每秒钟渲染大约60次左右,即每帧渲染时间为约16.7毫秒。

一般来说,人眼可以识别的帧数为每秒24帧左右,也就是说每一帧的渲染时间约41.67ms,如果一帧的渲染时间超过41.67ms,就会造成肉眼可见的卡顿从而影响用户体验。
重烩 :指改变外观属性 而不影响几何属性 的渲染,也就是渲染树的节点发生改变,但不影响节点的几何属性。比如说background-colormaskbox-shadowtextfont等。
回流/重绘有一个规则定理: 回流一定重绘,重绘不一定回流。也很好理解,在回流过程中一定会重新渲染元素的外观样式;而相反,我只是修改了外观样式不一定会修改元素的几何属性。

或许我们可以举一个生活中的例子来帮助理解:

你需要给一个朋友寄一个包裹,你找到了一个快递箱准备放包裹,然后你觉得普通的快递箱实在太丑了,不符合你的审美所以你进行了艺术涂鸦。

结果等你放包裹的时候发现快递箱太小了,根本放不进去,怎么办?

只能再重新找一个快递箱并且把包裹放进去然后封好,这个时候你又不得不重新进行一遍艺术涂鸦来让这个快递箱漂亮一点。

上面就是回流的过程,可以看到很复杂,又找快递箱又涂鸦结果一旦尺寸不合适又得重新来一遍,简直累死。

那么重绘呢?

你突然发现快递箱上的涂鸦不好看了,于是你涂抹掉重新画了一个满意的点点头,根本没有什么再找一个快递箱再涂鸦的繁琐操作,简直完美。

性能问题

回流重绘在操作节点样式时频繁出现,同时也存在很大程度上的性能问题。回流成本比重绘成本高得多,一个节点的回流很有可能导致子节点、兄弟节点或祖先节点的回流,听着就让人感到恐慌。

设备的性能不错的情况下尚不明显,而一旦设备性能拉垮简直就是灾难。

这两个过程都会消耗计算资源,因此优化回流和重绘可以提高页面的性能和响应速度。以下是一些优化回流和重绘的方法:

  • 使用 CSS3 动画和过渡:CSS3 动画和过渡通过 GPU 加速,可以减少回流和重绘的开销。
  • 使用 transform 替代 toplefttransform 属性可以通过 CSS 变换实现元素的位移,而不会引起回流,因此比使用 topleft 属性更高效。
  • 批量修改样式:避免频繁修改单个元素的样式,而是将需要修改的样式集中在一起,通过修改元素的 className 或使用 classList 批量添加/移除类名的方式,减少回流次数。
  • 使用虚拟文档片段:在 DOM 中进行复杂的操作时,可以使用虚拟文档片段(DocumentFragment)进行处理,最后再将整个片段添加到 DOM 中,减少回流次数。
  • 避免强制同步布局:在读取元素的几何属性(如 offsetTopoffsetLeftoffsetWidthoffsetHeight)时,会触发强制同步布局,建议尽量避免频繁读取这些属性。
  • 避免频繁修改文档流中的节点:频繁添加、删除或改变文档流中的节点会导致大量的回流和重绘,尽量避免这种操作。
  • 使用事件委托:将事件处理程序绑定到父元素上,利用事件冒泡机制,减少事件处理程序的数量,提高性能。

上面的只是列举了几点,具体的内容有空再出一篇文章讲讲吧。

另外查资料的时候还看到一个问题,是看到别人提出来的,先放到这里下次看到的时候在查一下资料看看:

为什么渲染进程不适用多个渲染线程处理任务?

相关推荐
正小安1 小时前
如何在微信小程序中实现分包加载和预下载
前端·微信小程序·小程序
_.Switch3 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j
一路向前的月光3 小时前
Vue2中的监听和计算属性的区别
前端·javascript·vue.js
长路 ㅤ   3 小时前
vite学习教程06、vite.config.js配置
前端·vite配置·端口设置·本地开发
长路 ㅤ   3 小时前
vue-live2d看板娘集成方案设计使用教程
前端·javascript·vue.js·live2d
Fan_web3 小时前
jQuery——事件委托
开发语言·前端·javascript·css·jquery
安冬的码畜日常3 小时前
【CSS in Depth 2 精译_044】第七章 响应式设计概述
前端·css·css3·html5·响应式设计·响应式
莹雨潇潇4 小时前
Docker 快速入门(Ubuntu版)
java·前端·docker·容器
Jiaberrr4 小时前
Element UI教程:如何将Radio单选框的圆框改为方框
前端·javascript·vue.js·ui·elementui
Tiffany_Ho5 小时前
【TypeScript】知识点梳理(三)
前端·typescript