(建议精读)输入URL到页面显示的前端体系知识

10,766

前言

从输入URL回车到页面显示发生了什么?

相信大多数人面试的时候都遇到过这个问题,也相信你肯定答得上来

记得以前我刚出去面试的时候回答是:“先解析URL、然后DNS域名解析、再发起HTTP请求建立TCP连接、服务端响应返回页面资源进行渲染、然后断开TCP连接”

面试官:“然后呢”

我:“没了,我说完了”

面试官:“......”

没错,我说完了,3秒就说完了

现在想想,我也:“......”

其实这一道能比较全面地考察我们对知识的掌握程度的面试题,里面涉及到计算机网络,浏览器原理,操作系统,Web等一系列知识,可以说这是一道题不同回答能直接表体现出不同的薪资水平的问题了

这里将一些过程给大家梳理了一遍,没有讲概念性的东西了,如果看完还是迷迷糊糊的,那么请回复我,一定是我写的不够清晰,我来改

先看个概要图,网上找的,本文内容步骤比图里的步骤要多一些

如果对进程与线程模糊的话,可以看下我另一篇文章有讲深入理解浏览器中的进程与线程

我们开始吧


输入

首先,在输入的过程中,浏览器的UI线程会实时捕捉输入的内容,如果输入的不是网址或者协议不合法的话,就会使用浏览器默认的搜索引擎,来合成新的带搜索关键字的URL,准备进行搜索

哦,这里面还会检查有没有出现非法字符,有的话会对非法字符进行转义

如果没有问题,在回车之前,还会执行一次当前页面的beforeunload事件,可以让页面退出之前执行一些数据清理工作,或者,有表单没有提交的情况提示用户是否确认离开

然后下一步浏览器进程会通过IPC把URL发送给网络进程,然后网络进程要先找本地缓存

检查缓存

如果有缓存,并且没有过期,就不发送请求,直接拿来解码再开始渲染流程(后面的步骤)

检查缓存的过程是这样的

  • 如果是https的话,有可能先找Service Worker,比如你设置了请求拦截,离线缓存的话

  • 如果没有,再找浏览器的内存缓存Memory Cache)

  • 如果还没有,再找硬盘缓存Disk Cache)( 强缓存和协商缓存都属于硬盘缓存)

  • 如果这三种都没有找到,请求还是http2的话,还可能会查找推送缓存Push Cache),就是找Session(Session会话结束就会释放,所以存在时间很短)

有关 HTTP缓存(浏览器缓存),这里不再展开,可以看我另一篇文章有详细介绍为什么第二次打开页面快?五步吃透前端缓存,让页面飞起

如果没有缓存或者缓存过期,再开始解析URL,解析出要请求的服务器 的IP地址

URL 解析

把我们请求需要的协议域名端口路径这些信息解析提取出来

然后根据解析出来的域名,进行DNS解析,找到要请求的服务器的IP地址

用大白话说的话:域名就像是备注名,对应的IP地址就像是手机号码,我们知道备注后去几个通讯录找手机号码,类似这样的过程

DNS解析

DNS解析IP的过程是这样子的

是先在客户端进行查询有没有解析过的记录,也就是DNS缓存,这个查询是递归查询,如图先找啥,再找啥

在这里任何一步找到就会结束查找流程(整个过程客户端只发出一次查询请求)

如果都没有找到,就会走DNS服务器设置的转发器请求,如果没设置转发模式,就向13根发起解析请求,这里的查询方式是迭代查询,如图 很明显,整个过程会发出多次查询请求

  1. 先去DNS根域名(.)服务器查询,属于哪个顶级域名服务器(.com),然后返回顶级域名服务器IP
  2. 再根据返回的IP去顶级域名服务器查找,属于哪个权威域名服务器(xxx.com),返回权威域名服务器IP
  3. 再根据返回的IP去权威域名服务器查找
    • 如果没有配置CDN,就直接返回解析到的IP
    • 如果有配置CDN,权威域名会返回一个CName别名记录,它指向CDN网络中的智能DNS负载均衡系统,然后负载均衡系统通过智能算法,将最佳CDN节点的IP返回
13根:
全球共有13个根域服务器的IP地址,不是13台服务器!
因为借助任播技术,可以在全球设立这些IP的镜像站点,所以访问的不是唯一的那台主机

然后我们终于拿到了IP地址,拿到目的主机的IP地址之后,开始正式发起请求,先建立TCP连接

