输入URL到页面显示的前端体系知识


前言

从输入 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 解析过程是这样子的

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

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

如果都没有找到,就会走 DNS 服务器设置的转发器请求,如果没设置转发模式,就向13根发起解析请求,这里的查询方式是迭代查询,如图

很明显,整个过程会发出多次查询请求

  1. 先去 DNS 根域名(.)服务器查询,属于哪个顶级域名服务器(.com),然后返回顶级域名服务器 IP
  2. 再根据返回的 IP 去顶级域名服务器查找,属于哪个权威域名服务器(xxx.com),返回权威域名服务器 IP
  3. 再根据返回的 IP 去权威域名服务器查找
    • 如果没有配置 CDN,就直接返回解析到的 IP
    • 如果有配置 CDN,权威域名会返回一个 CName 别名记录,它指向 CDN 网络中的智能 DNS 负载均衡系统,然后负载均衡系统通过智能算法,将最佳 CDN 节点的 IP 返回
1
2
3
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握手

1
2
这种握手方还有优化空间吗?
使用会话复用,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 脚本时,再由另一个线程解析剩下的文档,并加载后面需要通过网络加载的资源,这样可以使资源并行加载从而整体速度更快


文章作者: 沐华
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 沐华 !
 上一篇
再不怕被问TCP/IP/UDP 再不怕被问TCP/IP/UDP
TCP/UDP 作为传输层的协议,是经常在面试中会被问到的知识点,也是一个软件工程师素养必须具备的基础素养
下一篇 
Web Worker Web Worker
我们都知道JS是单线程的,虽然可以通过AJAX、定时器等可以实现"并行",但还是没有改变JS单线程执行的模式。那么如何使用Web Worker为JS创造多线程环境呢
2020-05-11