详谈从输入url到页面显示内容的过程发生了什么

1,422 阅读20分钟

相信面试过很多次的同学都会经常看到这道题,这里我把我整理的,会在这个阶段发生的内容写出来,同时说说还能引申到什么其他的知识点,引导面试官去问这些问题

1.构建请求


浏览器通过输入的url来解析要请求的是什么协议,构建请求报文,这里要构建的就是HTTP请求报文
HTTP请求报文包括报文首部和报文主体,对请求报文来说,报文首部包括请求行和各种首部字段,而对响应报文来说,报文首部包括状态行和各种首部字段

请求行:GET/HTTP/1.1 (请求方法和相应的协议)
状态行:HTTP/1.1 200 OK (相应的协议和状态码)
各种首部字段:诸如上文提到的Cache-Control和Expires,还有If-No-Match,If-modified-since,Etag等 报文主体:在get请求中没有,post请求中向服务器发出的数据,响应报文中为服务器返回给客户端的内容

下面就是www.baidu.com对应的请求头首部字段

在这里插入图片描述
(这里可以提一下在这部分设置一些相关的字段会影响后面的强缓存和协商缓存)

2. 强/本地缓存的查找


通过请求报文,找到本地相应资源的响应报文,查看expires或cache-control来判断是否使用强缓存,在HTTP1.0里面expires优先于cache-control,而在HTTP1.1则相反
如果缓存资源还没有过期,那么就使用本地缓存的资源,返回状态码304,如果资源已过期,那么接着执行
(这里可以从expires和cache-control来引出问HTTP1.1比HTTP1.0多了什么,当然如果不太清除的话就不要在这里说在不同HTTP版本里面两个的优先级关系)

强缓存相关内容

强缓存是根据返回头中的Expires或者Cache-Control两个字段来控制的,都是表示资源的缓存有效时间。

Expires是 http 1.0 的规范,值是一个GMT 格式的时间点字符串,比如 Expires:Mon,18 Oct 2066 23:59:59 GMT 。这个时间点代表资源失效的时间,如果当前的时间戳在这个时间之前,则判定命中缓存。有一个缺点是,失效时间是一个绝对时间,如果服务器时间与客户端时间偏差较大时,就会导致缓存混乱。而服务器的时间跟用户的实际时间是不一样是很正常的,所以 Expires 在实际使用中会带来一些麻烦。

Cache-Control这个字段是 http 1.1 的规范,一般常用该字段的 max-age 值来进行判断,它是一个相对时间,比如

.Cache-Control:max-age=3600 代表资源的有效期是 3600 秒。并且返回头中的 Date 表示消息发送的时间,表示当前资源在 Date ~ Date +3600s 这段时间里都是有效的。不过我在实际使用中常常遇到设置了 max-age 之后,在 max-age 时间内重新访问资源却会返回 304 not modified ,这是由于服务器的时间与本地的时间不同造成的。当然 Cache-Control 还有其他几个值可以设置, 不过相对来说都很少用了:
no-cache 不使用本地缓存。需要使用协商缓存。
no-store直接禁止浏览器缓存数据,每次请求资源都会向服务器要完整的资源, 类似于 network 中的 disabled cache。
public 可以被所有用户缓存,包括终端用户和 cdn 等中间件代理服务器。
private 只能被终端用户的浏览器缓存。
如果 Cache-Control与 Expires 同时存在的话, Cache-Control 的优先级高于 Expires。

3. DNS解析


我们请求的时候,请求的地址是IP地址,所以要将我们写的url地址转换为IP地址,就需要DNS解析
在解析的时候,首先查看本地DNS服务器是否有相应域名对应的IP地址,如果有的话,直接返回请求该IP地址
如果没有的话,根据该地址向根域名服务器发起请求,获取对应的顶级域名服务器的地址,向该顶级域名服务器发起请求,获得对应的权限域名服务器对应的地址,然后返回最终的IP地址
返回的时候,不仅会返回到要请求的地方,还将该地址缓存到本地DNS服务器

