写在前边
很久之前就想关于浏览器渲染原理做一份总结性文章。之前也看过网上不少这个方面的文章,关于浏览器渲染机制的原理文章非常多但是总是觉得差那么一点意思,没有串联整个流程。所以这里打算串联这两部分,从原理和实践出发讲解他们背后的含义。
文章中可能包含许多原理部分知识,如果大家对于这部分有疑惑的话我们可以在评论区互相交流。
进程和线程
- 进程是操作系统资源分配的最小单位,进程中包含线程。
- 线程是受进程管理的,浏览器中采用的是多进程模式。
日常中我们使用浏览器是基于一个一个tab
页进行访问网站,如果说某一个tab
页面挂掉了其实对于其他tab
页是没有任何影响的,其实每一个tab
页就是一个单独的进程。
他们之间互相独立互不影响。
我们来看看这张图:
浏览器中的进程分为下列5个:
- 浏览器进程: 你可以理解浏览器进程为一个统一的"调度大师"去调度其他进程,比如我们在地址栏输入
url
时,浏览器进程首先会调用网络进程。 它可以做一些子进程管理以及一些存储的处理。 - 渲染进程: 这个进程对于我们来说是最重要的一个进程,每一个**
tab
**页都拥有独立的渲染进程,它的主要作用是渲染页面。 - 网络进程: 这个进程是控制对于一些静态资源的请求,它将资源请求完成之后会交给渲染进程进行渲染。
GPU
进程: 这个进程可以调用硬件进行渲染,从而实现渲染加速。比如translate3d
等css3
属性会骗取调用GPU
进程从而开启硬件加速。- 插件进程:
chrome
中的插件也是一个独立的进程。
各个进程之间是互相独立不影响的,这里让我们先对浏览器这5
个进程有一个大概的认识,清楚每一个各自的大概作用。
从输入URL
到页面显示之间究竟发生了什么
我相信每一个合格的前端工程师对这个问题都已经了然于胸,网络上也有很多关于它的答案。
但是这里大多数人对于这个题理解的过于肤浅了,我会尽量从各个方面为大家来解释这个过程。
网络资源层面
首先我们先抛开浏览器对于资源的处理过程,先来看看一次正常的url
输入在资源加载方面经历的生命周期。
当我们在地址栏中输入了一个url
时,浏览器进程会监听到这次交互。紧接着它会分配出一个渲染进程进行准备渲染页面,同时浏览器进程会调用网络进程加载资源。
等待网络进程加载完成资源后会将资源交给渲染进程进行页面的渲染。从进程角度来说整体的加载流程就是这样。
大的方面来说就是浏览器进程进行调度,加载进程加载完成资源后交给渲染进程进行渲染加载的资源。
接下来我们详细看看输入url
之后的请求过程中究竟发生了哪些事情。
网络七层协议(OSI
)
我们来稍微看一下这个图:
对于这块不了解的同学可以稍微看一下一次网络请求涉及到的七个阶段。
有关他们的每个部分的详细介绍你可以在这里查看
我可以将这七层归为下列四层:
- 通常我们会将应用层、表示层、会话层统称为应用层,应用层的主要协议就是
HTTP
协议。 - 传输层中我们浏览器中
Http
协议是基于tcp
去进行网络传输。(常见传输协议的有tcp
还有udp
) - 网络层中一般都是
ip
协议。 - 当然在数据链路层和物理层都是被称为物理层。
让我们先从7层协议来分析一下浏览器对于url
加载的过程。
首先当我们输入**url
**输入一个域名浏览器会在磁盘/内存缓存中去查找请求的文件,查看是否命中缓存。如果命中缓存则直接会直接从缓存中拿取对应的ip地址。
如果命中强缓存则会直接返回对应资源不会进入下面的步骤。
这里我们先忽略缓存带来的影响,这里涉及一个协商缓存和强缓存的知识点会在下面的内容中进行详细讲解。
假设我们是首次访问这个页面,此时并没有任何缓存:
如果我们访问的这个域名没有被解析过,那么我们需要解析地址栏中输入的域名。解析域名主要依靠的是**DNS
**协议,将域名解析成为**ip
**地址。ip
地址才是真正找到对应的ip
。
dns
你可以理解它为一个映射表,将域名和ip
地址进行了映射。其实就是一个分布式的数据库,通过域名查找对应的ip
地址。 需要注意的是dns
解析是基于udp
协议的而非tcp
。
这里有一个小问题需要提一下,为什么**dns
**解析是基于**udp
**而非**tcp
**协议 ?
我们的
dns
解析过程是一个服务器的查找过程。因为域名分为一级/二级...域名,所以每一级域名都会迭代去查询如果它采用tcp
协议的话,每经过一次域名查询,域名服务器都会经过三次握手。 如果是基于tcp
协议进行域名查找的话每一次tcp
协议都会进行三次握手。但是udp
就不会,他会直接发包然后确认。
相较于udp
,tcp
是更加安全,可靠的(因为三次握手以及四次挥手)但是这也造成了它相对于udp
消耗更多时间。
udp
常用的场景是视频或者直播中,对于我们来说dns
解析中使用udp
更多的原因是因为udp
的速度,当然即使丢包了,我们重新发送就可以了。
tcp
传输的过程称为分段传输,也就是会拆分为多个包,一个包一个包的进行发送得到响应之后在发送下一个包。这样的方式无疑带来的有点是更加可靠和安全。但是在时效上并不如udp
协议的实时(直接通信无需建立连接)。
此时会根据DNS
解析通过域名 端口号解析出对应的IP
地址。
我们拥有了ip
地址之后,接下来我们就需要将利用ip
进行寻找网页地址。
此时如果我们的请求地址是**https
**,在通过**ip
**寻址之前会额外增加一步**ssl
**协商保证数据的安全性。
当通过ip
寻址成功后,浏览器知道了服务器的地址。此时并不会立即将数据发送过去,而是会进入一个排队等待的过程。比如一个域名下有多个请求,同一个域名在http1.1
下最多只能建立6
个tcp
链接,也就是说同一时间最多发送6
个请求,他们首先会进入一个排队的等待时间。
排队结束后,开始发送请求。此时就要通过tcp
先进行创建链接通过三次握手,建立完成链接之后开始传输数据。
上边我们说过tcp
是基于分段传输的,基于内容特别大的传输内容tcp
会将数据包进行拆分称为多个数据包进行有序传输。
在
tcp
传输过程中如果传输中出现了丢包,那么tcp
会进行重发。 有兴趣的小伙伴可以思考下为什么tcp
链接有时是三次又是又是四次。注意disici1sh
服务器再收到之后会按照顺序进行接收。
tcp
建立完成链接之后,浏览器会通过http
请求发送请求的数据。
一次http
请求包含
- 请求行
- 请求头
- 请求体
在http1.1
中默认开启了了Connection:keep-alive
,它的作用是在下次发送请求时在一定时间内可以复用上一次的tcp
链接而不需要重新建立这个链接。(也就是在一定时间内保持相同域名tcp
链接不断开)。
此时服务器时候收到请求发送的数据,根据请求行,请求头,请求体进行解析。解析完成后返回响应行、响应头、响应体。
注意:这里服务器返回状态码中有一些特殊的状态码
301
/302
这两个状态码都表示重定向,如果返回这两个任意一个就会根据返回头中的Location
返回的域名重新进行上边的一系列操作。304
状态码表示告诉浏览器本次资源走缓存而不会重新请求下载资源。
这个过程便是一个最基础的浏览器针对一个url
访问网络请求的过程。
以taobao.com
为例让我们一探究竟
上边说了那么多枯燥的理论,接下来让我们在实际中去体会一下。
首先我们打开一个全新的浏览器tab
页在地址栏输入taobao.com
因为我是首次进入这个页面,所以并没有任何缓存。前边说到过浏览器进程首先会开启一个页面渲染进程,同时开启网络进程去请求。
首先让我们打开chrome
开发者工具:
有兴趣的朋友可以自己尝试输入一下,这里当我们输入http://taobao.com/
浏览器会解析DNS
以及TCP
三次握手建立连接然后发送请求,当得到响应后发现Response Status
是302
它会根据返回的Location
重定向到http://www.taobao.com/
。
之后我们去重定向到http://www.taobao.com/
,请求得到访问又是301
状态码,于是有被重新重定向https://taobao.com/
上。
然后再次进行DNS
解析,Tcp
建立连接这个步骤。。
建议大家在新的无痕浏览页中去进行这些操作,我们排除掉
DNS
缓存以及任何浏览器缓存的干扰机制去看结果会更加纯粹。
这里我们已经大概领略到了重定向域名的访问,我们可以发现每一次重定向都会重新进行DNS
解析以及TCP
连接的建立是非常耗时的。所以在我们的真实项目中要尽量的避免进行资源重定向,如果有存在重定向的资源尽量还是将它直接替换成新的地址连接。
接下来我们以第三次https://www.taobao.com/
这次请求为例来分析一下一次请求(无任何缓存)的各个阶段:
分析一次请求完整的瀑布图所代表的含义
我们先来看看对应chrome
中的瀑布图:
Queueing
这个阶段表示排队阶段,浏览器在以下情况下对请求进行排队:
- 有更高优先级的请求。
- 已经为此**源**打开了六个 TCP 连接,这是限制。仅适用于 HTTP/1.0 和 HTTP/1.1。
- 浏览器在磁盘缓存中短暂分配空间
DNS Lookup
这一步就表示开始进行DNS
解析,将我们的请求域解析为ip
地址。Initial connection
这阶段表示我们进行tcp
链接/重试和ssl
协商共同耗费的时间。SSL
这一步就是当我们请求https
域名时会进行ssl
协商的耗时。request sent
表示请求开始发送TTFB`` TTFB
代表Time To First Byt
e。此时间包括 1 次往返延迟和服务器准备响应所用的时间。通俗来说就是当我们请求发送到接受到响应的第一个字节的时间。
TTFB
这一步通常可以粗略表示本次请求服务器(后台)从接受到请求然后返回响应结果处理的耗时。
Content Download
就不必多说了,是我们下载本地响应的时间。
同时对于chrome
而言在http1.1
下同一个域的最多支持并发6个TCP
链接,注意这里是TCP
链接而不是HTTP
请求。
这里因为1.1
中引入了pipelining
机制,客户端可以同时发送多个请求。这样就进一步改进了HTTP协议的效率。
我们用一个小例子来说明下,在同一个TCP连接里面,先发送A请求,然后等待服务器做出回应,收到后再发出B请求。管道机制则是允许浏览器同时发出A请求和B请求,但是服务器还是按照顺序,先回应A请求,完成后再回应B请求。
当然细心的同学会发现我们访问的taobao.com
基本上所有的静态资源都是基于http2
协议去实现的,这里我们稍微来介绍一下http
的各个阶段:
http
发展的各个阶段
http 0.9
最早时候只支持传输html
,请求中没有任何请求头。http 1.0
引入了请求头和响应头,这样的话就可以根据请求头区分传输的内容是图片还是html
又或是js
。http 1.1
针对http1.0
每一次请求都会发送请求建立tcp
链接,请求结束后断开tcp
链接。这无疑是非常耗时的。所以在http 1.1
中默认开启了一个请求头connect:keep-alive
进行在一个tcp
链接的复用。当然即使引入了长链接keep-alive
,还存在一个问题就是基于http 1.0
中是一个请求发送得到响应后才开始发送下一个请求,针对这个机制1.1
提出了管线化pipelining
机制,但是需要注意的是服务器对应同一tcp
链接上的请求是一个一个去处理的,所以这就会导致一个比较严重的问题队头阻塞。
如果说第一个发送的请求丢包了,那么服务器会等待这个请求重新发送过来在进行返回处理。之后才会处理下一个请求。即使浏览器是基于
pipelining
去多个请求同时发送的。
http 2.0
提出了很多个优化点,其中最著名的就是解决了http1.1
中的队头阻塞问题。- 多路复用: 支持使用同一个
tcp
链接,基于二进制分帧层进行发送多个请求,支持同时发送多个请求,同时服务器也可以处理不同顺序的请求而不必按照请每个请求的顺序进行处理返回。这就解决了http 1.1
中的队头阻塞问题。 - 头部压缩: 在
http2
协议中对于请求头进行了压缩达到提交传输性能。 Server push
:http2
中支持通过服务端主动推送给客户端对应的资源从而让浏览器提前下载缓存对应资源。
- 多路复用: 支持使用同一个
http3.0
: 基于tcp
下就难免存在阻塞问题,如果发生丢包就需要等待上一个包。在http3
彻底解决了tcp
的队头阻塞问题,它是基于udp
协议并且在上层增加了一层QUIC
协议。
关于
http 3.0
和2.0
这部分我研究的不是很多,所以就不做详细的对比了。大家如果有更详细的建议可以在评论区留言。后续如果有必要我会补充这部分内容。
关于http 1.1
的pipelining
机制和http 2.0
的多路复用
其实这个问题最开始我也是一直困惑的,他们究竟存在什么区别。直到有一天我看到了stackoverflow
上这个答案
- HTTP/1.1 without pipelining: 必须响应 TCP 连接上的每个 HTTP 请求,然后才能发出下一个请求。
- HTTP/1.1 with pipelining: 可以立即发出 TCP 连接上的每个 HTTP 请求,而无需等待前一个请求的响应返回。响应将以相同的顺序返回。
- HTTP/2 multiplexing: TCP 连接上的每个 HTTP 请求都可以立即发出,而无需等待先前的响应返回。响应可以按任何顺序返回。
浏览器渲染
首先我们先来看一看关于浏览器加载的粗略加载图
粗略来说浏览器的渲染过程带盖就是这样,但其实这其中涉及太多细节方面的知识点。比如一些文件的加载顺序,是否阻塞,CRP
关键渲染路径等等...
让我们一层一层来揭开浏览器渲染的面纱。
css
与js
对于dom
的影响
关于css
对js
和dom
构建的详细分析,你可以在这里看到。
css
是否会阻塞Dom
我们先来看看css
对于dom
的影响:
- 对于
css
的加载是不阻塞dom
的构建的。 - 对于
css
的加载时会阻塞之后的dom
节点的渲染的。
关于如何理解这两句话,我们结合这个Demo
来看一看内容:
首先我们尝试在chrome
中将network
网速调整到低网速情况下,你会发现页面首先会打印出对应的id=app
的节点但是此时页面**css
**并没有渲染任何内容,等待**css
**加载完成后页面才会进行渲染。
这样也就意味着css
的加载并不会阻塞Dom Tree
的构建,但是css
文件的加载和解析是会阻塞页面渲染的。
js
是否会阻塞Dom
其实毋庸置疑,js
的执行过程一定是会阻塞Dom Tree
和Css OM
的。这里有两个特殊的async
和defer
属性,我相信大家对于这两个属性都已经非常熟悉了。这里我就不展开讲解这部分了。
其实这里大家只要把握一个原则,在渲染进程中
JS
线程和渲染线程是互斥的关系。
为什么css
放上边而js
放在下面
我们搞清楚了关于js
和css
阻塞的问题后再来看看一道经典的面试题:为什么**css
**放在上边而**js
**放在下面。
为什么css
放在上边
上边我们讲到了css
的加载和解析并不会阻塞Dom
的构建,但是会阻塞页面上之后元素的渲染。这也就造成了如果**css
**放在顶部的话,后续**Dom
**元素的渲染需要依赖本次**css
**代码执行解析完成之后才会。
也许有的同学会想到,那如果我将css
放在底部,是不是Dom
元素首先会渲染出来之后等待样式解析完成之后页面又会重新进行一遍绘制,这样的话用户看来是不是"页面展现"就更快了?
让我们来看看这段代码:
我们可以看到将css
放在底部的话页面的确是会产生两次渲染的。但是第一次没有任何样式的渲染其实是一次“无效渲染”。
同时让我们来关注一下对比一次将css
顶部造成的一次渲染和将css
放在底部造成二次渲染的开销:
我们利用chrome
浏览器performance
去分析将css
放在底部的代码中发现实际上浏览器进行了两次元素的绘制,也就是说如果将**css
**代码放在底部是会发生重绘(以及可能会引发回流),这个操作是非常耗时的一个过程。
关于
重绘/回流
会在我们会在之后讲到他们已经如何去尽量避免。
所以将css
放在顶部的话:
页面首次渲染浏览器仅仅会进行一次渲染,而不会造成多余的重绘和回流步骤。
为什么js
需要放在底部
上边我们说到了关于js
实际上是会阻塞Dom Tree
的构建和渲染的。同时js
依赖于前边的css
文件加载完成后才会进行执行。
注意在网络进程中,解析
html
时候会提前针对所有外部链接进行预加载。简单点来说也就是css
和js
外部链接可以同时并行进行网络资源请求加载。
废话不多说我们利用performance
同样来看这样一段代码:
这里我们将js
放在了元素之前,首先在js
执行完成之前是不会进行后续元素的构建和渲染的。只有等待js
加载并且解析完成之后渲染线程才会继续之后的Dom Tree
的构建以及页面的渲染。
js
是会阻塞html
解析和渲染的,同时需要注意js
的执行是需要等待之前的css
加载并且执行完毕。保证js
可以操作样式的。 所以css
之后如果存在js
那么css
的加载过程也是可以间接性的阻塞DCL
事件的。 当然对于defer
和async
这两个属性我们会在后续深入讲解。 这里额外有一点:在页面解析Html
之前浏览器会额外扫描外部链接,将外部链接交给网络进程进行下载。所以css
和js
的下载可以是并行的。
所以,我们之所以将js
放在底部。是因为js
放在底部是会等待页面渲染完毕后再去阻塞的执行后续js
。
图解css
和js
的加载
css
加载执行会阻塞后续js
的执行,同时css
加载会阻塞页面的渲染。css
加载可能会阻塞后续dom
解析,这需要根据后续是否存在js
来判断。js
加载和解析是会阻塞后续dom
的解析。
写在结尾
笔者原本打算从全方位的浏览器渲染流程讲解再贯通到性能优化去逐步深入讲解,但是写到一半反观关于性能和渲染方面的确涉及到的点是非常庞大的一个分支。
思来想去,这里还是打算给大家抛砖引玉针对与渲染流程梳理一个大致的完整思路。后续会有不同的文章中去拆分出不同的细节去逐个攻破。
或者浏览器渲染上有哪一步部分比较感兴趣,或者文章中的点还存在问题的话,可以评论区给我留言。