1. URL解析
浏览器首先会判断输入的内容是一个URL还是搜索关键字。
如果是URL,会把不完整的URL合成完整的URL。一个完整的URL应该是:协议+主机+端口+路径[+参数][+锚点]
。比如我们在地址栏输入www.baidu.com
,浏览器最终会将其拼接成https://www.baidu.com/
,默认使用443端口。
果是搜索关键字,会将其拼接到默认搜索引擎的参数部分去搜索。这个流程需要对输入的不安全字符编码进行转义(安全字符指的是数字、英文和少数符号)。因为URL的参数是不能有中文的,也不能有一些特殊字符,比如= ? &
,否则当我搜索1+1=2
,假如不加以转义,url会是/search?q=1+1=2&source=chrome
,和URL本身的分隔符=
产生了歧义。
URL对非安全字符转义时,使用的编码叫百分号编码,因为它使用百分号加上两位的16进制数表示。这两位16进制数来自UTF-8编码,将每一个中文转换成3个字节,比如我在google地址栏输入“中文”,url会变成/search?q=%E4%B8%AD%E6%96%87
,一共6个字节。
我们在写代码时经常会用的encodeURI
和 encodeURIComponent
正是起这个作用的,它们的规则基本一样,只是= ? & ; /
这类URI组成符号,这些在encodeURI
中不会被编码,但在encodeURIComponent
中统统会。因为encodeURI
是编码整个URL,而encodeURIComponent
编码的是参数部分,需要更加严格把关。
2. DNS解析
如果用户输入的是ip地址则直接进入建立TCP链接。但去记录毫无规律且冗长的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(或Mac、Linux)系统内核,内核又把结果返回给浏览器,终于浏览器拿到了www.xxxx.com
对应的IP地址,这次dns解析圆满成功。
3. 建立TCP链接
拿到域名对应的IP地址之后,User-Agent(一般是指浏览器)会以一个随机端口(1024< 端口 < 65535)向服务器的WEB程序(常用的有httpd,nginx等)80端口发起TCP的连接请求。
这个连接请求(原始的http请求经过TCP/IP 四层模型的层层封包)到达服务器端后(这中间通过各种路由设备,局域网内除外),进入到网卡,然后是进入到内核的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,http1.1的页面性能有了巨大提升,因为省去了很多tcp的握手挥手时间。
下图第一种是tcp建立后只能发一个请求的http1.0的通信状态,而拥有了持久连接的h1.1则避免了tcp握手及慢启动带来的漫长时延。
从图中可以看到相比http1.0,http1.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的支持度并不友好。
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. 服务器处理请求
5. 浏览器接受响应
浏览器接收到来自服务器的响应资源后,会对资源进行分析。首先查看 Response header,根据不同状态码做不同的事(比如上面提到的重定向)。
如果响应资源进行了压缩(比如 gzip),还需要进行解压。
然后,对响应资源做缓存。
接下来,根据响应资源里的 MIME 类型去解析响应内容(比如 HTML、Image各有不同的解析方式)。
6. 渲染页面
1、当浏览器拿到HTML文档时首先会进行HTML文档解析,构建DOM树。
2、遇到css样式如link标签或者style标签时开始解析css,构建样式树。HTML解析构建和CSS的解析是相互独立的并不会造成冲突,但CSS会阻塞JS执行,因此我们通常将css样式放在head中,让浏览器尽早解析css。
3、当html的解析遇到script
标签时,停止DOM树的解析开始下载js。**因为js是会阻塞html解析的,是阻塞资源。其原因在于js可能会改变html现有结构。**例如有的节点是用js动态构建的,在这种情况下就会停止dom树的构建开始下载解析js。脚本在文档的何处插入,就在何处执行。当 HTML 解析器遇到一个 script 标记时,它会暂停构建 DOM,将控制权移交给 JavaScript 引擎;等 JavaScript 引擎运行完毕,浏览器会从中断的地方恢复 DOM 构建。而因此就会推迟页面首绘的时间。
**可以在首绘不需要js的情况下用async和defer实现异步加载。这样js就不会阻塞html的解析了。**当HTML解析完成后,浏览器会将文档标注为交互状态,并开始解析那些处于“deferred”模式的脚本,也就是那些应在文档解析完成后才执行的脚本。然后,文档状态将设置为“完成”,一个“加载”(onload)事件将随之触发。
注意,异步执行是指下载。执行js时仍然会阻塞。
4、在得到DOM树和样式树后就可以进行渲染树的构建了。应注意的是渲染树和 DOM 元素相对应的,但并非一一对应。比如非可视化的 DOM 元素不会插入呈现树中,例如“head”元素。如果元素的 display 属性值为“none”,那么也不会显示在呈现树中(但是 visibility 属性值为“hidden”的元素仍会显示)。
渲染树构建完毕后将会进行布局。布局使用流模型的Layout算法。所谓流模型,即是指Layout的过程只需进行一遍即可完成,后出现在流中的元素不会影响前出现在流中的元素,Layout过程只需从左至右从上至下一遍完成即可。
但实际实现中,流模型会有例外。Layout是一个递归的过程,每个节点都负责自己及其子节点的Layout。Layout结果是相对父节点的坐标和尺寸。其过程可以简述为:
- 父节点确定自己的宽度
- 父节点完成子节点放置,确定其相对坐标
- 节点确定自己的宽度和高度
- 父节点根据所有的子节点高度计算自己的高度
5、此时renderTree已经构建完毕,不过浏览器渲染树引擎并不直接使用渲染树进行绘制,为了方便处理定位(裁剪),溢出滚动(页内滚动),CSS转换/不透明/动画/滤镜,蒙版或反射,Z (Z排序)等,浏览器需要生成另外一棵树 - 层树(Layer Tree)。
并不是布局树的每个节点都能生成一个图层,如果一个节点没有自己的层,那么这个节点就从属于父节点的图层。
通常满足下面两点中任意一点的元素就可以被提升为单独的一个图层。
1、拥有层叠上下文属性的元素会被提升为单独的一层:明确定位属性position
的元素、定义透明属性opacity
的元素、使用 CSS 滤镜filter
的元素等,都拥有层叠上下文属性。
2、需要剪裁(clip)的地方也会被创建为图层overflow
。
在完成图层树的构建之后,接下来终于到对每个图层进行绘制。 首先会把图层拆解成一个一个的绘制指令,排布成一个绘制列表,在上文提到的开发者工具的Layers面板中,点击detail中的profiler可以看到绘制列表。
至此,渲染进程中的主线程——GUI渲染线程已经完成了它所有任务,接下来交给渲染进程中的合成线程。
合成线程接下来会把视口拆分成图块,把图块转换成位图。
至此,渲染进程的工作全部完成,接下来会把生成的位图还给浏览器进程,最后在页面上显示。
绘制过程如下:
- 获取 DOM 并将其分割为多个层(RenderLayer) 。
- 将每个层栅格化,并独立的绘制进位图中。
- 将这些位图作为纹理上传至 GPU。
- 复合多个层来生成最终的屏幕图像。