4. 建立TCP连接


获得对应的IP地址后,就等于我们知道了服务器所在的地方,那么对于HTTP请求来说,下一步就是发起TCP连接了
首先,我们要进行三次握手建立TCP连接 首先最开始客户端和服务端的TCP进程都处于CLOSED(关闭)状态,由客户端主动打开连接,服务端被动打开连接。

  • 第一次握手:客户端先创建传输控制模块TCB,在建立连接的时候发出连接请求报文段,首部中同步位SYN=1,同时选择一个初始序号seq=x。此时TCP客户进程进入SYN-SENT(同步已发送)状态。
  • 第二次握手:服务端收到连接请求报文段后,如同意确认连接,则向客户端发送确认报文段,在确认报文段中将ACK和SYN都置为1,确认号ack=x+1,同时也要选择一个初始序号seq=y,此时TCP服务器进程进入SYN-RCVD(同步收到)状态
  • 第三次握手:客户进程收到服务端的确认报文段后,还要给服务端发出确认报文,确认报文段的ACK置为1ack=y+1,而自己的序号seq=x+1,客户端进入ESTABLISHED(已建立连接)状态,服务端在收到这个确认报文段后,也进入ESTABLISHED状态

(这里也可以说说为什么要三次握手,缺了第三次会怎样)

5. 发起HTTP请求


在TCP连接建立好之后,就可以从浏览器向服务端发送HTTP请求报文了

准备好HTTP请求报文后,就向服务器发起HTTP请求,然后等待服务器返回响应报文

在这个过程中,根据该HTTP连接的方式,会有不同的处理方式

短连接

如果是短连接的话,在这次HTTP请求发出得到响应后,便会四次挥手关闭TCP连接,在下一次要连接时重新建立HTTP连接,这是HTTP1.0中默认的连接方式

长连接

如果是长连接的话,在这次HTTP请求发出得到响应后,不会关闭TCP连接,而是会继续通过该连接,发起HTTP请求。当然,可以通过和服务器协商,关闭这个连接,可以通过connection值是否为Keep-Alive来判断,若服务器想明确断开连接的话,将connection的值设为close就可以了
然而,长连接存在一定的问题,同一个连接中的请求,是不能并行发起的,也即是说,只能等上一个请求得到响应后才能发起下一个请求,如果其中一个请求需要很长的时间,那么就会阻塞所有请求
当然,我们可以采用发起并行的HTTP连接来解决这个问题,这样我们可以同时发起几个请求,但始终还是会造成多次握手,而且请求的并行量也只限于建立的HTTP连接数
(这里可以扯一下请求放在网络进程里,看会不会引导问出浏览器进程的一些问题)

长连接是HTTP1.1中默认的连接方式,若想在HTTP1.0中使用,只要给connection字段添加Keep-Alive值就可以了

连接复用

在HTTP2.0中,出现了连接复用,允许在一个TCP连接中发起并行的HTTP请求,通过这种方式,解决了长连接出现的问题
(这里我说了三种连接方式很显然就是想引导到HTTP各个版本增加了什么)

此外,如果对cookie熟悉的话,这里可以加一句,如果有相应的cookie,请求会自动加上cookie发送,当然也分哪些请求,也可以提一下2 月份发布的 Chrome80 版本將cookie的SameSite属性默认设置从none改为Lax,导致一些请求无法自动携带cookie,然后看面试官待会会不会继续问下去。。。

6. 协商缓存


在发起HTTP请求后,我们所获取的资源还不一定是最新的资源,要先通过协商缓存来判断是否使用缓存
通过在请求中添加If-Modified-Since字段和If-None-Match字段来向服务器确认当前资源是否继续使用缓存

协商缓存

简单来说,协商缓存会通过在请求中添加If-Modified-Since字段和If-None-Match字段来向服务器确认当前资源是否继续使用缓存。

