从渲染原理谈前端性能优化

31次阅读

共计 16587 个字符,预计需要花费 42 分钟才能阅读完成。

作者:李佳晓 原文:学而思网校技术团队

前言

合格的开发者知道怎么做,而优秀的开发者知道为什么这么做。

这句话来自《web 性能权威指南》,我一直很喜欢,而本文尝试从浏览器渲染原理探讨如何进行性能提升。
全文将从网络通信以及页面渲染两个过程去探讨浏览器的行为及在此过程中我们可以针对那些点进行优化,有些的不足之处还请各位不吝雅正。

一、关于浏览器渲染的容易误解点总结

关于浏览器渲染机制已经是老生常谈,而且网上现有资料中有非常多的优秀资料对此进行阐述。遗憾的是网上的资料良莠不齐,经常在不同的文档中对同一件事的描述出现了极大的差异。怀着严谨求学的态度经过大量资料的查阅和请教,将会在后文总结出一个完整的流程。

1、DOM 树的构建是文档加载完成开始的?

DOM 树的构建是从接受到文档开始的,先将字节转化为字符,然后字符转化为标记,接着标记构建 dom 树。这个过程被分为标记化和树构建
而这是一个渐进的过程。为达到更好的用户体验,呈现引擎会力求尽快将内容显示在屏幕上。它不必等到整个 HTML 文档解析完毕之后,就会开始构建呈现树和设置布局。在不断接收和处理来自网络的其余内容的同时,呈现引擎会将部分内容解析并显示出来。
参考文档:
http://taligarsiel.com/Projec…

2、渲染树是在 DOM 树和 CSS 样式树构建完毕才开始构建的吗?

这三个过程在实际进行的时候又不是完全独立,而是会有交叉。会造成一边加载,一边解析,一边渲染的工作现象。
参考文档:

http://www.jianshu.com/p/2d52…

3、css 的标签嵌套越多,越容易定位到元素

css 的解析是自右至左逆向解析的,嵌套越多越增加浏览器的工作量,而不会越快。
因为如果正向解析,例如「div div p em」,我们首先就要检查当前元素到 html 的整条路径,找到最上层的 div,再往下找,如果遇到不匹配就必须回到最上层那个 div,往下再去匹配选择器中的第一个 div,回溯若干次才能确定匹配与否,效率很低。
逆向匹配则不同,如果当前的 DOM 元素是 div,而不是 selector 最后的 em,那只要一步就能排除。只有在匹配时,才会不断向上找父节点进行验证。
打个比如 p span.showing
你认为从一个 p 元素下面找到所有的 span 元素并判断是否有 class showing 快,还是找到所有的 span 元素判断是否有 class showing 并且包括一个 p 父元素快
参考文档:
http://www.imooc.com/code/4570

二、页面渲染的完整流程

当浏览器拿到 HTTP 报文时呈现引擎将开始解析 HTML 文档,并将各标记逐个转化成“内容树”上的 DOM 节点。同时也会解析外部 CSS 文件以及样式元素中的样式数据。HTML 中这些带有视觉指令的样式信息将用于创建另一个树结构:呈现树。浏览器将根据呈现树进行布局绘制。

以上就是页面渲染的大致流程。那么浏览器从用户输入网址之后到底做了什么呢?以下将会进行一个完整的梳理。鉴于本文是前端向的所以梳理内容会有所偏重。而从输入到呈现可以分为两个部分:网络通信 页面渲染

我们首先来看网络通信部分:

1、用户输入 url 并敲击回车。

2、进行 DNS 解析。

如果用户输入的是 ip 地址则直接进入第三条。但去记录毫无规律且冗长的 ip 地址显然不是易事,所以通常都是输入的域名,此时就会进行 dns 解析。所谓 DNS(Domain Name System)指域名系统。因特网上作为域名和 IP 地址相互映射的一个分布式数据库,能够使用户更方便的访问互联网,而不用去记住能够被机器直接读取的 IP 数串。通过主机名,最终得到该主机名对应的 IP 地址的过程叫做域名解析(或主机名解析)。这个过程如下所示:

浏览器会首先搜索浏览器自身的 DNS 缓存(缓存时间比较短,大概只有 2 分钟左右,且只能容纳 1000 条缓存)。

  • 如果浏览器自身缓存找不到则会查看系统的 DNS 缓存, 如果找到且没有过期则停止搜索解析到此结束.
  • 而如果本机没有找到 DNS 缓存,则浏览器会发起一个 DNS 的系统调用,就会向本地配置的首选 DNS 服务器发起域名解析请求(通过的是 UDP 协议向 DNS 的 53 端口发起请求,这个请求是递归的请求,也就是运营商的 DNS 服务器必须得提供给我们该域名的 IP 地址),运营商的 DNS 服务器首先查找自身的缓存,找到对应的条目,且没有过期,则解析成功。
  • 如果没有找到对应的条目,则有运营商的 DNS 代我们的浏览器发起迭代 DNS 解析请求,它首先是会找根域的 DNS 的 IP 地址(这个 DNS 服务器都内置 13 台根域的 DNS 的 IP 地址),找打根域的 DNS 地址,就会向其发起请求(请问 www.xxxx.com 这个域名的 IP 地址是多少啊?)
  • 根域发现这是一个顶级域 com 域的一个域名,于是就告诉运营商的 DNS 我不知道这个域名的 IP 地址,但是我知道 com 域的 IP 地址,你去找它去,于是运营商的 DNS 就得到了 com 域的 IP 地址,又向 com 域的 IP 地址发起了请求(请问 www.xxxx.com 这个域名的 IP 地址是多少?),com 域这台服务器告诉运营商的 DNS 我不知道 www.xxxx.com 这个域名的 IP 地址,但是我知道 xxxx.com 这个域的 DNS 地址,你去找它去,于是运营商的 DNS 又向 linux178.com 这个域名的 DNS 地址(这个一般就是由域名注册商提供的,像万网,新网等)发起请求(请问 www.xxxx.com 这个域名的 IP 地址是多少?),这个时候 xxxx.com 域的 DNS 服务器一查,诶,果真在我这里,于是就把找到的结果发送给运营商的 DNS 服务器,这个时候运营商的 DNS 服务器就拿到了 www.xxxx.com 这个域名对应的 IP 地址,并返回给 Windows 系统内核,内核又把结果返回给浏览器,终于浏览器拿到了 www.xxxx.com 对应的 IP 地址, 这次 dns 解析圆满成功。