建立TCP连接

建立连接前双方需要确认对方的收/发消息的能力,以及沟通好要使用的 并且双方都支持的协议等,所以要先发起三次握手来确定这些(这也是为什么不能两次握手的原因,握四次就没必要了,都确认完了)

三次握手

第一次握手:客户端向服务器发送(SYN,seq)

  • 一个SYN报文
  • 一个客户端初始化随机序列号(seq)

第二次握手:服务器收到请求后向客户端发送(SYN,ACK,seq,ack)

  • 自己的SYN报文ACK报文
  • 一个服务端的初始化随机序列号seq
  • 一个确认号ack=客户端发来的序列号+1,表示自己收到了

第三次握手:客户端收到服务器的确认应答后,向服务端发送(ACK,seq,ack)

  • 确认应答ACK报文
  • 一个seq,值为第二次握手客户端发过来的ack的值
  • 一个确认号ack,值为服务端的序列号+1,告诉服务端我收到了

如果是http,这时连接成功进入传输阶段,如果是https,这时候还需要进行一个TLS加密协议的握手过程

HTTPS 的 TLS 握手

根据TLS版本和密钥交换法不同,握手过程也不一样,有三种方式

RSA握手

早期的TLS密钥交换法都是使用RSA算法,它的握手流程是这样子的

  1. 浏览器给服务器发送一个随机数client-random和一个支持的加密方法列表
  2. 服务器把另一个随机数server-random加密方法公钥传给浏览器
  3. 浏览器又生成另一个随机数pre-random,并用公钥加密后传给服务器
  4. 服务器再用私钥解密,得到pre-random,此时浏览器和服务器都得到三个随机数了,各自将三个随机数用加密方法混合生成最终密钥

然后开始通信

TLS1.2握手

在TLS1.2版本中用ECDHE密钥交换法,它的握手流程是这样子的

  1. 浏览器给服务器发送一个随机数client-random、TLS版本和一个支持的加密方法列表
  2. 服务器生成一个椭圆曲线参数server-params、随机数server-random加密方法证书等传给浏览器
  3. 浏览器又生成椭圆曲线参数client-params,握手数据摘要等信息传给服务器
  4. 服务器再返回摘要给浏览器确认应答

这个过程中,服务器和浏览器两边都得到server-paramsclient-params之后,就会用ECDHE算法算出pre-random,这就两边都有了三个随机数,然后各自再将三个随机加密混合生成最终密钥

然后开始通信

TLS 1.3握手

在TLS1.3版本中废弃了RSA算法,因为RSA算法可能泄露私钥导致历史报文全部被破解,而ECDHE算法每次握手都会生成临时的密钥,所以就算私钥被破解,也只能破解一条报文,而不会对之前的历史信息产生影响,TLS1.3版本中握手过程是这样子的

  1. 浏览器生成client-params、和client-random、TLS版本和加密方法列表发送给服务器
  2. 服务器返回server-paramsserver-random加密方法证书摘要等传给浏览器
  3. 浏览器确认应答,返回握手数据摘要等信息传给服务器

然后开始通信

这个版本简化了握手过程,只有三步,把原来的两个RTT打包成一个发送了,所以减少了传输次数。这种握手方式也叫1-RTT握手

这种握手方还有优化空间吗?
使用会话复用,Session ID 和 Session Ticket

连接之后

连接建立成功之后,浏览器会构建请求行、cookie等数据附加到请求头中,发给服务器,服务器接受请求并解析

如果没有对应的资源就404了

否则检查HTTP请求头有没有包含协商缓存信息(前面查询强缓存已过期的话会走这个步骤),如果验证缓存没有更新,过期的缓存依然可以使用,就返回304和空响应体

  1. 要是没有缓存或者资源更新了,还没有CDN的话,就读取完整请求并准备http响应,进行查询数据库等操作

  2. 要是连接的是CDN节点,并且正好有这个资源就直接返回,要是没有资源或者资源更新了的话,CDN服务器就去源站获取文件,如果源站也没有,就404了,有的话就返回给CDN节点缓存起来

然后将响应数据通过之前建立的TCP连接,返回给浏览器的网络进程

浏览器接收到响应数据之后,如果是http1.1以下则直接关闭连接,否则双方都可以根据情况选择关闭TCP连接或者保留重用,现在浏览器默认都会保持连接(keep-alive)

关闭连接四次挥手

