浏览器渲染原理

79 阅读14分钟

如果你是一名Web开发者,那你肯定天天都跟浏览器打交道,你了解它吗?它怎么将你的页面渲染到电脑上的呢?了解它如何指导我们开发,如何让我们的代码能够快速、流畅地展示给你的用户呢?这边是本章的主题。

浏览器的组成

在谈浏览器怎么渲染到电脑上之前,先谈谈浏览器内部如何组成。从宏观上对浏览器有个大致的认识。浏览器的组成主要有如下几部分组成:

  • 用户界面: 用户界面主要包括地址栏,后退/前进按钮,书签菜单等。浏览器的每个部分都显示出来,除了主窗口,在主窗口中您可以看到所请求的页面。
  • 浏览器引擎: 浏览器引擎是浏览器的核心部件。主要将网页文件转换成在屏幕上可以看到并与之交互的视觉画面。浏览器引擎又分为如下几个主要模块:
    • 资源加载器:负责网络通信。在用户界面输入网络地址时,资源加载器会根据地址进行一系列处理获取资源。
    • 渲染引擎:负责将网页文件渲染到屏幕。它是本章的主角。
    • JS引擎:负责解析、编译和执行代码。如熟悉的执行上下文和事件循环等都在这里。
    • 数据持久层:管理浏览器存储的所有数据。例如localStorage、Cookie等。
    • UI后端:提供基本浏览器UI组件(如对话框、按钮、表单相关组件)的通用接口。 浏览器在渲染时各个模块之间相互紧密配合,如图所示:

image.png 浏览器中的渲染引擎到底是如何工作,使网页代码渲染到屏幕上的呢?接下便是本章的核心内容。

浏览器如何渲染

当用户输入地址后,浏览器引擎的资源加载器模块根据网址获取网页资源(HTML),将开启一个渲染的任务,此时渲染引擎将开始工作了。它的工作主要分为如下几个阶段: HTML解析样式计算布局分层绘制分块光栅化合成与显示

HTML解析

HTML解析的目的是将获取到的HTML字节转换成一个可以被容易理解且操作的文档对象模型树(DOM Tree)。在解析的 过程遇到CSS时,将获取到的CSS资源解析成CSSOM,为后续样式计算做好准备。

生成DOM Tree

从HTML字节到DOM Tree这个过程大致分为以下处理。字节 -> 字符 -> 令牌 -> 节点 -> DOM Tree。

1. 转换成字符

  1. 浏览器通过网络加载器获取HTML的原始字节数据
  2. 根据编码(一般是UTF-8)将字节编码成立一个个独立的字符。也就是浏览器开发开发工具的Network中Response看到字符串。

2. 字符到令牌

解析器会遍历字符,依据HTML规范将它们分解成有意义、带有类型的令牌(Tokens)。令牌就是HTML语法的基本单位。例如<html>(开始标签令牌)<!DOCTYPE html>(DOCTYPE令牌)</html>(结束标签令牌)<img/>(自闭合标签令牌)本文令牌属性令牌(标签上的属性)等。

3. 令牌到节点

在生成令牌的同时,会将这些令牌转换成节点对象。根据节点的特定类型生成不同类型的节点对象。例如元素节点文本节点注释节点等。

4. 构建DOM Tree

这是最核心的一步。利用栈的结构特点加上算法,处理节点之间的嵌套关系和纠正语法错误。大致过程如下:

  • 当遇到一个开始标签令牌,将会创建一个新的元素节点,将压入栈中,成为当前栈顶元素的子节点。
  • 当遇到文本令牌时,会创建一个文本节点,作为当前栈顶元素的子节点,由于文本节点下不可能子元素,因此不会压入栈。
  • 当遇到一个结束标签令牌,将对应的开始标签从栈中弹出。 在这样的循环反复的压入和弹出,从而构建出了具有父子关系的层级关系。最终形成了DOM Tree。例如<div><p>Hello</p></div>这个简单的例子解析情况,如图所示:

image.png

上述是DOM解析大致流程。但是有时候开发者编写的HTML并不是规范的。例如未正确标签闭合、标签嵌套错误等。在构建DOM Tree的阶段会自动为其补全缺失的标签。

在解析DOM时极有可能遇到了link或者script标签,看看浏览器是如何处理它们的。

浏览器在主线程解析HTML开始前,会启动一个预解析线程提前快速扫描HTML中外部CSS和JS文件。提前发起下载请求。

  • 遇到link

当解析到link标签时,CSS文件还没有下载解析好,主线程不会等待,将继续后续的解析。由于与预解析在处理下载和解析CSS,CSS是不会阻塞HTML的解析,但是最终渲染还是要等到样式树构建完成后才能合并成渲染树,CSS过多或者下载过慢会影响渲染时间

  • 遇到script