3、建立 tcp 连接

拿到域名对应的 IP 地址之后,User-Agent(一般是指浏览器)会以一个随机端口(1024< 端口 < 65535)向服务器的 WEB 程序(常用的有 httpd,nginx 等)80 端口发起 TCP 的连接请求。这个连接请求(原始的 http 请求经过 TCP/IP4 层模型的层层封包)到达服务器端后(这中间通过各种路由设备,局域网内除外),进入到网卡,然后是进入到内核的 TCP/IP 协议栈(用于识别该连接请求,解封包,一层一层的剥开),还有可能要经过 Netfilter 防火墙(属于内核的模块)的过滤,最终到达 WEB 程序,最终建立了 TCP/IP 的连接。

tcp 建立连接和关闭连接均需要一个完善的确认机制,我们一般将连接称为三次握手,而连接关闭称为四次挥手。而不论是三次握手还是四次挥手都需要数据从客户端到服务器的一次完整传输。将数据从客户端到服务端经历的一个完整时延包括:

  • 发送时延:把消息中的所有比特转移到链路中需要的时间,是消息长度和链路速度的函数
  • 传播时延:消息从发送端到接受端需要的时间,是信号传播距离和速度的函数
  • 处理时延:处理分组首部,检查位错误及确定分组目标所需的时间
  • 排队时延:到来的分组排队等待处理的时间以上的延迟总和就是客户端到服务器的总延迟时间

以上的延迟总和就是客户端到服务器的总延迟时间。因此每一次的连接建立和断开都是有巨大代价的。因此去掉不必要的资源和资源合并(包括 js 及 css 资源合并、雪碧图等)才会成为性能优化绕不开的方案。但是好消息是随着协议的发展我们将对性能优化这个主题有着新的看法和思考。虽然还未到来,但也不远了。如果你感到好奇那就接着往下看。

以下简述下 tcp 建立连接的过程:

  • 第一次握手:客户端发送 syn 包(syn=x,x 为客户端随机序列号)的数据包到服务器,并进入 SYN_SEND 状态,等待服务器确认;
  • 第二次握手:服务器收到 syn 包,必须确认客户的 SYN(ack=x+1),同时自己也发送一个 SYN 包(syn=y,y 为服务端生成的随机序列号),即 SYN+ACK 包,此时服务器进入 SYN_RECV 状态;
  • 第三次握手:客户端收到服务器的 SYN+ACK 包,向服务器发送确认包 ACK(ack=y+1)

此包发送完毕,客户端和服务器进入 ESTABLISHED 状态,完成三次握手。握手过程中传送的包里不包含数据,三次握手完毕后,客户端与服务器才正式开始传送数据。理想状态下,TCP 连接一旦建立,在通信双方中的任何一方主动关闭连接之前,TCP 连接都将被一直保持下去

这里注意,三次握手是不携带数据的,而是在握手完毕才开始数据传输。因此如果每次数据请求都需要重新进行完整的 tcp 连接建立,通信时延的耗时是难以估量的!这也就是为什么我们总是能听到资源合并减少请求次数的原因。

下面来看看 HTTP 如何在协议层面帮我们进行优化的:

HTTP1.0

在 http1.0 时代,每个 TCP 连接只能发送一个请求。发送数据完毕,连接就关闭,如果还要请求其他资源,就必须再新建一个连接。TCP 连接的新建成本很高,因为需要客户端和服务器三次握手,并且开始时发送速率较慢(TCP 的拥塞控制开始时会启动慢启动算法)。在数据传输的开始只能发送少量包,并随着网络状态良好(无拥塞)指数增长。但遇到拥塞又要重新从 1 个包开始进行传输。

以下图为例,慢启动时第一次数据传输只能传输一组数据,得到确认后传输 2 组,每次翻倍,直到达到阈值 16 时开始启用拥塞避免算法,既每次得到确认后数据包只增加一个。当发生网络拥塞后,阈值减半重新开始慢启动算法。

因此为避免 tcp 连接的三次握手耗时及慢启动引起的发送速度慢的情况,应尽量减少 tcp 连接的次数。

而 HTTP1.0 每个数据请求都需要重新建立连接的特点使得 HTTP 1.0 版本的性能比较差。随着网页加载的外部资源越来越多,这个问题就愈发突出了。为了解决这个问题,有些浏览器在请求时,用了一个非标准的 Connection 字段。Kepp-alive 一个可以复用的 TCP 连接就建立了,直到客户端或服务器主动关闭连接。但是,这不是标准字段,不同实现的行为可能不一致,因此不是根本的解决办法。