如果要关闭连接的话,比如浏览器要关闭连接,过程是这样子的

  1. 浏览器先发送FIN报文、Seq=初始化序列号给服务器,并停止发送数据,但仍可以接受服务端响应的数据
  2. 服务器收到后,发送ACK=浏览器序列号+1给浏览器,表明收到
  3. 服务器数据都发完了,给浏览器发送FIN报文、Seq=序列号给浏览器
  4. 浏览器收到后,发送ACK=服务器序列号+1给服务器,表明收到

这个过程就被称为四次挥手,挥手结束后,过一段时间就会自动关闭连接,这是为了防止发送给服务器的确认报文段丢失或者出错,从而导致服务端不能正常关闭。

这个挥手完到自动关闭连接的等待时间是2MSL,超过这个时间连接就会被丢弃。RFC793中规定MSL为2分钟,但实际应用中常用的是30秒,1分钟和2分钟都有,如果超过这个时间,主动关闭者会发送一个RST状态位的包,表示重置连接,这时候被关闭者就知道对方已经关闭了连接

如果主动关闭者不进行等待会怎样?
由于端口复用的原因,主动关闭者可能已经开启了另一个连接,这时候被关闭者还在重试发起FIN请求,导致主动关闭者收到很多没用的包。因为包是有序列号的,所以可以判断到不是本次连接该接收的包,就不会管。为此需要让主动关闭者等待,确保被关闭者不会再发送FIN请求了再进行端口复用

接着网络进程开始解析请求响应回来的数据

解析响应数据

如果返回的状态码是301302就需要重定向到其他URL,在重定向地址会在响应头的Location字段中,然后一切从头开始,否则然后根据情况选择关闭TCP连接或者保留重用

然后网络线程会通过SafeBrowsing来检查站点是不是恶意站点,如果是就展示警告页面,告诉你这个站点有安全问题,浏览器会阻止访问,当然也可以强行继续访问。喜欢看岛国小电影的人应该遇到过很多次这种情况

SafeBrowsing 是谷歌内部的一套站点安全系统,通过检查该站点的数据来判断是不是安全,比如通过查看该站点的IP有没有在谷歌黑名单中,如果是Chrome浏览器的话

响应成功返回状态码2xx,然后判断资源能不能缓存,如果可以就先缓存起来

然后对响应解码,比如gzip压缩,然后根据资源类型(Content-Type)决定如何处理,如果浏览器判断是下载文件,那么请求会被提交给浏览器的下载管理器,同时URL请求流程就结束了

否则网络线程会通知UI线程,然后UI线程会创建一个渲染器进程来准备渲染页面

然后浏览器进程通过IPC管道将数据传给渲染器进程的主线程,准备渲染流程

默认情况下会为每一个标签页配置一个渲染进程,但是也有例外,比如从A页面里面打开一个新的页面B页面,而A页面和B页面又属于同一站点的话,A和B就共用一个渲染进程,其他情况就为B创建一个新的渲染进程

渲染进程收到确认消息后,会和网络进程建立传输数据的管道,开始执行解析数据、下载资源等

为什么进入新页面,之前的页面不会立马消失,而是要加载一会才会更新的原因

  • 因为这时候的旧的文档还在网络进程中,渲染进程准备好了之后,渲染进程会向浏览器进程发出提交文档的消息

  • 浏览器进程收到后会开始清理当前的旧页面的文档,然后发出确认消息给渲染进程,同时浏览器更新浏览器界面(安全状态、URL、前进后退历史状态)并更新页面(此时是空白页)

开始渲染

由于渲染机制很复杂,需要执行的任务很多,所以渲染模块执行过程会被分为多个子阶段,开始一个边解析边渲染的流程,过程是这样子的

因为浏览器不能直接理解和使用html,所以要先构建DOM树,将html转为浏览器认识的结构

DOM 树

构建DOM树的流程是这样的

  • 由html解析器接收html,将原始字节数据转换成字符
  • 根据html规范对字符词法分析,将字符解析成标记也称为令牌(token)
  • 对标记进行语法分析,转成DOM节点对象并定义属性和规则
  • 解析器会维护一个解析栈,栈底为document,也就是DOM树的根节点
  • 然后根据节点对象关系按顺序依次向解析栈添加,形成DOM树

这个过程中,display:none的元素、script标签、注释也都会添加到DOM树中

解析过程中遇到没有async和defer的script标签引用入时,会暂停解析过程,同时通过网络线程加载文件,文件加载后切换至js引擎执行相应代码,代码执行完成后再切换回渲染引擎继续渲染流程

