合格的开发者知道怎么做,而优秀的开发者知道为什么这么做。
这句话来自《web性能权威指南》,我一直很喜欢,而本文尝试从浏览器渲染原理探讨如何进行性能提升。
全文将从网络通信以及页面渲染两个过程去探讨浏览器的行为及在此过程中我们可以针对那些点进行优化,有些的不足之处还请各位不吝雅正。
关于浏览器渲染机制已经是老生常谈,而且网上现有资料中有非常多的优秀资料对此进行阐述。遗憾的是网上的资料良莠不齐,经常在不同的文档中对同一件事的描述出现了极大的差异。怀着严谨求学的态度经过大量资料的查阅和请教,将会在后文总结出一个完整的流程。
1、DOM树的构建是文档加载完成开始的?
DOM树的构建是从接受到文档开始的,先将字节转化为字符,然后字符转化为标记,接着标记构建dom树。
这个过程被分为标记化和树构建
而这是一个渐进的过程。为达到更好的用户体验,呈现引擎会力求尽快将内容显示在屏幕上。它不必等到整个 HTML 文档解析完毕之后,就会开始构建呈现树和设置布局。在不断接收和处理来自网络的其余内容的同时,呈现引擎会将部分内容解析并显示出来。
参考文档:http://taligarsiel.com/Projects/howbrowserswork1.htm
2、渲染树是在DOM树和CSS样式树构建完毕才开始构建的吗?
这三个过程在实际进行的时候又不是完全独立,而是会有交叉。会造成一边加载,一边解析,一边渲染的工作现象。
参考文档:http://www.jianshu.com/p/2d522fc2a8f8
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条缓存)。
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并不是救世主,它也存在不少缺陷:
HTTP2
2015年,HTTP/2 发布。它不叫 HTTP/2.0,是因为标准委员会不打算再发布子版本了,下一个新版本将是 HTTP/3。HTTP2将具有以下几个主要特点:
就这几个点我们分别讨论一下:
就多工来看:虽然http1.1支持了pipelining,但是仍然会有队首阻塞问题,如果浏览器同时发出http请求请求和css,服务器端处理css请求耗时20ms,但是因为先请求资源是html,此时的css尽管已经处理好了但仍不能返回,而需要等待html处理好一起返回,此时的客户端就处于盲等状态,而事实上如果服务器先处理好css就先返回css的话,浏览器就可以开始解析css了。而多工的出现就解决了http之前版本协议的问题,极大的提升了页面性能。缩短了通信时间。我们来看看有了多工之后有那些影响:
就头部压缩来看: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排序)等,浏览器需要生成另外一棵树 - 层树。因此绘制过程如下:
获取 DOM 并将其分割为多个层(RenderLayer)
将每个层栅格化,并独立的绘制进位图中
将这些位图作为纹理上传至 GPU
复合多个层来生成最终的屏幕图像(终极layer)。
HTML解析是一个将字节转化为字符,字符解析为标记,标记生成节点,节点构建树的过程。。CSS样式的解析则由于复杂的样式层叠而变得复杂。对此不同的渲染引擎在处理上有所差异,后文将会就这点进行详细讲解
1、HTML的解析分为标记化和树构建两个阶段
标记化算法:
是词法分析过程,将输入内容解析成多个标记。HTML标记包括起始标记、结束标记、属性名称和属性值。标记生成器识别标记,传递给树构造器,然后接受下一个字符以识别下一个标记;如此反复直到输入的结束。
该算法的输出结果是 HTML 标记。该算法使用状态机来表示。每一个状态接收来自输入信息流的一个或多个字符,并根据这些字符更新下一个状态。当前的标记化状态和树结构状态会影响进入下一状态的决定。这意味着,即使接收的字符相同,对于下一个正确的状态也会产生不同的结果,具体取决于当前的状态。
树构建算法
在树构建阶段,以 Document 为根节点的 DOM 树也会不断进行修改,向其中添加各种元素。
标记生成器发送的每个节点都会由树构建器进行处理。规范中定义了每个标记所对应的 DOM 元素,这些元素会在接收到相应的标记时创建。这些元素不仅会添加到 DOM 树中,还会添加到开放元素的堆栈中。此堆栈用于纠正嵌套错误和处理未关闭的标记。其算法也可以用状态机来描述。这些状态称为“插入模式”。
以下将会举一个例子来分析这两个阶段:
<html>
<body>
Hello world
body>
html>
标记化:初始状态是数据状态。
标记也会进行同样的处理。目前 html 和 body 标记均已发出。现在我们回到“数据状态”。接收到 Hello world 中的 H 字符时,将创建并发送字符标记,直到接收
中的<。我们将为 Hello world 中的每个字符都发送一个字符标记。