协商缓存是由服务器来确定缓存资源是否可用。 主要涉及到两对属性字段,都是成对出现的,即第一次请求的响应头带上某个字, Last-Modified 或者 Etag,则后续请求则会带上对应的请求字段 If-Modified-Since或者 If-None-Match,若响应头没有 Last-Modified 或者 Etag 字段,则请求头也不会有对应的字段。

Last-Modified/If-Modified-Since 二者的值都是GMT格式的时间字符串, Last-Modified 标记最后文件修改时间, 下一次请求时,请求头中会带上 If-Modified-Since 值就是 Last-Modified 告诉服务器我本地缓存的文件最后修改的时间,在服务器上根据文件的最后修改时间判断资源是否有变化, 如果文件没有变更则返回 304 Not Modified ,请求不会返回资源内容,浏览器直接使用本地缓存。当服务器返回 304 Not Modified 的响应时,response header 中不会再添加的 Last-Modified 去试图更新本地缓存的 Last-Modified, 因为既然资源没有变化,那么 Last-Modified 也就不会改变;如果资源有变化,就正常返回返回资源内容,新的 Last-Modified 会在 response header 返回,并在下次请求之前更新本地缓存的 Last-Modified,下次请求时,If-Modified-Since会启用更新后的 Last-Modified。

Etag/If-None-Match,值都是由服务器为每一个资源生成的唯一标识串,只要资源有变化就这个值就会改变。服务器根据文件本身算出一个哈希值并通过 ETag字段返回给浏览器,接收到 If-None-Match 字段以后,服务器通过比较两者是否一致来判定文件内容是否被改变。与 Last-Modified 不一样的是,当服务器返回 304 Not Modified 的响应时,由于在服务器上ETag 重新计算过,response header中还会把这个 ETag 返回,即使这个 ETag 跟之前的没有变化。

7. 获取响应

通过强缓存,协商缓存,最后不管是在缓存,还是在一些缓存的服务器上,又或者是在源服务器上,我们最终拿到了响应资源,将其渲染到页面上

在这里插入图片描述
请求http://www.baidu.com实际上就返回了这样的html文本 如果是在HTTP2.0中,因为有服务端推送的功能,所以可能当我们去请求一个html的时候,会将这个html引用到的css文件和js文件都通过服务端推送到客户端,这样请求的次数也会减少
(如果问的时候没说具体请求什么,这里可以扯一下如果是只请求资源的部分内容的话,可以通过在请求头添加Range来选择要请求哪一部分,顺便扯一下HTTP1.1里新的状态码206)

此外,我们在这里获取到响应报文,还可能得到一个301或302状态码的响应报文。这时网络进程会从响应头的Location字段里面读取重定向的地址,然后再发起新的HTTP或者HTTPS请求,一切又重头开始了
(这里可以尽情扯永久重定向和临时重定向的区别,这里我就不扯了)

这里实际上也可以提一下跨域,上一次我面试的时候被问到请求图片到显示的时候,说了句用img的src属性去得到一张图片不会引起跨域,后来就问了句你刚才说了跨域,说说跨域吧(引导成功.jpg)
(关于跨域的内容包括解决办法可以又写出一篇文章。。。我这里就不扯过多了)

8. 构建DOM树

因为我们请求的是一个html文件,而实际上,浏览器无法直接理解和使用html,所以需要将其解析为浏览器能理解的解构---DOM树
我们可以试着将下面的这部分html内容来转换成DOM树试试

<html>
    <head>
        <link href="index.css" rel="stylesheet">
    </head>
    <body>
        <p><span>重点介绍</span>渲染过程</p>
        <div>
            <div>div</div>
            <p>p</p>
        </div>
    </body>
</html>

这里蓝色背景的是DOM节点,而其他的则是节点内容
此外,我们也可以通过在控制台console里面输入document来观察这颗DOM树

9. 样式计算

样式计算是为了计算出DOM节点中每个元素的具体样式