HTTP1.1

http1.1(以下简称 h1.1)版的最大变化,就是引入了持久连接(persistent connection),即 TCP 连接默认不关闭,可以被多个请求复用,不用声明 Connection: keep-alive。客户端和服务器发现对方一段时间没有活动,就可以主动关闭连接。不过,规范的做法是,客户端在最后一个请求时,发送 Connection: close,明确要求服务器关闭 TCP 连接。目前,对于同一个域名,大多数浏览器允许同时建立 6 个持久连接。相比与 http1.0,1.1 的页面性能有了巨大提升,因为省去了很多 tcp 的握手挥手时间。下图第一种是 tcp 建立后只能发一个请求的 http1.0 的通信状态,而拥有了持久连接的 h1.1 则避免了 tcp 握手及慢启动带来的漫长时延。

从图中可以看到相比 h1.0,h1.1 的性能有所提升。然而虽然 1.1 版允许复用 TCP 连接,但是同一个 TCP 连接里面,所有的数据通信是按次序进行的。服务器只有处理完一个回应,才会进行下一个回应。要是前面的回应特别慢,后面就会有许多请求排队等着。这称为 ” 队头堵塞 ”(Head-of-line blocking)。为了避免这个问题,只有三种方法:一是减少请求数,二是同时多开持久连接。这导致了很多的网页优化技巧,比如合并脚本和样式表、将图片嵌入 CSS 代码、域名分片(domain sharding)等等。如果 HTTP 协议能继续优化,这些额外的工作是可以避免的。三是开启 pipelining,不过 pipelining 并不是救世主,它也存在不少缺陷:

    • pipelining 只能适用于 http1.1,一般来说,支持 http1.1 的 server 都要求支持 pipelining
    • 只有幂等的请求(GET,HEAD)能使用 pipelining,非幂等请求比如 POST 不能使用,因为请求之间可能会存在先后依赖关系。
    • head of line blocking 并没有完全得到解决,server 的 response 还是要求依次返回,遵循 FIFO(first in first out)原则。也就是说如果请求 1 的 response 没有回来,2,3,4,5 的 response 也不会被送回来。
    • 绝大部分的 http 代理服务器不支持 pipelining。和不支持 pipelining 的老服务器协商有问题。可能会导致新的队首阻塞问题。

    鉴于以上种种原因,pipelining 的支持度并不友好。可以看看 chrome 对 pipelining 的描述:

    https://www.chromium.org/deve…

    HTTP2

    2015 年,HTTP/2 发布。它不叫 HTTP/2.0,是因为标准委员会不打算再发布子版本了,下一个新版本将是 HTTP/3。HTTP2 将具有以下几个主要特点:

    • 二进制协议:HTTP/1.1 版的头信息肯定是文本(ASCII 编码),数据体可以是文本,也可以是二进制。HTTP/2 则是一个彻底的二进制协议,头信息和数据体都是二进制,并且统称为 ” 帧 ”(frame):头信息帧和数据帧。
    • 多工:HTTP/2 复用 TCP 连接,在一个连接里,客户端和浏览器都可以同时发送多个请求或回应,而且不用按照顺序一一对应,这样就避免了 ” 队头堵塞 ”。
    • 数据流:因为 HTTP/2 的数据包是不按顺序发送的,同一个连接里面连续的数据包,可能属于不同的回应。因此,必须要对数据包做标记,指出它属于哪个回应。HTTP/2 将每个请求或回应的所有数据包,称为一个数据流(stream)。每个数据流都有一个独一无二的编号。数据包发送的时候,都必须标记数据流 ID,用来区分它属于哪个数据流。另外还规定,客户端发出的数据流,ID 一律为奇数,服务器发出的,ID 为偶数。数据流发送到一半的时候,客户端和服务器都可以发送信号(RST_STREAM 帧),取消这个数据流。1.1 版取消数据流的唯一方法,就是关闭 TCP 连接。这就是说,HTTP/2 可以取消某一次请求,同时保证 TCP 连接还打开着,可以被其他请求使用。客户端还可以指定数据流的优先级。优先级越高,服务器就会越早回应。
    • 头信息压缩:HTTP 协议不带有状态,每次请求都必须附上所有信息。所以,请求的很多字段都是重复的,比如 Cookie 和 User Agent,一模一样的内容,每次请求都必须附带,这会浪费很多带宽,也影响速度。HTTP2 对这一点做了优化,引入了头信息压缩机制(header compression)。一方面,头信息使用 gzip 或 compress 压缩后再发送;另一方面,客户端和服务器同时维护一张头信息表,所有字段都会存入这个表,生成一个索引号,以后就不发送同样字段了,只发送索引号,这样就提高速度了。
    • 服务器推送:HTTP/2 允许服务器未经请求,主动向客户端发送资源,这叫做服务器推送(server push)。常见场景是客户端请求一个网页,这个网页里面包含很多静态资源。正常情况下,客户端必须收到网页后,解析 HTML 源码,发现有静态资源,再发出静态资源请求。其实,服务器可以预期到客户端请求网页后,很可能会再请求静态资源,所以就主动把这些静态资源随着网页一起发给客户端了。

    就这几个点我们分别讨论一下:
    就多工来看:虽然 http1.1 支持了 pipelining,但是仍然会有队首阻塞问题,如果浏览器同时发出 http 请求请求和 css,服务器端处理 css 请求耗时 20ms,但是因为先请求资源是 html,此时的 css 尽管已经处理好了但仍不能返回,而需要等待 html 处理好一起返回,此时的客户端就处于盲等状态,而事实上如果服务器先处理好 css 就先返回 css 的话,浏览器就可以开始解析 css 了。而多工的出现就解决了 http 之前版本协议的问题,极大的提升了页面性能。缩短了通信时间。我们来看看有了多工之后有那些影响:

    • 无需进行资源分片:为了避免请求 tcp 连接耗时长的和初始发送速率低的问题,浏览器允许同时打开多个 tcp 连接让资源同时请求。但是为了避免服务器压力,一般针对一个域名会有最大并发数的限制,一般来说是 6 个。允许一个页面同时对相同域名打开 6 个 tcp 连接。为了绕过最大并发数的限制,会将资源分布在不同的域名下,避免资源在超过并发数后需要等待才能开始请求。而有了 http2,可以同步请求资源,资源分片这种方式就可以不再使用。
    • 资源合并:资源合并会不利于缓存机制,因为单文件修改会影响整个资源包。而且单文件过大对于 HTTP/2 的传输不好,尽量做到细粒化更有利于 HTTP/2 传输。而且内置资源也是同理,将资源以 base64 的形式放进代码中不利于缓存。且编码后的图片资源大小是要超过图片大小的。这两者都是以减少 tcp 请求次数增大单个文件大小来进行优化的。

    就头部压缩来看:HTTP/1.1 版的头信息是 ASCII 编码,也就是不经过压缩的,当我们请求只携带少量数据时,http 头部可能要比载荷要大许多,尤其是有了很长的 cookie 之后这一点尤为显著,头部压缩毫无疑问可以对性能有很大提升。

    就服务器推送来看:少去了资源请求的时间,服务端可以将可能用到的资源推送给服务端以待使用。这项能力几乎是革新了之前应答模式的认知,对性能提升也有巨大帮助。

    因此很多优化都是在基于 tcp 及 http 的一些问题来避免和绕过的。事实上多数的优化都是针对网络通信这个部分在做。

    4、建立 TCP 连接后发起 http 请求

    5、服务器端响应 http 请求,浏览器得到 html 代码

    以上是网络通信部分,接下来将会对页面渲染部分进行叙述。

    • 当浏览器拿到 HTML 文档时首先会进行 HTML 文档解析,构建 DOM 树。
    • 遇到 css 样式如 link 标签或者 style 标签时开始解析 css,构建样式树。HTML 解析构建和 CSS 的解析是相互独立的并不会造成冲突,因此我们通常将 css 样式放在 head 中,让浏览器尽早解析 css。
    • 当 html 的解析遇到 script 标签会怎样呢?答案是停止 DOM 树的解析开始下载 js。因为 js 是会阻塞 html 解析的,是阻塞资源。其原因在于 js 可能会改变 html 现有结构。例如有的节点是用 js 动态构建的,在这种情况下就会停止 dom 树的构建开始下载解析 js。脚本在文档的何处插入,就在何处执行。当 HTML 解析器遇到一个 script 标记时,它会暂停构建 DOM,将控制权移交给 JavaScript 引擎;等 JavaScript 引擎运行完毕,浏览器会从中断的地方恢复 DOM 构建。而因此就会推迟页面首绘的时间。可以在首绘不需要 js 的情况下用 async 和 defer 实现异步加载。这样 js 就不会阻塞 html 的解析了。当 HTML 解析完成后,浏览器会将文档标注为交互状态,并开始解析那些处于“deferred”模式的脚本,也就是那些应在文档解析完成后才执行的脚本。然后,文档状态将设置为“完成”,一个“加载”事件将随之触发。

    注意,异步执行是指下载。执行 js 时仍然会阻塞。

    • 在得到 DOM 树和样式树后就可以进行渲染树的构建了。应注意的是渲染树和 DOM 元素相对应的,但并非一一对应。比如非可视化的 DOM 元素不会插入呈现树中,例如“head”元素。如果元素的 display 属性值为“none”,那么也不会显示在呈现树中(但是 visibility 属性值为“hidden”的元素仍会显示)

    渲染树构建完毕后将会进行布局。布局使用流模型的 Layout 算法。所谓流模型,即是指 Layout 的过程只需进行一遍即可完成,后出现在流中的元素不会影响前出现在流中的元素,Layout 过程只需从左至右从上至下一遍完成即可。但实际实现中,流模型会有例外。Layout 是一个递归的过程,每个节点都负责自己及其子节点的 Layout。Layout 结果是相对父节点的坐标和尺寸。其过程可以简述为:

    • 此时 renderTree 已经构建完毕,不过浏览器渲染树引擎并不直接使用渲染树进行绘制,为了方便处理定位(裁剪),溢出滚动(页内滚动),CSS 转换 / 不透明 / 动画 / 滤镜,蒙版或反射,Z(Z 排序)等,浏览器需要生成另外一棵树 – 层树。因此绘制过程如下:1、获取 DOM 并将其分割为多个层(RenderLayer) 2、将每个层栅格化,并独立的绘制进位图中 3、将这些位图作为纹理上传至 GPU 4、复合多个层来生成最终的屏幕图像(终极 layer)。

    三、HTML 及 CSS 样式的解析

    HTML 解析是一个将字节转化为字符,字符解析为标记,标记生成节点,节点构建树的过程。。CSS 样式的解析则由于复杂的样式层叠而变得复杂。对此不同的渲染引擎在处理上有所差异,后文将会就这点进行详细讲解

    1、HTML 的解析分为标记化和树构建两个阶段

    标记化算法:

    是词法分析过程,将输入内容解析成多个标记。HTML 标记包括起始标记、结束标记、属性名称和属性值。标记生成器识别标记,传递给树构造器,然后接受下一个字符以识别下一个标记;如此反复直到输入的结束。
    该算法的输出结果是 HTML 标记。该算法使用状态机来表示。每一个状态接收来自输入信息流的一个或多个字符,并根据这些字符更新下一个状态。当前的标记化状态和树结构状态会影响进入下一状态的决定。这意味着,即使接收的字符相同,对于下一个正确的状态也会产生不同的结果,具体取决于当前的状态。
    树构建算法:

    在树构建阶段,以 Document 为根节点的 DOM 树也会不断进行修改,向其中添加各种元素。
    标记生成器发送的每个节点都会由树构建器进行处理。规范中定义了每个标记所对应的 DOM 元素,这些元素会在接收到相应的标记时创建。这些元素不仅会添加到 DOM 树中,还会添加到开放元素的堆栈中。此堆栈用于纠正嵌套错误和处理未关闭的标记。其算法也可以用状态机来描述。这些状态称为“插入模式”。

    以下将会举一个例子来分析这两个阶段:

    标记化:初始状态是数据状态。

    • 遇到字符 < 时,状态更改为“标记打开状态”。接收一个 a- z 字符会创建“起始标记”,状态更改为“标记名称状态”。这个状态会一直保持到接收 > 字符。在此期间接收的每个字符都会附加到新的标记名称上。在本例中,我们创建的标记是 html 标记。
    • 遇到 > 标记时,会发送当前的标记,状态改回“数据状态”。标记也会进行同样的处理。目前 html 和 body 标记均已发出。现在我们回到“数据状态”。接收到 Hello world 中的 H 字符时,将创建并发送字符标记,直到接收 </body> 中的 <。我们将为 Hello world 中的每个字符都发送一个字符标记。
    • 现在我们回到“标记打开状态”。接收下一个输入字符 / 时,会创建 end tag token 并改为“标记名称状态”。我们会再次保持这个状态,直到接收 >。然后将发送新的标记,并回到“数据状态”。输入也会进行同样的处理。

    还是以上的例子,我们来看看树构建

    树构建:树构建阶段的输入是一个来自标记化阶段的标记序列。

    • 第一个模式是“initial mode”。接收 HTML 标记后转为“before html”模式,并在这个模式下重新处理此标记。这样会创建一个 HTMLHtmlElement 元素,并将其附加到 Document 根对象上。
    • 然后状态将改为“before head”。此时我们接收“body”标记。即使我们的示例中没有“head”标记,系统也会隐式创建一个 HTMLHeadElement,并将其添加到树中。
    • 现在我们进入了“in head”模式,然后转入“after head”模式。系统对 body 标记进行重新处理,创建并插入 HTMLBodyElement,同时模式转变为“body”。
    • 现在,接收由“Hello world”字符串生成的一系列字符标记。接收第一个字符时会创建并插入“Text”节点,而其他字符也将附加到该节点
    • 接收 body 结束标记会触发“after body”模式。现在我们将接收 HTML 结束标记,然后进入“after after body”模式。接收到文件结束标记后,解析过程就此结束。解析结束后的操作

    在此阶段,浏览器会将文档标注为交互状态,并开始解析那些处于“deferred”模式的脚本,也就是那些应在文档解析完成后才执行的脚本。然后,文档状态将设置为“完成”,一个“加载”事件将随之触发。

    完整解析过程如下图:

    2、CSS 的解析与层叠规则

    每一个呈现器都代表了一个矩形的区域,通常对应于相关节点的 CSS 框,这一点在 CSS2 规范中有所描述。它包含诸如宽度、高度和位置等几何信息。就是我们 CSS 里常提到的盒子模型。构建呈现树时,需要计算每一个呈现对象的可视化属性。这是通过计算每个元素的样式属性来完成的。由于应用规则涉及到相当复杂的层叠规则,所以给样式树的构建造成了巨大的困难。为什么说它复杂?因为同一个元素可能涉及多条样式,就需要判断最终到底哪条样式生效。首先我们来了解一下 css 的样式层叠规则

    ①层叠规则:

    根据不同的样式来源优先级排列从小到大:

    • 1>、用户端声明:来自浏览器的样式,被称作 UA style,是浏览器默认的样式。比如,对于 DIV 元素,浏览器默认其‘display’的特性值是“block”,而 SPAN 是“inline”。
    • 2>、一般用户声明:这个样式表是使用浏览器的用户,根据自己的偏好设置的样式表。比如,用户希望所有 P 元素中的字体都默认显示成蓝色,可以先定义一个样式表,存成 css 文件。
    • 3>、一般作者声明: 即开发者在开发网页时,所定义的样式表。
    • 4>、加了’!important’的作者声明
    • 5>、加了’!important’的用户声明

    !important 规则 1: 根据 CSS2.1 规范中的描述,’!important’可以提高样式的优先级,它对样式优先级的影响是巨大的。
    注意,’!important’规则在 IE7 以前的版本中是被支持不完善。因此,经常被用作 CSS hack2。

    如果来源和重要性相同则根据 CSS specificity 来进行判定。

    特殊性的值可以看作是一个由四个数组成的一个组合,用 a,b,c,d 来表示它的四个位置。依次比较 a,b,c,d 这个四个数比较其特殊性的大小。比如,a 值相同,那么 b 值大的组合特殊性会较大,以此类推。注意,W3C 中并不是把它作为一个 4 位数来看待的。
    a,b,c,d 值的确定规则:

    • 如果 HTML 标签的‘style’属性中该样式存在,则记 a 为 1;
    • 数一下选择器中 ID 选择器的个数作为 b 的值。比如,样式中包含‘#c1’和‘#c2’的选择器;
    • 其他属性以及伪类(pseudo-classes)的总数量是 c 的值。比如’.con’,’:hover’等;
    • 元素名和伪元素的数量是 d 的值

    在这里我们来看一个 W3C 给出的例子:

    那么在如下例子中字体的显示应当为绿色:

    总结为表格的话计算规则如下:

    ②CSS 解析

    为了简化样式计算,Firefox 还采用了另外两种树:规则树和样式上下文树。Webkit 也有样式对象,但它们不是保存在类似样式上下文树这样的树结构中,只是由 DOM 节点指向此类对象的相关样式。

    1>、Firefox 的规则树和样式上下文树:

    样式上下文包含端值。要计算出这些值,应按照正确顺序应用所有的匹配规则,并将其从逻辑值转化为具体的值。例如,如果逻辑值是屏幕大小的百分比,则需要换算成绝对的单位。规则树的点子真的很巧妙,它使得节点之间可以共享这些值,以避免重复计算,还可以节约空间。
    所有匹配的规则都存储在树中。路径中的底层节点拥有较高的优先级。规则树包含了所有已知规则匹配的路径。规则的存储是延迟进行的。规则树不会在开始的时候就为所有的节点进行计算,而是只有当某个节点样式需要进行计算时,才会向规则树添加计算的路径。
    这个想法相当于将规则树路径视为词典中的单词。如果我们已经计算出如下的规则树:

    假设我们需要为内容树中的另一个元素匹配规则,并且找到匹配路径是 B – E – I(按照此顺序)。由于我们在树中已经计算出了路径 A – B – E – I – L,因此就已经有了此路径,这就减少了现在所需的工作量。

    那么 Firefox 是如何解决样式计算难题的呢?接下来看一个样例,假设我们有如下 HTML 代码:

    并且我们有如下规则:

    为了简便起见,我们只需要填充两个结构:color 结构和 margin 结构。color 结构只包含一个成员(即“color”),而 margin 结构包含四条边。
    形成的规则树如下图所示(节点的标记方式为“节点名 : 指向的规则序号”):

    上下文树如下图所示(节点名 : 指向的规则节点):

    假设我们解析 HTML 时遇到了第二个 <div> 标记,我们需要为此节点创建样式上下文,并填充其样式结构。
    经过规则匹配,我们发现该 <div> 的匹配规则是第 1、2 和 6 条。这意味着规则树中已有一条路径可供我们的元素使用,我们只需要再为其添加一个节点以匹配第 6 条规则(规则树中的 F 节点)。
    我们将创建样式上下文并将其放入上下文树中。新的样式上下文将指向规则树中的 F 节点。
    现在我们需要填充样式结构。首先要填充的是 margin 结构。由于最后的规则节点 (F) 并没有添加到 margin 结构,我们需要上溯规则树,直至找到在先前节点插入中计算过的缓存结构,然后使用该结构。我们会在指定 margin 规则的最上层节点(即 B 节点)上找到该结构。
    我们已经有了 color 结构的定义,因此不能使用缓存的结构。由于 color 有一个属性,我们无需上溯规则树以填充其他属性。我们将计算端值(将字符串转化为 RGB 等)并在此节点上缓存经过计算的结构。
    第二个 元素处理起来更加简单。我们将匹配规则,最终发现它和之前的 span 一样指向规则 G。由于我们找到了指向同一节点的同级,就可以共享整个样式上下文了,只需指向之前 span 的上下文即可。
    对于包含了继承自父代的规则的结构,缓存是在上下文树中进行的(事实上 color 属性是继承的,但是 Firefox 将其视为 reset 属性,并缓存到规则树上)。
    例如,如果我们在某个段落中添加 font 规则:

    那么,该段落元素作为上下文树中的 div 的子代,就会共享与其父代相同的 font 结构(前提是该段落没有指定 font 规则)。

    2>、Webkit 的样式解析

    在 Webkit 中没有规则树,因此会对匹配的声明遍历 4 次。首先应用非重要高优先级的属性(由于作为其他属性的依据而应首先应用的属性,例如 display),接着是高优先级重要规则,然后是普通优先级非重要规则,最后是普通优先级重要规则。这意味着多次出现的属性会根据正确的层叠顺序进行解析。最后出现的最终生效。

    四、渲染树的构建

    样式树和 DOM 树连接在一起形成一个渲染树,渲染树用来计算可见元素的布局并且作为将像素渲染到屏幕上的过程的输入。值得一提的是,Gecko 将视觉格式化元素组成的树称为“框架树”。每个元素都是一个框架。Webkit 使用的术语是“渲染树”,它由“呈现对象”组成。Webkit 和 Gecko 使用的术语略有不同,但整体流程是基本相同的。

    接下来将来看一下两种渲染引擎的工作流程:
    Webkit 主流程:

    Mozilla 的 Gecko 呈现引擎主流程

    虽然 Webkit 和 Gecko 使用的术语略有不同,但整体流程是基本相同的。

    Gecko 将视觉格式化元素组成的树称为“框架树”。每个元素都是一个框架。
    Webkit 使用的术语是“呈现树”,它由“呈现对象”组成。
    对于元素的放置,Webkit 使用的术语是“布局”,而 Gecko 称之为“重排”。
    对于连接 DOM 节点和可视化信息从而创建呈现树的过程,Webkit 使用的术语是“附加”。有一个细微的非语义差别,就是 Gecko 在 HTML 与 DOM 树之间还有一个称为“内容槽”的层,用于生成 DOM 元素。我们会逐一论述流程中的每一部分。

    五、关于浏览器渲染过程中需要了解的概念

    Repaint(重绘)——屏幕的一部分要重画,比如某个 CSS 的背景色变了。但是元素的几何尺寸没有变。
    Reflow(重排)——意味着元件的几何尺寸变了,我们需要重新验证并计算 Render Tree。是 Render Tree 的一部分或全部发生了变化。这就是 Reflow,或是 Layout。reflow 会从这个 root frame 开始递归往下,依次计算所有的结点几何尺寸和位置,在 reflow 过程中,可能会增加一些 frame,比如一个文本字符串必需被包装起来。
    onload 事件——当 onload 事件触发时,页面上所有的 DOM,样式表,脚本,图片,flash 都已经加载完成了。
    DOMContentLoaded 事件——当 DOMContentLoaded 事件触发时,仅当 DOM 加载完成,不包括样式表,图片,flash。
    首屏时间——当浏览器显示第一屏页面所消耗的时间,在国内的网络条件下,通常一个网站,如果“首屏时间”在 2 秒以内是比较优秀的,5 秒以内用户可以接受,10 秒以上就不可容忍了。
    白屏时间——指浏览器开始显示内容的时间。但是在传统的采集方式里,是在 HTML 的头部标签结尾里记录时间戳,来计算白屏时间。在这个时刻,浏览器开始解析身体标签内的内容。而现代浏览器不会等待 CSS 树(所有 CSS 文件下载和解析完成)和 DOM 树(整个身体标签解析完成)构建完成才开始绘制,而是马上开始显示中间结果。所以经常在低网速的环境中,观察到页面由上至下缓慢显示完,或者先显示文本内容后再重绘成带有格式的页面内容。

    六、页面优化方案

    本文的主题在于从浏览器的渲染过程谈页面优化。了解浏览器如何通信并将拿到的数据如何进行解析渲染,本节将从网络通信、页面渲染、资源预取及如何除了以上方案外,如何借助 chrome 来针对一个页面进行实战优化四个方面来谈。

    从网络通信过程入手可以做的优化

    减少 DNS 查找

    每一次主机名解析都需要一次网络往返,从而增加请求的延迟时间,同时还会阻塞后续请求。

    重用 TCP 连接

    尽可能使用持久连接,以消除 TCP 握手和慢启动延迟;

    减少 HTTP 重定向

    HTTP 重定向极费时间,特别是不同域名之间的重定向,更加费时;这里面既有额外的 DNS 查询、TCP 握手,还有其他延迟。最佳的重定向次数为零。

    使用 CDN(内容分发网络)

    把数据放到离用户地理位置更近的地方,可以显著减少每次 TCP 连接的网络延迟,增大吞吐量。

    去掉不必要的资源

    任何请求都不如没有请求快。说到这,所有建议都无需解释。延迟是瓶颈,最快的速度莫过于什么也不传输。然而,HTTP 也提供了很多额外的机制,比如缓存和压缩,还有与其版本对应的一些性能技巧。

    在客户端缓存资源

    应该缓存应用资源,从而避免每次请求都发送相同的内容。(浏览器缓存)

    传输压缩过的内容

    传输前应该压缩应用资源,把要传输的字节减至最少:确保每种要传输的资源采用最好的压缩手段。(Gzip,减少 60%~80% 的文件大小)

    消除不必要的请求开销

    减少请求的 HTTP 首部数据(比如 HTTPcookie),节省的时间相当于几次往返的延迟时间。

    并行处理请求和响应

    请求和响应的排队都会导致延迟,无论是客户端还是服务器端。这一点经常被忽视,但却会无谓地导致很长延迟。

    针对协议版本采取优化措施

    HTTP 1.x 支持有限的并行机制,要求打包资源、跨域分散资源,等等。相对而言,
    HTTP 2.0 只要建立一个连接就能实现最优性能,同时无需针对 HTTP 1.x 的那些优化方法。
    但是压缩、使用缓存、减少 dns 等的优化方案无论在哪个版本都同样适用

    你需要了解的资源预取

    preload : 可以对当前页面所需的脚本、样式等资源进行预加载,而无需等到解析到 script 和 link 标签时才进行加载。这一机制使得资源可以更早的得到加载并可用,且更不易阻塞页面的初步渲染,进而提升性能。
    用法文档:

    https://developer.mozilla.org…

    prefetch:prefetch 和 preload 一样,都是对资源进行预加载,但是 prefetch 一般预加载的是其他页面会用到的资源。当然,prefetch 不会像 preload 一样,在页面渲染的时候加载资源,而是利用浏览器空闲时间来下载。当进入下一页面,就可直接从 disk cache 里面取,既不影响当前页面的渲染,又提高了其他页面加载渲染的速度。
    用法文档:

    https://developer.mozilla.org…

    subresource:被 Chrome 支持了有一段时间,并且已经有些搔到预加载当前导航 / 页面(所含有的资源)的痒处了。但它有一个问题——没有办法处理所获取内容的优先级(as 也并不存在),所以最终,这些资源会以一个相当低的优先级被加载,这使得它能提供的帮助相当有限

    prerender:prerender 就像是在后台打开了一个隐藏的 tab,会下载所有的资源、创建 DOM、渲染页面、执行 js 等等。如果用户进入指定的链接,隐藏的这个页面就会立马进入用户的视线。但是要注意,一定要在十分确定用户会点击某个链接时才使用该特性,否则客户端会无端的下载很多资源和渲染这个页面。正如任何提前动作一样,预判总是有一定风险出错。如果提前的动作是昂贵的(比如高 CPU、耗电、占用带宽),就要谨慎使用了。

    preconnect: preconnect 允许浏览器在一个 HTTP 请求正式发给服务器前预先执行一些操作, 这包括

    dns-prefetch:通过 DNS 预解析来告诉浏览器未来我们可能从某个特定的 URL 获取资源,当浏览器真正使用到该域中的某个资源时就可以尽快地完成 DNS 解析

    这些属性虽然并非所有浏览器都支持,但是不支持的浏览器也只是不处理而已,而是别的话则会省去很多时间。因此,合理的使用资源预取可以显著提高页面性能。

    高效合理的 css 选择符可以减轻浏览器的解析负担。

    因为 css 是逆向解析的所以应当避免多层嵌套。

    避免使用通配规则。如 *{} 计算次数惊人!只对需要用到的元素进行选择

    尽量少的去对标签进行选择,而是用 class。如:#nav li{}, 可以为 li 加上 nav_item 的类名,如下选择.nav_item{}

    不要去用标签限定 ID 或者类选择符。如:ul#nav, 应该简化为 #nav

    尽量少的去使用后代选择器,降低选择器的权重值。后代选择器的开销是最高的,尽量将选择器的深度降到最低,最高不要超过三层,更多的使用类来关联每一个标签元素。

    考虑继承。了解哪些属性是可以通过继承而来的,然后避免对这些属性重复指定规则

    从 js 层面谈页面优化

    ①解决渲染阻塞
    如果在解析 HTML 标记时,浏览器遇到了 JavaScript,解析会停止。只有在该脚本执行完毕后,HTML 渲染才会继续进行。所以这阻塞了页面的渲染。
    解决方法:在标签中使用 async 或 defer 特性
    ②减少对 DOM 的操作
    对 DOM 操作的代价是高昂的,这在网页应用中的通常是一个性能瓶颈。
    解决办法:修改和访问 DOM 元素会造成页面的 Repaint 和 Reflow,循环对 DOM 操作更是罪恶的行为。所以请合理的使用 JavaScript 变量储存内容,考虑大量 DOM 元素中循环的性能开销,在循环结束时一次性写入。
    减少对 DOM 元素的查询和修改,查询时可将其赋值给局部变量。
    ③使用 JSON 格式来进行数据交换
    JSON 是一种轻量级的数据交换格式,采用完全独立于语言的文本格式,是理想的数据交换格式。同时,JSON 是 JavaScript 原生格式,这意味着在 JavaScript 中处理 JSON 数据不需要任何特殊的 API 或工具包。
    ④让需要经常改动的节点脱离文档流
    因为重绘有时确实不可避免,所以只能尽可能限制重绘的影响范围。

    如何借助 chrome 针对性优化页面

    首先打开控制台,点击 Audits 一栏,会看到如下表单。在选取自己需要模拟测试的情况后点击 run audits,即可开始页面性能分析。

    然后将会得到分析结果及优化建议:

    我们可以逐项根据现有问题进行优化,如性能类目(performance)中的第一项优化建议延迟加载屏幕外图像(defer offscreen images),点击后就能看到详情以下详情:

    而具体页面的指标优化可以根据给出的建议进行逐条优化。目前提供的性能分析及建议的列表包括性能分析、渐进式 web 应用、最佳实践、无障碍访问及搜索引擎优化五个部分。基本上涵盖了常见优化方案及性能点的方方面面,开发时合理使用也能更好的提升页面性能

    相信以上优化方案之所以行之有效的原因大都可以在本文中找出原因。理论是用来指导实践的,即不能闭门造车式的埋头苦干,也不能毫不实践的夸夸其谈。这样才会形成完整的知识体系,让知识体系树更加庞大。知道该如何优化是一回事,真正合理应用是另一回事,要有好的性能,要着手于能做的每一件“小事”。

    七、附录

    性能优化是一门艺术,更是一门综合艺术。这其中涉及很多知识点。而这些知识点都有很多不错的文章进行了总结。如果你想深入探究或许这里推荐的文章会给你启发。

    HTTP2 详解:

    https://www.jianshu.com/p/e57…
    TCP 拥塞控制:

    https://www.cnblogs.com/losby…
    页面性能分析网站:

    https://gtmetrix.com/analyze….
    Timing 官方文档:

    https://www.w3.org/TR/navigat…
    chrome 中的高性能网络:

    https://www.cnblogs.com/xuan5…

    正文完
     0