因为JS有可能会修改DOM,所以JS执行结束前,没有必要继续解析HTML

有了DOM树,还需要为每个DOM节点计算样式

样式计算

因为浏览器同样不认识CSS样式文本,所以渲染引擎拿到CSS之后,首先格式化CSS,将CSS转为一个成浏览器认识的结构styleSheets

至于怎么格式化的,有link标签、style标签、内联样式都有区别,总之这个过程非常复杂,这里就不展开了

最后的格式化后结果,有兴趣的可以在控制台输入document.styleSheets看看

然后对计算好的样式进行标准化操作,比如rem、颜色(blue,red)、字体(bold),转成统一的渲染引擎更容易理解的值

样式已经格式化和标准化之后,就可以计算每个节点的具体样式信息了

计算规则主要是继承层叠

继承:每个子节点默认继承父节点的样式属性,如果没有定义样式,浏览器对每个节点添加默认的样式

层叠:是CSS的基本特性,CSS(Cascading Style Sheets)翻译过来就是层叠样式表,从这就可以看出来。默认情况下CSS是流式布局的,元素与元素之间不会重叠,可有一些情况下流式布局会被打破,比如浮动(float)、定位(position)等,所以就需要计算出哪些脱离了文档流的元素,并记住它们的层叠信息,以便于后面进行分层

总之计算阶段的目的就是计算出DOM树中每个节点的位置信息、样式数据、文本节点数据

然后是CSS解析和DOM解析是可以同时进行的,但是script执行和CSS解析不能同时进行,CSS会阻塞JS执行

因为JS执行时可能在文档的解析过程中 获取样式信息,如果样式信息没有加载和解析完毕,JS就会得到错误的值,所以会延迟JS执行

知道DOM结构和DOM树中元素的样式后,接下来需要计算DOM树中可见元素的几何位置等信息,这个过程叫布局

layout布局

首先创建布局树

  • 遍历DOM树中所有节点,将可见节点添加到布局树中,不可见的不包括,如meta、script、display:none...
  • 然后根据DOM结构和元素样式 对布局树中节点的几何位置信息计算

计算的过程非常复杂,如果直接按布局树的顺序渲染就会导致很多错误,比如说z-index很高的先渲染了,总不能被后渲染的给压制住了,所以还需要分层

分层

上面介绍了层叠,是为了分层,因为脱离文档流的元素会形成一个层叠上下文。类似于(ps)中的图层,如图

这一步就是对布局树中特定的节点生成专用的图层,所以不是每一个节点都生成一个图层的,只有满足以下条件之一才会被提升为单独的图层,不然它就属于父节点的层

  • 拥有层叠上下文属性的元素,比如:
    • html
    • z-index不为auto
    • position:fixed
    • opacity小于1
    • transform不为none
    • filter不为none
    • -webkit-overflow-scrolling:touch
  • 裁剪的地方,比如内容溢出裁剪
  • 不裁剪出现滚动条的话,滚动条也会被提升为单独的层

然后主线程为每个图层计算样式,把每一个图层的绘制拆分成很多小的绘制指令,生成绘制表,这个表表记录了绘制的顺序和绘制指令

栅格化

有了绘制表后,主线程会把绘制表通过commit提交给合成器线程

因为一个图层可能会太大,所以合成器线程会再将图层分成图块

另外渲染引擎还维护了一个栅格化(光栅化)线程,合成线程将分割好的图块发送给栅格化线程,然后分别栅格化每个图块,再将栅格化之后的图块存储在GPU内存

合成器线程能够对不同的栅格化线程做优先处理,所以出现在视口内的图块会被优先栅格化

合成和显示

当图块都被栅格化完成后,合成线程会收集栅格化线程的draw quads图块信息,该信息记录了图块在内存中的位置信息和图块在页面中的位置信息

根据这些信息,合成器线程生成一个合成器帧,然后通过IPC传给浏览器进程

浏览器进程里有个叫 viz 的组件,用来接收这个合成器帧

然后浏览器进程再将合成器帧绘制到显存中,再通过 GPU 渲染在屏幕上,这时候终于看到了页面内容

当屏幕内容发生变化,比如滚动了页面,合成器线程就会将栅格化好的层合成一个新的合成器帧,新的帧再传到显存,GPU 再渲染到页面上

补充

重绘和重排