1. 把css转换成浏览器能理解的结构

首先我们先确定一下,css的样式从哪里获取,有以下四种

  • 标签自带的样式
  • 通过link/@import引用的css文件
  • style标签内的css样式
  • 元素内嵌的css样式

当渲染引擎接收到这些css样式之后,会将这些css样式转换为stylesheets
我们可以通过在控制台console里面使用document.stylesheets来查看相关结构

2. 转换样式表中的属性值,使其标准化

对于样式中em这样的属性值,或者font-weight的bold,color的red等,这些都不容易被渲染引擎理解,所以要将其转换成标准值,比如2em在font-size为16px的时候,2em就会转换成32px,bold转换成一个具体的数值,red转换城rgb(255,0,0)这样

3. 计算每个DOM的具体样式

我们上面也说过了,样式的获取有四个来源,而在这四个来源中,可能会对同一个元素的样式进行重复的设置,所以我们需要在这一步去具体计算每个元素的具体样式
关于具体样式,就涉及到了css的继承规则和层叠规则了

CSS继承

首先是CSS继承,我们知道,在css中,父元素的样式,会直接做为子元素的样式,比如下面的样式

body{
    font-size:20px;
}

如果我们没对body里的子元素做font-size样式的设置的话,就会采用在这里body设置的样式
我们可以通过在控制器里面的element标签页,选中一个元素,在下方的styles标签页查看具体的样式信息,除了样式的值外,还能看到样式具体是在哪个文件里设置的,以及哪些样式被无效化/覆盖(被划了一条横线)了

CSS层叠

CSS层叠是CSS的一个非常重要的规则,它决定了当我们使用多个选择器去设置一个元素的样式时,我们要使用哪一种,这涉及到各种设置对应的权值

10. 布局阶段

1. 创建布局树

在得到了DOM节点树和stylesheets之后,我们就具备了构建布局树的条件,在构建布局树的时候,我们需要结合DOM节点树和stylesheets,来遍历所有可见的节点,将其添加到布局树中

在创建布局树时,要明确哪些元素是不会被添加进去的,比如head标签下的所有内容,都不会添加到布局树中,以及display样式为none的元素也不会被添加到布局树中

2. 布局计算

有关于布局的计算,不只是简单的二维位置上的计算,还涉及到“三维”的计算
我们知道,在页面上,我们是只能看到一个二维的显示的,但是实际上,我们也能看到很多元素是存在重叠的,那么,我们怎么去绘制这种重叠呢,就需要在垂直于页面的z轴上做一个分层了

分层

层叠上下文引起的分层

我们首先要了解一下层叠上下文的概念
这里就简单地说一下有哪些我们常见的层叠上下文的情况

  • html根标签,这是做为一个最基础的一个层级
  • position为absolute或者relative的且z-index不为auto的元素,设置这个样式的元素,在绘制时会覆盖在我们文档流的上方,所以也会提升为一个层
  • opacity小于1的元素,我们平时在写的时候能看到,如果样式呈现透明的话,我们是能看到背景的内容的,所以这也是一个提升层,同样地,filter设置的透明度也是一样的道理
  • transform设置translateZ为一个不为0的数值的时候,这个非常直观,直接相对于本身所处的层级进行一个z轴的移动,在布局计算分层时会被提升为一个新的层

实际上,还有其他情况,这里主要是要讲分层内容,所以不详细描述了,具体资料详见:MDN

通过上面举出的层叠上下文我们可以看到,凡是会引起重叠,或者在z轴上进行了移动的内容,都会引起分层

这也得出一个结论,在浏览器进行分层的时候,每个带有层叠上下文的元素都会被提升为一个单独的层

为了更直观地看到效果,我们通过一段代码和chrome开发者工具来查看分层

<html>

<head>
    <style>
        .transform {
            transform: translateZ(10px);
        }
    </style>
</head>

<body>
    <p><span>重点介绍</span>渲染过程</p>
    <div>
        <div>div</div>
        <p class="transform">p</p>
    </div>