当解析到script标签时,根据标签的引入方式和标签属性,有如下情况:

  • 内联JS(无async/defer属性): 立即解析和执行。将阻塞DOM的解析
  • 外部引入:
    • 无async、defer属性: 主线程必须立刻停下,等待脚本下载和执行,然后继续解析HTML。因为JS有可能改变DOM结构。这边是以前开发都将script放入到body标签内最后的原因。
    • 带有async属性:脚本下载不会阻塞HTML解析,但下载完毕后立即执行,执行时仍然阻塞解析。一般引入一些独立的、不修改DOM和样式、无任何依赖其他脚本的脚本
    • 带有defer属性:脚本下载不会阻塞HTML解析,并且会延迟到整个DOM解析完成后、DOMContentLoaded事件触发前按顺序执行。

image.png

生成CSSOM

将所有CSS规则(浏览器默认样式、外部样式、内部样式、行内样式)转换为浏览器理解和高效查询的表与元素对应关系的数据结构。生成的过程与生成DOM Tree的过程类似。也有其独特之处。其过程为字节->字符->令牌->节点->CSSOM树。

1. 转换成字符

这一过程与解析HTML一样,读取CSS原始字节,根据字符编码解码成字符串。

2. 转换成令牌

将字符流分解成一系列令牌。例如 标识令牌(div, .class, #id)属性令牌(color, width,font-size)值令牌(red,100px, 50%)at-rule令牌(@media @support @font-face)分隔符令牌({,},:,;)

3. 转换成节点

将令牌转换特定的对象节点。每个规则都将转换成一个对象。

4. 构建CSSOM

这是关键的一步,CSSOM从结构来讲是一个哈希表。有如下特点:

  • 规则映射: 构建成一个可高效查询的索引结构,将CSS选择器映射到对应的样式声明。其目的主要是为后面样式计算,能快速地生成渲染树做好铺垫。
  • 级联: 这个也是CSS的核心机制之一。为后续样式计算,提供必要的信息,例如权重,规则的来源。以便后续计算出最终的规则。

样式计算

样式计算将会DOM Tree和所有CSS规则,经过一系列复杂计算和规则处理,最终合并成一个可渲染的样式集合。其步骤如下:

  • 收集样式规则: 将收集所有可能影响页面的样式的来源,包括:

    • 内部样式(<style>标签内的css)
    • 外部样式
    • 行内样式
    • 用户代理样式(浏览器默认样式)
    • 用户样式(用户通过浏览器设置的样式)
  • 标准化样式值: 由于值的写法有多种,例如颜色值:十六进制(如 #f00)、rgba、rgb等。字体大小:px、em等。需要通过统一的转换成标准的格式。颜色值都转换成rgb,字体大小都转换为px。有些值例外例如百分比的值,需要等到布局阶段根据父级元素决定。

  • 计算继承和层叠: 这是最核心和复杂的部分。它将决定同一个元素上的多个规则,哪个规则将'胜出'。还将为某些元素上没有设置且有继承性的属性,将会查看父级元素属性并继承下来。

    • 层叠: 将根据上一步CSSOM提供的来源和权重信息来决定谁将胜出。
    • 继承:对于某些没有在当前元素上明确设置且具有继承性的CSS属性(如 colorfont-familyline-height 等),浏览器会查看其父元素的计算样式,并将可继承的值传递下来。
  • 生成计算样式表: 经上述步骤后将会生成每个DOM元素上所有样式(几百个)的规则出来。可以通过window.getComputedStyle(element)方法查看某个元素的所有属性值。

布局

样式计算解决了DOM上是什么样式的问题后,布局阶段需要解决的核心问题是元素在屏幕什么位置,展示的大小的问题。它也称为重排(Reflow)。这便是性能优化需要特别注意的地方。现在看看布局阶段的工作。具体流程如下:

  • 生成渲染树(Render Tree): 渲染树是有DOM Tree和CSSOM树结合而来。将不会在屏幕上占据位置的DOM节点删除。例如包含display:none样式的元素和本身不可见的元素<head><meta><link><script>
  • 计算几何信息: 这里其实就是确定元素的盒子模型,将在样式计算阶段没有确定的值确定。例如width:50%相对值。需要获取包含块(父级)的宽度并计算出来。还有元素的横纵坐标值。
  • 建立坐标系和层级关系: 确定在屏幕中的坐标和z轴的层级关系。
  • 处理复杂的布局模型: 处理各种不同的布局上下文。

为什么要避免重排

在开发中,我们需要尽量避免重排,那为什么需要避免重排呢?

    1. 递归计算和全局性变化: 在布局阶段,通过递归从根元素开始遍历整个渲染树对元素进行计算几何信息。一个元素的几何信息的变化可能导致其子元素、兄弟元素甚至父元素都需要重新计算。这个称之为布局抖动
    1. 同步操作: 如果试图修改元素样式后,立即读取其几何属性值(left,offsetHeight,clientWidth等),为了给其准确的值,必须强制触发一次同步布局操作(强制重排)。这样的操作如果在循环中多次操作的话,会触发多次的布局计算,严重拖慢页面速度。

如何避免重排

  1. 在动画方面尽量使用transform,而不是left,top。隐藏显示使用opacity。它们不会触发重排。而是在合成阶段,此阶段是在GPU上进行的,效率极高。
  2. 尽量使用Flex和Grid布局,切勿使用表格布局。表格布局修改一个单元格,可能导致整个表格重排。
  3. 将读取DOM和修改DOM的操作分离。
  4. 使用documentFragment或者离线DOM进行大规模操作。
  5. 先隐藏元素后,操作完元素,再显示元素。
  6. 避免多次读取DOM元素的几何信息
// ❌ 不好:在循环中多次读取offsetLeft
for (let i = 0; i < 10; i++) {
  element.style.marginLeft = element.offsetLeft + 10 + 'px'; // 写之后又读,灾难!
}

// ✅ 好:先读取并缓存值
const initialLeft = element.offsetLeft; // 读一次
for (let i = 0; i < 10; i++) {
  initialLeft += 10;
  element.style.marginLeft = initialLeft + 'px'; // 只写
}
// 一次读,十次写,只触发一次重排(浏览器会将写操作合并)
  1. 尽量使用类名修改样式,而不是内联样式修改。 内联修改非常低效。

分层

分层是浏览器基于优化的考虑添加进来的。简单来讲,页面是一张画,他是由画在不同的'透明的玻璃'(图层)上,最终将这些玻璃按照顺序叠放在一起,然后呈现到屏幕上的。浏览器开发者工具中layers选项中。

image.png

决定谁为单独图层

它有一套规则来决定哪些元素提升到单独的合成层。如下元素会被提升到单独的图层:

  • 根元素<html>:它本身就是一个基础层
  • 拥有3D或透视变换的CSS属性:
    • transform: translate3d(x, y, z)
    • transform: translateZ(0)(通过这个属性强制创建一个层)
    • perspective
    • 使用加速视频解码的<video>元素
    • 拥有3D的WebGL上下文或加速2D上下文的<canvas>元素
    • opacity和transform被使用动画的元素
    • 拥有will-change属性的元素。如will-change: transform

创建图层树

通过遍历渲染树,按照上述规则,创建一颗对应的图层树。描述了哪些渲染对象是哪个图层的以及图层的层级。

警示

虽然分层对于性能上有一定的优化,但也要知晓它对内存的开销和管理的开销。因为每个图层都会为其分配显存来存储它的位图。图层越多内存占用越大。尤其是移动设备上。图层越多也增加了浏览器计算图层关系、上传数据到GPU的开销。不能盲目地将元素提升到图层,应通过will-change选择性地、在有必要的地方进行优化。

绘制

分层结束后,进入绘制阶段,此阶段将决定元素以什么顺序、用什么样式画出来。根据渲染树和几何信息为每一层生成一个绘制指令的列表。

什么是重绘

重绘就是元素样式发生变化且几何信息不发生改变时,浏览器重新绘制元素外观的过程。就像一个房子重新刷漆,但是房屋的结构和位置没有发生任何改动。重绘将再一次从绘制阶段开始进行渲染。

触发重绘的相关属性

当修改CSS以下属性时,将只触发重:

  • 颜色相关属性:colorbackground-colorborder-coloroutline-color
  • 背景:background-imagebackground-positionbackground-size
  • 盒子阴影:box-shadow
  • 透明度:opacity
  • visibility: 它与display:none是有区别。当visibility:hidden时,虽然看不见,但是物理位置还是占据着,因此它只会触发重绘。

分块

分块阶段也是优化而采用的一个关键策略。主要任务就是将一个大图层切割成一系列的小图块,并优先处理用户看到的部分。其具体工作如下:

  • 合成线程会创建一个网格,将每个图层切割成许多大小均匀的小正方形。每个正方形就是一个图块。
  • 在怎么切的问题上, 会按照可见视口以及周围的图块优先,其次才是远离视口的图块。
  • 最后合成线程会创建一个光栅化任务。按照优先级的顺序放入到任务队列中,等待光栅化线程池认领和执行。

光栅化

将每个图块的位置信息和相关绘制指令集转换GPU内存中的位图。这个任务由光栅化线程和GPU共同完成。

合成与显示

合成与显示是最后一个阶段。

  1. 合成线程获取到每个层、每个块的位置后,生成一个个指引(quad)信息。
  2. 指引会标识出每个位置应该显示到屏幕的哪个位置,以及考虑到旋转、缩放等变形。
  3. 变形发生的合成线程,与渲染主线无关,这就是transformopacity效果高的本质原因。
  4. 合成线程会把quad提交给GPU进程,由GPU进程产生系统调用,提交给GPU硬件,完成最终的屏幕成像。

小结

渲染器渲染原理至此告一段落。本文主要讲了以下内容:

  • 浏览器大致组成部分
  • 浏览器渲染步骤。分为:解析HTML、样式计算、布局、分层、绘制、分块、光栅化、合成与显示。对每个步骤是如何处理的。哪些步骤对于实际开发的影响。例如重排和重绘触发后,都从哪个阶段开始。在开发开发中什么会触发它们。如何避免重排等。 希望对前端开发的朋友们有一定帮助。如有不对之处给予指正。我将及时更正。Thanks♪(・ω・)ノ。