从输入url到浏览器渲染
"浏览器输入URL发生了什么"是一道经典的面试题。虽然在工作中我们更多地接触框架,但回过头来思考这个问题,会发现其中蕴含着互联网标准制定者的智慧结晶。沿着这个问题的脉络,我们可以填补一些知识盲区,为理解底层框架的优化打下坚实的基础,同时可以为解决实际工作中的问题提供更多思路,快速评估解决方案的能力。
那话不多说我们顺着这个脉络梳理一下,在浏览器输入URL到渲染完成究竟发生了什么?
STEP1:解析URL
浏览器进程检查url,组装协议,构成完整url,浏览器的主进程将url发给网络进程
STEP2: 缓存检查
浏览器缓存(Browser Caching)是为了节约网络的资源加速浏览,浏览器在用户磁盘上对最近请求过的文档进行存储,当访问者再次请求这个页面时,浏览器就可以从本地磁盘/内存读取请求内容,这样就可以加速页面的阅览。
通常浏览器缓存策略分为两种:强缓存(Expires,cache-control)和协商缓存(Last-modified ,Etag),并且缓存策略都是通过设置 HTTP Header 来实现的。
先来借用一张图,看看浏览器使用缓存和存储缓存的机制
根据浏览器是否需要向服务器请求是否可以使用缓存,缓存可以分为两种,强制缓存和协商缓存
强制缓存
强制缓存是指,通过缓存标识由浏览器来决定是否使用当前的缓存。
协商缓存
协商缓存,是指浏览器告诉服务端当前的缓存标识,由服务器来判断当前是否使用缓存。
由上面两个缓存的定义可以看出,强制缓存浏览器可以自行判断,效率自然比协商缓存高,浏览器在判断缓存时会先判断是否命中协商缓存,然后再去判断是否命中协商缓存,所以优先级是:强制缓存>协商缓存。
刚才提到了缓存标识,这个标识是放在请求头和返回头中,我们先来了解一下相关的字段和含义吧
Expires
Expires是HTTP/1.0控制网页缓存的字段,其值为服务器返回该请求结果缓存的到期时间,即再次发起该请求时,如果客户端的时间小于Expires的值时,直接使用缓存结果。
到了HTTP/1.1,Expire已经被Cache-Control替代,原因在于Expires控制缓存的原理是使用客户端的时间与服务端返回的时间做对比,那么如果客户端与服务端的时间因为某些原因(例如时区不同;客户端和服务端有一方的时间不准确)发生误差,那么强制缓存则会直接失效,这样的话强制缓存的存在则毫无意义,那么Cache-Control又是如何控制的呢?
Cache-Control
在HTTP/1.1中,Cache-Control是最重要的规则,主要用于控制网页缓存,主要取值为:
- public:所有内容都将被缓存(客户端和代理服务器都可缓存)
- private:所有内容只有客户端可以缓存,Cache-Control的默认取值
- no-cache:客户端缓存内容,但是是否使用缓存则需要经过协商缓存来验证决定
- no-store:所有内容都不会被缓存,即不使用强制缓存,也不使用协商缓存
- max-age=xxx:缓存内容将在xxx秒后失效
由于Cache-Control 是新的标准,在http 1.1中自然是 优先级大于 Expires
Etag(返回头) / If-None-Match(请求头)
Etag是服务器响应请求时,返回当前资源文件的一个唯一标识(由服务器生成)
If-None-Match是客户端再次发起该请求时,携带上次请求返回的唯一标识Etag值,通过此字段值告诉服务器该资源上次请求返回的唯一标识值。服务器收到该请求后,发现该请求头中含有If-None-Match,则会根据If-None-Match的字段值与该资源在服务器的Etag值做对比
若强制缓存(Expires和Cache-Control)生效则直接使用缓存,若不生效则进行协商缓存(Last-Modified / If-Modified-Since和Etag / If-None-Match),协商缓存由服务器决定是否使用缓存,若协商缓存失效,那么代表该请求的缓存失效,重新获取请求结果,再存入浏览器缓存中;生效则返回304
Last-Modified(返回头)/ If-Modified-Since(请求头)
Last-Modified是服务器响应请求时,返回该资源文件在服务器最后被修改的时间,
If-Modified-Since则是客户端再次发起该请求时,携带上次请求返回的Last-Modified值,通过此字段值告诉服务器该资源上次请求返回的最后被修改时间。服务器收到该请求,发现请求头含有If-Modified-Since字段,则会根据If-Modified-Since的字段值与该资源在服务器的最后被修改时间做对比,若服务器的资源最后被修改时间大于If-Modified-Since的字段值,则重新返回资源,状态码为200;否则则返回304,代表资源无更新,可继续使用缓存文件
整个流程如下图
STEP3: DNS解析
发起请求第一步需要DNS解析,将域名转换成IP地址,DNS在浏览器端也是有缓存的,一般时间很短,如果没有,就会交给操作系统,操作系统有可能有缓存,如果没有会先检查hosts文件,如果没有会请求的DNS服务器,DNS服务器返回的结果可能有两种
- 正常解析
- 告知客户端自身无法解析,可以去上游DNS查询
STEP4:TCP链接
一旦获取到服务器 IP 地址,浏览器就会通过 TCP“三次握手” (en-US)与服务器建立连接。这个机制的是用来让两端尝试进行通信——在浏览器和服务器通过上层协议 HTTPS 发送数据之前,可以协商网络 TCP 套接字连接的一些参数。
首先我们来看下TCP报文的格式,里面包含了哪些内容
其中比较关键的有 序列号(seq),确认应答号(ack)
标示位:ACK,SYN,RST,FIN,RSH,URG,这些标示的值为0/1。
在建立请求前
- 客户端会发送一个SYN=1,seq = (client_isn 随机生成的一个数字),
- 当服务端收到后会发送一个SYN=1,ACK=1,ack= client_isn+1,seq=_server_isn(服务端随机生成的数),
- 当客户端收到上述报文后,会验证是否满足ack==client_isn+1,如果正确,发送ACK=1,SYN=0,ack=serverisn+1 告诉服务器,我已收到刚才的报文,并且准备就绪
- 当服务端收到最后一个报文时会校验ack== server_isn+1,如果是,则准备好接受数据
扩展阅读
在学习的过程中发现一个还不错的blog,详细解释了网络知识,感兴趣的可以看这里
4.1 TCP 三次握手与四次挥手面试题 | 小林coding (xiaolincoding.com)
STEP5:服务器返回数据
一旦我们建立了到 web 服务器的连接,浏览器就代表用户发送一个初始的 HTTP GET
请求,对于网站来说,这个请求通常是一个 HTML 文件。一旦服务器收到请求,它将使用相关的响应头和 HTML 的内容进行回复。
STEP6:浏览器渲染
一旦浏览器收到数据的第一块,它就可以开始解析收到的信息。“解析”是浏览器将通过网络接收的数据转换为 DOM 和 CSSOM 的步骤,通过渲染器把 DOM 和 CSSOM 在屏幕上绘制成页面。
具体步骤如下:
构建DOM树
第一步是处理HTML标记构造DOM树,DOM 树描述了文档的内容。html 元素是第一个标签也是文档树的根节点。树反映了不同标记之间的关系和层次结构。嵌套在其他标记中的标记是子节点。DOM 节点的数量越多,构建 DOM 树所需的时间就越长。
当然在解析的过程中会遇到各种各样的资源,CSS、JS、Image等,当遇到CSS文件时解析可以继续进行,但是对于script标签(没有async、defer属性),会阻塞 HTML 的解析,对于有defer属性的script,会在 HTML 解析完成后再执行,async属性的script会在下载结束后立即执行。
通过上述我们可以知道解析html过程可能会被js的下载和执行中断,那如果等到发现资源才开始下载资源是不是晚了点,毕竟理论上解析和下载实际上是可以同时进行的,事实也是如此,浏览器是通过预加载扫描器让解析和下载同步进行的。
预加载扫描器:浏览器构建 DOM 树时,这个过程占用了主线程。当这种情况发生时,预加载扫描仪将解析可用的内容并请求高优先级资源,如 CSS、JavaScript 和 web 字体。也就是说在构建DOM树的同时,预加载扫描器已经开始扫描哪些资源需要下载,并且按照一定的优先级开始下载内容了。
构建CSSOM树
当解析HTML的过程中遇到style 标签或者 link标签的css 文件下载完成后,会开始解析CSS,
浏览器将 CSS 规则转换为可以理解和使用的样式映射。浏览器遍历 CSS 中的每个规则集,根据 CSS 选择器创建具有父、子和兄弟关系的节点树。
构造渲染树
浏览器会根据DOM和CSSOM构造渲染树,,浏览器会检查每个节点,从 DOM 树的根节点开始,并且决定哪些 CSS 规则被添加。
渲染树只包含了可见内容。头部(通常)不包含任何可见信息,因此不会被包含在渲染树种。如果有元素上有 display: none;
,它本身和其后代都不会出现在渲染树中。
布局
布局指的是浏览器更具当前设备的尺寸和当前的渲染树,每个元素在位置,尺寸的过程,所以这一过程会收到两个因素的影响,渲染树中元素相对位置的变化或者外部条件(视窗大小、横竖屏)的影响
回流说的就是因为元素相对位置属性可能发生变化导致需要重新布局的过程。
渲染
通过上面的布局,我们能够得到一个布局树,里面每个元素我们称之为LayoutObject(布局对象),在布局树中每个LayoutObject 并不是孤立的,有些是紧密相关的,比如在父级设置了透明度,所有的子元素的透明度也会跟着改变,那这个父级以及子元素是一体的,浏览器会将这些元素合并成一个PaintLayer渲染层,我们借用一下淘系前端团队 (taobao.org) 博客中的总结
根据创建 PaintLayer 的原因不同,可以将其分为常见的 3 类
- NormalPaintLayer
- 根元素(HTML)
- 有明确的定位属性(relative、fixed、sticky、absolute)
- 透明的(opacity 小于 1)
- 有 CSS 滤镜(fliter)
- 有 CSS mask 属性
- 有 CSS mix-blend-mode 属性(不为 normal)
- 有 CSS transform 属性(不为 none)
- backface-visibility 属性为 hidden
- 有 CSS reflection 属性
- 有 CSS column-count 属性(不为 auto)或者 有 CSS column-width 属性(不为 auto)
- 当前有对于 opacity、transform、fliter、backdrop-filter 应用动画
- OverflowClipPaintLayer
- overflow 不为 visible
- NoPaintLayer
- 不需要 paint 的 PaintLayer,比如一个没有视觉属性(背景、颜色、阴影等)的空 div。
满足以上条件的 LayoutObject 会拥有独立的渲染层,而其他的 LayoutObject 则和其第一个拥有渲染层的父元素共用一个。
通过上面树形结构一定程度上已经被压缩了,但是我们会发现如果按照上述规则来看,这个树还是非常复杂的,比如上述的relative 就会创建单独的层,事实上很多元素即便设置了relative,在不设置opacity 或者3d属性,形变属性的情况下和父级的PaintLayer 一起渲染没有什么问题,所以浏览器会对渲染树进一步压缩,并不是每一个渲染层都会分配一个GraphicLayer,这个层我们称之为合成层。将多个渲染层合并成一个合成层的过程就是层压缩。
每个 GraphicsLayer 都有一个 GraphicsContext,GraphicsContext 会负责输出该层的位图,位图是存储在共享内存中,作为纹理上传到 GPU 中,最后由 GPU 将多个位图进行合成,然后 draw 到屏幕上,此时,我们的页面也就展现到了屏幕上。
层的转换过程如下图所示
那这里为什么会有这么多树呢?
仔细想想每个树都有它存在的道理
布局树能够很快找到具体哪个元素发生了改变,什么属性发生了改变,
渲染层能够找到某个元素改变的最小影响范围
合成层能够防止层过多而影响性能
结合上述的过程我们总结一下开发过程中有哪些可以提升性能优化的点
性能优化建议:
- 使用动画时减少使用改变布局的属性,更多的使用transform 和opacity(不会导致回流和重绘)
- 合理使用位置属性,将相关的内容放到一个层中,比如列表,可以放到一个层中,避免因为外部的影响导致无法层合并
- 合理使用defer/async ,无需及时操作dom 的script 可以延迟执行,避免阻塞渲染
- 如果是客户端渲染,可以尽量将html 压缩到14kb,保证浏览器接收到第一个包时就可以拿到完整的html
你还知道哪些性能优化建议呢,原因是什么?欢迎您在留言区留言~
参考文章
关键渲染路径 - Web 性能 | MDN (mozilla.org)
从输入 URL 到页面展示到底发生了什么?看完吊打面试官! - 知乎 (zhihu.com)