重排也叫回流,就是改变一个元素的尺寸位置属性时,会重新进行样式计算,布局、绘制以及后面所有流程

重绘比如改变元素的颜色时,就会触发重绘,重绘不会重新触发布局,但还是会触发样式计算和绘制

所以重排一定会触发重绘,重绘不一定会触发重排

在页面首次加载时,必然会触发重排和重绘

怎么避免重绘和重排

重排和重绘都是运行在主线程上,而JS也是在主线程上执行,就会出现抢占执行时间的问题

如果写了一个不断触发重排重绘的动画,那浏览器需要在每一帧都运行样式计算布局和绘制的操作

我们知道每秒60帧才不会让用户感觉到卡顿,如果在运行动画时还有大量JS需要执行,因为布局绘制和JS都是在主线程上运行的,不发在一帧的时间内布局和结束后,如果还有剩余时间,JS就会拿到主线程的使用权

如果JS执行时间过长,就会导致下一帧动画开始时JS还没有执行完,而出现下一帧动画没有按时渲染导致动画卡顿的现象,怎么优化呢?

  • 一是通过requestAnimationFrame()来解决这个问题

    因为这个方法会在每一帧被调用,通过API的回调,我们可以把JS运行任务分成一些更小的任务块(分到每一帧),在每一帧时间用完前暂停JS执行,归还主线程,这样的话在下一帧开始时主线程就可以按时执行布局和绘制

    react最新的渲染引擎React Fiber,就是用到了这个API来做了很多优化

  • 二是通过transform,刚才的流程我们知道栅格化的整个流程是不占用主线程的,只在合成器线程和栅格线程中运行

    这就意味着它不用JS抢主线程,刚才提到反复重绘和重排会导致掉帧,是因为JS阻塞了主线程,而通过CSS中的动画属性transform实现的动画不会经过布局和绘制,而是直接运行在合成器线程和栅格化线程中,所以不会受到主线程JS执行的影响

    更重要的是通过transform实现的动画由于不需要经过布局和绘制,样式计算等操作,所以节省了很多运算时间

渲染优化

针对JS

因为JS会阻塞HTML解析,也会阻塞CSS解析,所以script标签尽量放在body的最后,然后尽量使用异步加载的方式引入JS资源,比如async,defer属性,都不会阻塞DOM的解析

  • async:立即请求文件,但不阻塞渲染引擎,而是文件加载完毕后再阻塞渲染引擎并执行js先
  • defer:立即请求文件,但不阻塞渲染引擎,等解析完HTML再执行js
  • H5标准的type="module":让浏览器按照ES6标准将文件当模板解析,默认阻塞效果和defer一样,也可以配合async在请求完成后立即执行

针对CSS

style是GUI渲染线程直接渲染的,而link和@import都是导入外部样式

  • link:浏览器会派发一个新的http线程加载资源文件,同时GUI渲染线程会继续向下渲染
  • @import:GUI渲染线程会暂停止渲染,去服务器加载资源,资源文件没有返回前不会继续渲染

外部样式如果长时间没有加载完成,浏览器为了用户体验会自动使用默认样式,以确保首次渲染的速度,所以CSS一般写在header中,让浏览器尽快去请求样式,所以在开发过程中,导入外部样式尽量使用link,而不用@import,如果CSS少,就尽可能使用内嵌样式,直接写在style标签里

针对DOM树

  • html代码的嵌套层级不要太深
  • 使用语义化标签,来避免不标准的的语义化处理
  • 减少CSS代码的层级,因为选择器是从右向左进行解析的

减少重排和重绘

  • 对于频繁操作元素的样式,尽量使用类名,而不是样式
  • 多个DOM操作批量操作后再一次插入
  • 离线操作DOM。对DOM节点有较大改动的时候,我们先将元素脱离文档流,然后对元素进行操作,最后再把操作后的元素放回文档流,比如将元素display:none之后修改完再显示出来
  • 克隆标签再修改

其实浏览器自身针对重排与重绘也进行也渲染队列的优化,就是会将所有的重排和重绘操作放在一个队列里,当队列中的操作达到一定量,或者一定时间间隔,浏览器就会对队列批量处理,这样就会让多次重排和重绘变成一次

使用浏览器预解析

就是在执行JS脚本时,再由另一个线程解析剩下的文档,并加载后面需要通过网络加载的资源,这样可以使资源并行加载从而整体速度更快

结语

点赞支持、手留余香、与有荣焉

感谢你能看到这里,加油哦!