</body>

</html>

打开浏览器控制台,输入ctrl+shift+p,在搜索框内输入layers,选择show Layers

然后我们进入这个选项卡,可以看到这样的界面
这里在左边我们已经看到有两个分层了,document实际上就是html根元素的一个层,下面的p.transform就是使用了transform: translateZ(10px);样式的层,为了更直观地观察,我们选择可选操作第二个,旋转一下我们看的角度,将其转到侧面
此时我们可以明确地看到,确实是被分成了两层,通过修改transform: translateZ(10px);里面的移动值,可以改变两个层之间的距离

剪裁引起的分层

在页面出现剪裁的时候,同样会引起分层,我们举个例子

<html>

<head>
    <style>
        div {
            width: 100px;
            height: 100px;
            overflow: auto;
        }
    </style>
</head>

<body>
    <div>
        <p>这里我们给外部的div标签设置了一个width为100px,height为100px的宽高</p>
        <p>同时,为了实现一个剪裁,我们将其overflow设置为auto,使其出现滚动条效果</p>
    </div>
</body>

</html>

同样使用layers来查看分层,进入layers,同样转移到侧面视角,可以看到四个分层

那么,这四个分层对应的是什么呢,首先,和之前的例子一样,#document是html根元素所在的层,而此外的三个层,都出自div,这三个层分别是

  • 显示内容的层
  • 完整内容的层
  • 滚动条的层

我们可以将视角再偏移一下,就会更加明显了

这里我们要确定哪个是完整内容,其实拖动一下滚动条就行了,会发现中间的层在上下移动

11. 生成图层绘制列表

到了这一步,图层已经分层好了,我们也能确定要绘制的内容的具体位置,那么我们怎么去进行一个绘制呢

其实原理相当简单,举一个例子

上面这个图片要怎么绘制呢,首先,我们要绘制一个黑色的圆,然后绘制一个黄色的五角星,最会绘制一个红色的六边形
其实结果已经出来了,在浏览器渲染的时候,就是先从离我们“远”的那个图层开始绘制起,渲染机制通过将这些层做为一个个任务,每层对应一个任务,放在一个任务队列里,依次绘制这些图层

要观察这个过程,我们同样用到chrome浏览器开发者工具的Layers
用回我们上面的第一个例子,来到#document层,选择Paint Profiler,打开Profiler界面,可以看到绘制的过程

通过下图可见,绘制过程是从“远”的图层到“近”的图层
在拖动右下角的区域的进度条的时候,我们同样可以看到,绘制是是从上到下绘制的

但是到这一步,也只是生成图层绘制列表而已,还没真正开始绘制

12. 栅格化操作

实际上,绘制是由渲染引擎中的合成线程来完成的。
在绘制列表提交到合成线程之后,合成线程会将图层分成多个图块,这些图块的大小通常是256x256或者512x512
生成图块的优先级与我们当前的视口有关(一个页面中用户当前能看到的内容),合成线程会针对视口附近的图块优先生成位图,实际生成位图的操作是由栅格化来实现的。而图块是栅格化的最小单位。

通常,栅格化过程都会使用GPU来加速生成,使用GPU生成位图的过程叫快速栅格化,或者GPU栅格化,生成的位图被保存在GPU内存中。

GPU操作是运行在GPU进程中,如果栅格化操作使用了GPU,那么最终生成位图的操作是在GPU中完成的,这就涉及到了跨进程操作。

13. 合成与显示

一旦所有的图块都被光栅化,合成线程就会生成一个绘制图块的命令,DrawQuad,然后将该命令提交给浏览器进程

浏览器进程里面有一个叫viz的组件,用来接收合成线程发过来的DrawQuad命令,然后根据DrawQuad命令,将其页面内容绘制到内存中,最后再将内存显示在屏幕上。

如果还有哪里漏了,请大家帮忙在评论里指出,十分感谢!