一个经典的面试问题:从输入 URL 到页面展示,浏览器内部到底经历了什么?这个问题可以说的很粗略,概括为就是浏览器网络请求到数据 + 浏览器解析渲染页面 两个大块,但仔细的挖掘会发现,这两大块每块都有许多步骤,每个步骤都有许多知识点需要去了解。甚至有些部分需要我们去深入学习,这对我们在前端性能优化的问题定位和方案选择上,都有很大的帮助。
结论先行,先说整个流程如下:
接下来我就分别从根据 URL 浏览器网络请求数据和浏览器解析渲染页面两个部分来讲解,再次之前我们先来了解一下浏览器的结构
一、浏览器多进程结构
二、浏览器网络请求数据
1. 用户输入URL
用户输入 URL 后,浏览器首先会判断输入的 URL 是否符合规范,如果符合规范,地址栏根据输入的内容加上协议,组成完整的URL,比如:baidu.com -> baidu.com。用户回车表示当前页面将要被替换;标签页面的图标进入的加载状态,但是页面还是旧的页面,需要等待解析渲染新页面;
从进程角度来说:当在地址栏中输入内容时,浏览器进程的UI线程会捕捉用户输入的内容,如果输入的网址,则UI线程会启动一个网络线程来请求DNS进行域名解析,接着开始连接服务器获取数据。如果输入的是关键词,浏览器就知道你是要使用搜索功能,于是就会使用默认配置的搜索引擎进行查询 ;
当网络线程获取到数据后,会通过Safe Browsing(谷歌内部的一套站点安全系统,通过检测该站点的数据来判断是否安全)来检查站点是否是恶意站点,如果是则会提示个警告页面,告诉用户这个站点有安全问题,浏览器就会阻止用户的访问,但用户也可以强行继续访问。当返回数据准备完毕并且安全校验通过时,网络线程会通知UI线程用户准备好了,可以进行后续的操作了。
2. URL请求过程
URL 请求的具体流程如下:
2.1. 查找缓存
首先查询浏览器本地是否缓存了需要请求的资源:
- 如果有缓存且没有失效,则直接返回给浏览器进程 ;
- 如果没有缓存,则正常进行网络请求;
2.2. DNS解析获取IP地址
- 浏览器首先检查自己的
DNS缓存中是否有对应记录,如有且有效则直接返回ip地址; - 如没有缓存,浏览器会查看
本地硬盘的 hosts 文件是否有和域名对应的IP地址,如果有就使用 ; - 如果没有,浏览器会发出一个 DNS 查询请求到
本地DNS服务器进行递归查询; 本地DNS服务器首先也会查询缓存记录,如有且有效就使用;- 如果没有,
本地DNS服务器开始向各级域名服务器发起迭代查询请求; - 本地 DNS 服务器首先向
根域名服务器发起查询请求,获得顶级域名服务器的地址; - 然后本地 DNS 服务器向
顶级域名服务器发起查询请求,获得权限域名服务器的地址; - 最后本地 DNS 服务器向
权限域名服务器发起查询请求,获得查询域名的 IP 地址; - 最后
本地 DNS 服务器将 IP 地址返回给请求的用户主机,同时将映射信息保存到其缓存中。
2.3. 建立TCP连接:TCP 三次握手
TCP三次握手有两个目的:
- 确保建立可靠连接
- 避免资源浪费
核心思想:TCP 为什么要 3 次握手?其原因是建立连接就是需要确认双方发送和接收功能都完好。客户端需要确认服务器端的发送和接收能力完好,服务器端也需要确认客户端的发送和接收能力完好
第一次握手:服务端可以确定客户端的发送功能完好;第二次握手:客户端可以确定服务端的发送和接收功能完好,但此时服务端还不能确定客户端的接收功能是否完好,所以需要第三次握手告诉服务器客户端能够接收;第三次握手:服务器端确认客户端接收功能完好。
2.4. 浏览器发送 HTTP 请求
一个HTTP请求报文由请求行(request line)、请求头部(header)、空行和请求实体4个部分组成。浏览器端开始构建请求行、请求头和请求体等信息,将浏览器的一些基础信息和cookie等信息添加到请求头中,然后向服务器发送构建好的请求报文。
2.5. 服务器处理请求并响应数据
服务端接受到请求报文之后,会根据报文信息生成响应头、响应行和响应体等数据,等网络进程接受到响应头和响应行之后,就可以解析响应头中的内容。网络进程解析完成响应头信息之后,将会将其转发给浏览器进程处理。
影响后续流程的两个信息:
-
响应行中的301/302状态码:执行重定向,解析响应头时还需要注意一个重定向的操作:如果返回的是响应行信息为301/302,代表需要进行重定向至其他URL地址,此时网络进程会从响应头中读取到location字段的值,并重新发起http/https请求。 -
响应头中的Content-Type: 网络进程会首先基于响应头中的Content-Type字段来确定服务器返回的何种类型的资源文件,因为不同的资源类型浏览器对其处理不一样:- 如果这里接收到的是一个
text/html类型的文件的话,那么意味这是一个HTML类型的文件,此时网络进程将响应头数据转发给浏览器进程,浏览器进程就通知渲染进程要准备渲染了。 - 而如果是一个
application/octet-stream也就是二进制字节流类型的文件,那么浏览器会将其提交给下载管理器进行下载。
- 如果这里接收到的是一个
2.6. 断开连接:TCP 四次挥手
核心思想:TCP为什么需要四次挥手?其原因是:释放连接是释放双向的连接,客户端到服务器端 and 服务器端到客户端。具体一点来说,就是确认客户端和服务端的发送和接收功能都关闭;
第一次挥手:客户端发送连接释放请求(FIN报文)。服务器知道客户端关闭了发送功能;第二次挥手:服务端收到连接释放请求(FIN报文)后,告诉应用层要释放 TCP 连接了,然后发送一个ACK报文,表示客户端到服务端的连接关闭,客户端知道服务器关闭了接收功能;但此时服务端到客户端的连接依旧还在,服务器还可以向客户端发送消息,客户端也可以接收;第三次挥手:当服务器也完成了数据发送,它会发送一个连接释放请求FIN报文,表示它也要关闭连接。完成后客户端知道服务器关闭了发送功能,但可以发送 FIN 报文;服务端进入 LAST-ACK 状态第四次挥手:客户端收到连接释放请求FIN报文后,返回一个确认应答 ACK报文。表明服务器知道客户端关闭接收功能;客户端进入 TIME-WAIT 状态
注意:三四次挥手中客户端和服务端的状态会持续 2MSL ( Maximum Segment Lifetime,报文最大生存时间),若这段时间内客户端没有收到服务端的重发请求,表示 ACK 成功到达,挥手结束进入 CLOSED 状态,否则客户端重发 ACK。当服务端收到确认应答后,也进入 CLOSED 状态
等待2MSL的意义
如果不等待,客户端直接跑路,当服务端还有很多数据包要给客户端发,且还在路上的时候,若客户端的端口此时刚好被新的应用占用,那么就接收到了无用数据包,造成数据包混乱。所以,最保险的做法是等服务器发来的数据包都死翘翘再启动新的应用。 那照这样说一个 MSL 不就不够了吗,为什么要等待 2 MSL?
- 1 个 MSL
确保四次挥手中客户端最后的ACK报文最终能达到服务器。因为网络传输是有延迟的,如果客户端在发送完ACK报文后立即关闭,那么这个ACK报文可能还在网络中传输,还没有被服务器收到。等待一个MSL可以让这个ACK报文有足够的时间到达服务器。 - 1 个 MSL
确保服务器没有收到ACK报文时,服务器重传的FIN报文可以到达客户端。如果服务器没有收到客户端的ACK报文,它会认为自己的FIN报文丢失,所以会重传FIN报文。等待一个MSL可以让客户端有足够的时间接收到这个重传的FIN报文。
3. 准备渲染进程
- 服务器返回响应体,并且Content-Type 类型不是下载,这时候要进入渲染进程;
- 浏览器主进程会为
每一个标签页 Tab 都准备一个渲染进程,但是如果几个页面是同一个站点一个根源(站点与站点之间的协议和根域名相同,那么根域名下的所有子域名以及所有端口号组成的站点都属于同一站点),如果从一个页面打开了另一个新页面,而新页面和当前页面属于同一站点的话,那么新页面会复用父页面的渲染进程; - 渲染进程准备好之后,还不能立即进入文档解析状态,因为此时的文档数据还在网络进程中,并没有提交给渲染进程,下一步就是提交文档阶段。
4. 提交文档
- 当
浏览器进程接收到网络进程的响应头数据之后,便向渲染进程发起“提交文档”的消息; 渲染进程接收到“提交文档”的消息后,会和网络进程建立传输数据的“管道”;渲染进程会返回“确认提交”的消息给浏览器进程,然后接收网络进程传递的数据并开始解析渲染;浏览器进程收到消息之后,开始更新页面的状态,包括了安全状态、地址栏的 URL、前进后退的历史状态,并更新 Web 页面。
5. 浏览器解析渲染页面
一旦文档确认提交之后,渲染进程就开始解析资源和渲染页面了,注意浏览器的渲染页面不是等待所有资源都加载完成之后才开始渲染,而是一边解析一遍渲染的,这也是为什么有的页面会先给用户呈现文字,然后图片等资源才会随后加载。
三、浏览器的解析渲染机制
1. 渲染流程总结
- Parse:渲染进程将
HTML解析为DOM 树结构 - Style:
解析 CSS,计算出每个 DOM 节点的样式 - Layout:根据 DOM 树和样式生成
布局树 Layout tree,计算得到节点的坐标信息和尺寸大小。 - 根据 Layout tree 依次
生成Layer tree 图层树和绘制记录表 - 将 Layer tree 和绘制记录表提交到
合成器线程。 - 合成器线程将图层
分成图块,并在栅格化线程池中将图块栅格化,转换成位图。 - 栅格化线程传送
绘制图块命令DrawQuads信息给合成器线程,合成器线程生成合成器帧 合成器帧通过 IPC 发送给浏览器进,浏览器进程再将其传送给GPU,GPU最终渲染显示内容到屏幕上
省流版:
PARSE:解析 HTML,构建 DOM 树。STYLE:为每个节点计算最终的有效样式。LAYOUT布局:为每个节点计算位置和大小等布局信息。PAINT绘制:绘制不同的盒子,为了避免不必要的重绘,分成多个图层进行处理。COMPOSITE合成 & RENDER:将不同的图层合成为一张位图,发送给 GPU,渲染到屏幕上。
2. 解析渲染页面阶段流程
2.1. 解析 HTML,构建 DOM 树
HTML 首先通过词法分析和语法分析将输入的 html 内容解析成多个标记,根据识别后的标记构造 以 document 对象为根节点DOM树构造
次级资源加载
一个网页通常会使用多个外部资源,如图片、JavaScript、CSS、字体等。图片和CSS这些资源需要通过网络请求下载或者从缓存中直接加载,这些资源不会阻塞 html 的解析,因为他们不会影响DOM的生成。为了加速渲染流程,会有一个叫做预加载扫描器(preload scanner)线程并发运行。如果 HTML 中存在 img 或 link 之类的内容,则预加载扫描器会查看 HTML parser 生成的标记,并发送请求到浏览器进程的网络线程获取这些资源。
JavaScript 可能阻塞解析
当 HTML 解析遇到 script 标签时,会暂停 HTML 的解析,转而开始加载、解析和执行 JavaScript。因为 JS 可能会改变 DOM 的结构。如果不想因 JS 阻塞 HTML 的解析,可以为 script 标签添加 async 或 defer 属性或将 script 放在 body 结束标签之前,浏览器会在最后执行 JS 代码,避免阻塞 DOM 构建
2.2. 解析 CSS,计算每个节点的样式
在 html 解析完成后,我们会获得一个DOM Tree,但此时我们还不知道DOM tree上的每个节点长什么样子,这时主线程需要解析css,并确定每个DOM节点的计算样式,CSSOM tree。
2.3. Layout 布局
在知道DOM结构和每个节点的样式后,接下来需要知道每个节点放在页面上的哪个位置,也就是节点的坐标以及该节点需要占用多大的区域,这个阶段被称为 layout布局,主线程通过遍历dom和计算好的样式来生成Layout tree,Layout tree上的每个节点都记录了x,y坐标和尺寸大小。
需要注意的是:DOM tree 和Layout tree并不是一一对应的,设置了display:none的不会出现在Layout tree上,而在before伪类中添加了content值的元素,content里的内容会出现在Layout tree上不会出现在DOM tree上,因为DOM是通过html解析获得并不关心样式,而 Layout tree 是 DOM tree 结合CSSOM tree来生成的。
2.4. Paint 绘制:根据 Layout tree 生成绘制记录表
知道了元素的大小形状和位置这还不够,我们还需要知道以什么样的顺序绘制(paint)这个节点,毕竟像z-index属性会影响节点绘制的层级关系,如果我们按照DOM的层级结构来绘制,则会导致错误的渲染。所以为了保证在屏幕上展示正确的层级,主线程遍历Layout tree创建一个绘制记录表(Paint Record ) ,该表记录了绘制的顺序,该阶段被称为绘制。
2.5. 合成:将 Layer tree 和绘制顺序表提交给合成器线程
知道了节点的绘制顺序,就该把这些信息转化为像素点显示在屏幕上(转化为位图),被称为栅格化(Rastering)
chrome早期的栅格化方案是只栅格化用户可视区域的内容,当用户滚动页面时,再栅格化更多的内容来填充缺失的部分,这种方案会导致展示延迟。现在 chrome 的栅格化流程叫做合成(composting),合成是一种将页面的各个部分分成多个图层,分别对其进行栅格化,在合成器线程的技术中单独合成页面的技术
主线程遍历
layout tree生成分层树 layer tree,然后将 layer tree 和绘制顺序的信息传递给合成器线程。
2.6. 合成器线程将图层切分为图块,在栅格化线程将图块栅格化(转化为位图)
合成器线程将每个图层栅格化,由于一层可能像页面的整个长度一样大,因此合成器线程将图层切分为许多图块(tiles),然后将每个图块发送给栅格化线程,栅格化线程栅格化每个图块。
2.7. 栅格化线程向合成器线程传送 DrawQuads 信息,合成器线程生成合成器帧
栅格化线程栅格化每个图块,当图块栅格化完成后,将它们存储到GPU内存中,并向合成器线程传送”draw quads“的图块信息,这些信息里记录了图块在内存中的位置和在页面哪个位置绘制图块的信息。
合成器线程收到”draw quads“的图块信息后,根据这些信息合成器线程生成了一个合成器帧(Compositor Frame)
2.8. 合成器帧通过 IPC 发送给浏览器进程,浏览器进程再将其传送给 GPU,GPU 渲染显示到屏幕上
然后这个合成器帧通过IPC传送给浏览器进程,接着浏览器进程将合成器帧传送到GPU,然后GPU渲染展示到屏幕上,这时就可以看到页面的内容。
但当页面发生变化时,都会生成一个新的合成器帧,然后重复上面的动作
3. 重排重绘
重排/回流:当改变一个元素的尺寸位置属性时,会触发样式计算、布局绘制以及后面的所有流程重绘:当改变一个元素的颜色属性时,不会重新触发布局,但还是会触发样式计算和绘制
重排和重绘都会占用渲染进程的主线程,js也是运行在主线程,就会出现抢占执行时间的问题。当在一帧的时间内布局绘制结束后还有剩余时间,js就会拿到主线程的使用权,如果js执行时间过长就会导致在下一帧开始时js没有及时归还主线程,导致下一帧动画没有按时渲染,就会出现页面动画的卡顿,
(一)重排/回流
触发重排 Layout 的情况:
-
布局结构或节点内容变化时,会导致重排。如
height、float、position等。- 盒子尺寸和类型。
- 修改
position(正常流、浮动和绝对定位)。 - 文档树中元素之间的关系。
- 外部信息(如视口大小等)。
-
获取 DOM 布局信息时,会导致重排。相关的方法属性如
offsetTop、getComputedStyle等。
(二)重绘
由于没有导致 DOM 几何属性的变化,因此元素的位置信息不需要更新,从而省去布局的过程。
跳过了 生成布局树 和 建图层树 的阶段,直接生成绘制列表,然后继续进行分块、生成位图等后面一系列 操作。 可以看到,重绘不一定导致重排,但重排一定发生了重绘
4. 优化重排重绘带来的页面动画卡顿的问题
- 通过
requestAnimationFrame(),这个方法会在每一帧被调用,通过API的回调,我们可以把JS运行任务分成一些更小的任务块(分到每一帧),在每一帧时间用完前暂停js执行,归还主线程,这样在下一帧开始时主线程就可以按时布局和绘制
- 栅格化不占用主线程,只在合成器线程和栅格线程中运行,意味着他不会和js抢占主线程,css有个
动画属性transform,通过该属性实现的动画不会经过布局和绘制,而是直接运行在合成器线程和栅格线程,所以不会受到主线程中JS的影响,更重要的是,通过transform实现的动画由于不需要经过布局绘制样式计算等操作,所以节省了很多运算时间
四、浏览器进程的角度看
在整个过程中,浏览器的主进程、渲染进程和网络进程参与了工作,它们的主要分工如下:
浏览器主进程:主要负责子进程管理、页面交互和文件存储功能渲染进程:主要负责将网络进程中加载好的HTML、CSS、Javascript 和图片等资源进行解析渲染,(对于 Chrome 来说其渲染引擎 blink和JS 解析引擎 V8都是在这个进程中工作的)网络进程:主要负责发起HTTP请求,将服务器返回的资源进行下载
从浏览器进程的角度来分析输入URL到页面展示经历的过程:
浏览器主进程接收到用户输入的URL地址后,会将其URL地址转发给网络进程;- 网络进程发起真正的HTTP请求;
- 随后
网络进程接收并解析到服务器返回的响应头数据,并将数据转发给浏览器主进程,然后继续接读取响应体的数据; 浏览器主进程接收到响应头数据,开始准备渲染进程,然后发送一个名为"提交文档”的消息给渲染进程。(理解就是:主进程说我把文档资源信息交给你了,你可以开始渲染了)渲染进程接收到该消息后,便和网络进程建立一个管道,准备开始接收网络进程中响应体的数据;- 之后,
渲染进程向浏览器主进程发送一个"确认提交"的信息,此时便等于告诉浏览器可以开始接收和解析数据了,之后渲染进程就接受网络进程中的响应体数据,然后开始页面解析和子资源加载;(确认提交就是告诉主进程,我确认你已经把信息提交给我了,我也已准备好接收数据并开始渲染) - 加载完成之后,浏览器进程便开始移除旧的文档,更新页面。