浏览器是如何渲染页面的
浏览器的网路线程收到html文档后,会产生一个渲染任务,并将其传递给渲染主线程的消息队列。
在事件循环的机制下,渲染主线程取出消息队列中的渲染任务,开启渲染流程。
整个渲染流程分为多个阶段,分别是:html解析、样式计算、布局、分层、绘制、分块、光栅化、画
每个阶段都有明确的输入输出,上个阶段的输出会成为下一个阶段的输入,整个渲染流程是一个组织严密的生产流水线
渲染的第一步是解析html
解析过程中遇到css解析css,遇到js执行js。为了提高解析效率,浏览器在开始解析前,会启动一个预解析的线程,率先下载html中的外部css文件和外部的js文件
如果主线程解析到了link位置,此时外部的css文件还没有下载解析好,主线程不会等待,继续解析后续的html,这是因为下载和解析css的工作是在预解析线程中进行的。这就是css不会阻塞html解析的根本原因
如果主线程解析到script位置,会停止解析html,等待js文件下载好,并将全局代码解析完成后,才能继续解析html,这是因为js执行的过程中可能会改变DOM树,所以DOM树的生成必须暂停。这就是js会阻塞html解析的根本原因
第一步完成后,会得到DOM树和CSSOM树,浏览器的默认样式、内部样式、外部样式、行内样式均会包含在CSSOM树中
渲染的下一步是样式计算
主线程会遍历得到的DOM树,依次为树中的每个节点计算出它最终的样式,称之为computed style
在这一过程中,很多预设值会变成绝对值,比如red会变成rgb(255, 0, 0),相对单位会变成绝对单位,比如em会变成px
这一步完成后,会得到一颗带有样式的DOM树
接下来是布局,布局完成后会得到布局树
布局阶段会依次遍历DOM树的每一个节点,计算每个节点的几何信息。例如节点的宽高、相对包含块的位置
大部分时候,DOM树和布局树并非一一对应
比如display: none的节点没有任何几何信息,因此不会生成到布局树,又比如使用了伪元素选择器,虽然DOM树中不存在这些伪元素节点,但它们拥有几何信息,所以会生成到布局树中。还有匿名行盒、匿名块盒等都会造成DOM树和布局树无法一一对应
下一步是分层
主线程会使用一套极为复杂的策略对整个布局树进行分层
分层的好处在于,将来某一个层改变后,只会对这个层进行处理,从而提升效率
滚动条,堆叠上下文,transform,opacity等样式会或多或少影响分层结果,可以通过will-change属性更大程度的影响分层结果
再下一步是绘制
主线程会为每个分层单独产生绘制指令集,用于描述这一层的内容该如何画出来
完成绘制后,主线程将每个图层的绘制信息提交给合成线程,剩余工作将由合成线程完成
合成线程首先会对每个图层进行分块,将其划分为更多的小区域
它会从线程池中拿取多个线程来完成分块工作
分块完成后,进入光栅化阶段
合成线程将块信息交给GPU进程,以极高的速度完成光栅化。
GPU进程会开启多个线程来完成光栅化,并且优先处理靠近视口区域的块
光栅化的结果,就是一块一块的图
最后一个阶段就是画
合成线程拿到每个层,每个块的位置后,生成一个个指引信息
指引会标识出每个位图应该画到屏幕的位置,以及会考虑到旋转、缩放等变形
变形发生在合成线程,与主线程无关,这就是transform效率高的根本原因
合成线程会把指引提交给GPU进程,由GPU进程产生系统调用,提交给GPU硬件,完成最终的屏幕成像
什么是reflow?
reflow的本质就是重新计算layout树
当进行了会影响布局树的操作后,需要重新计算布局树,会引发layout
为了避免连续多次的操作导致布局树反复计算,浏览器会合并这些操作,当js代码完成后统一计算。所以,改动属性造成的reflow是异步完成的
也同样因为如此,当js获取布局树属性时,可能会造成无法获取到最新的布局信息
浏览器在反复权衡下,最终决定获取属性时立即reflow
什么是repaint?
repaint的本质是重新根据分层信息计算了绘制指令
当改动了可见样式后,就需要重新计算,会引发repaint
由于元素的布局信息也属于可见样式,所有reflow一定会引起repaint
为什么transform的效率高?
因为transform既不会影响布局也不会影响绘制指令,它影响的只是渲染流程的最后一个阶段【画】
由于画阶段在合成线程中,所以transform的变化几乎不影响渲染主线程。反之,渲染主线程无论如何忙碌,也不会影响transform的变化
一、前端页面的生命周期
性能问题呈现给用户的感受往往是简单而直接的:加载资源缓慢、运行过程卡顿或相应交互迟缓等,当把这些问题呈现到前端工程师面前时,却是另一种系统级别复杂的图景。
从域名解析、TCP建立连接到HTTP的请求与响应,以及从资源请求、文件解析到关键渲染路径等,每一个环节都有可能因为设计不当、考虑不周、运行出错而产生不佳的体验。作为前端工程师,为了能在遇到性能问题时快速而准确地定位问题所在,并设计可行的优化方案,熟悉前端页面的生命周期是一堂必修课。
从输入url到页面的打开经历了什么,大致可以分为以下几个阶段:
- 浏览器接收到url,到网络请求线程的开启
- 一个完整的http请求发出
- 服务器接收到请求并转到具体的处理后台
- 前后台之间的http交互和涉及的缓存机制
- 浏览器接收到数据包后的关键渲染路径
- js引擎的解析过程
二、网络请求线程的开启
浏览器接收到url到开启网络请求线程,这个阶段是在浏览器内部完成的。
首先是对url的解析,它的各部分含义如下:
| 标题 | 名称 | 备注 |
|---|---|---|
| protocol | 协议头 | 说明浏览器如何处理要打开的文件,常见的有http/https、ftp、maito等 |
| host | 主机域名/ip地址 | 比如www.baidu.com,经过DNS解析为ip地址 |
| port | 端口号 | 请求程序和响应程序之间连接的标识,默认使用80端口 |
| path | 目标路径 | 请求的目录或文件名,比如/man/shoes |
| query | 查询参数 | 请求所传递的参数,比如age=20&sex=man |
| fragment | 片段 | 次级资源信息,通常作为 或锚点#abc |
结构:protocol://host:port/path?query#fragment 例如:taobao.com/man/shoes?a…
解析url后,如果是http协议,则浏览器会新建一个网络请求去下载所需的资源,要明白这个过程需要先了解进程和线程之间的区别,以及目前主流的多进程浏览器结构。
进程与线程
进程:一个程序运行的实例,操作系统会为进程创建独立的内存,用来存放运行所需的代码和数据
线程:线程是进程的组成部分,每个进程至少有一个主线程和若干的子线程,这些线程由所属的进程进行启动和管理
由于多个线程可以共享其所属进程上的资源,所以多线程的并行处理能有效提高程序的运行效率。
进程和线程的区别:
- 只要某个线程出错,将会导致整个进程出错
- 进程和进程之间相互隔离。这可以保证一个进程挂起或崩溃时,不会影响到其他进程。每个进程只能访问系统分配给自己的资源,可以通过IPC机制进行进程间通信
- 进程所占用的资源会在其关闭后由操作系统回收。即使进程中存在某个线程引起的内存泄漏,当进程退出时,相关的内存资源也会被回收
- 线程之间可以共享所属进程的数据
单进程浏览器
在2008年谷歌发布Chrome多进程浏览器之前,市面上几乎所有的浏览器都是单进程的,其中所有功能模块都运行在一个进程中。
单进程浏览器在以下方面有明显的隐患:
- 流畅性:首先是页面内存泄漏,浏览器内核通常非常复杂,单进程浏览器打开再关闭一个页面的操作,通过会有一些内存不能完全回收,这样随着使用时间延长,占用的内存会越来越多,从而引起浏览器运行变慢;其次由于很多模块运行在同一个线程中,如js引擎、页面渲染及插件等,那么执行某个循环任务的模块就会阻塞其他模块的任务执行,这样难免会有卡顿的现象发生
- 安全性:由于插件的存在,其中难免会有恶意脚本利用浏览器的漏洞来获取系统权限,进行安全问题的行为
- 稳定性:由于所有模块都运行在同一个进程中,对于稍复杂的js代码,如果页面渲染引擎崩溃,就会导致整个浏览器崩溃。同样,各种不稳定的第三方插件,也是导致浏览器崩溃的隐患
多进程浏览器
2008年Chrome推出多进程浏览器,把原先单进程内功能相对独立的模块抽离为单个进程。主要分为以下几个进程:
- 浏览器主进程:一个浏览器只有一个主进程,负责菜单栏、标题栏等界面显示,文件访问、前进、后退及子进程管理等
- GPU进程:GPU(图形处理单元)最初是为了实现3D的css效果,后来随着网页及浏览器在界面中的使用越来越普遍,Chrome便在架构中加入了GPU进程
- 插件进程:主进程会为每个加入浏览器的插件开启独立的子进程,由于进程间是互相隔离的,所以即便某个插件出错也不会对浏览器造成影响。另外,出于对安全的考虑,这里采用了沙箱模式,在沙箱中运行的程序会受到一些限制:不能读取敏感位置的数据,也不能在硬盘上写入数据。这样即使插件运行了恶意脚本,也无法获取系统权限
- 网络进程:负责页面的网络资源加载,之前属于浏览器主进程中的一个模块,最近才独立出来
- 渲染进程:也称为浏览器内核,默认会为每个标签窗口开辟一个独立的渲染进程,负责将html、css、js等资源转换为可交互的页面,其中包含多个子线程,即js引擎线程、GUI渲染线程、事件触发线程、定时器触发线程、异步http请求线程(发起一个ajax请求,在底层是开启一个独立的线程去处理的)等。当打开一个标签页输入url后,所发起的网络请求就是从这个进程开启的。另外,出于对安全性的考虑,渲染进程也被放入沙箱中
通过shift+esc打开浏览器进程控制台:
三、建立http请求
这个阶段的工作分为两部分:DNS和通信链路的建立
首先发起请求的客户端浏览器要明确知道要访问的服务器地址,然后建立通往该服务器地址的路径
DNS解析
url解析完后,以参数的形式传入网络请求线程(网络请求属于渲染进程)进行进一步处理,第一步会进行DNS解析。将url中的host转为ip地址,因为域名是方便记忆的,ip地址才是具体的“门牌号”。
如图所示,DNS解析首先查询浏览器自身的DNS缓存,如果查到ip地址就结束解析,由于缓存时间限制比较大,一般只有1分钟,同时缓存容量也有限制,所以在浏览器缓存中没找到ip地址时,就会搜索系统自身的DNS缓存,如果还未找到,就会从系统的hosts文件中查找。
在本地主机进行的查询如果都未找到,便会在本地域名服务器上查询。如果本地域名服务器没有直接的目标ip可供返回,则本地域名服务器会采取迭代的方式依次查询根域名服务器、COM顶级域名服务器和权限域名服务器,最终将ip地址返回或者返回报错信息。
由此可以看出,DNS解析是很耗时的过程,若解析的域名过多,会延缓首屏的加载时间。
网络模型
通过DNS解析获取到目标服务器ip地址后,就可以建立网络连接进行资源的请求与响应了。
网络架构模型:OSI模型、TCP/IP模型
OSI(开放系统互连)模型将网络划分为7层,TCP/IP借鉴了OSI引入的服务、接口、协议及分层的概念,建立了TCP/IP模型并广泛使用,称为目前互联网的标准。
TCP连接
当使用本地主机连上网线接入互联网后,数据链路层和网络层就已经打通了,而要向目标主机发起http请求,就需要通过传输层建立端到端的连接。
传输层常见的协议有TCP和UDP协议,TCP协议是面向有连接的通信协议,在数据传输之前需要建立好客户端与服务器端之间的连接,即“三次握手”,具体过程如下:
- 第一次握手,客户端向服务端发送一条消息,看看服务端能不能收到
- 第二次握手,服务端如果收到客户端发来的消息,告诉客户端我收到了
- 第三次握手,如果服务器端有反馈说能收到第一次的消息,再发送一条消息告诉服务器端我也能收到你的反馈
当用户关闭标签页或请求完成后,TCP连接会进行“四次挥手”断开连接,具体过程如下:
- 第一次挥手:客户端向服务器端发送一条消息告诉服务器端我已经没有要请求的了,请求断开连接
- 第二次挥手:服务器端接收到客户端发来的断开请求,会告诉客户端我知道你的请求了,但是当前还有数据正在传输呢,所以你先等会
- 第三次挥手:服务器端已经确定了所有数据传输完毕了,发送一条消息告诉客户端,我这边也可以断开了
- 第四次挥手:客户端接收到服务器端可以断开的消息,发送确认报文断开连接,发送后一段时间后没有收到服务器端传来的消息就可以断开了
如果已经建立了连接,但是客户端突然出现错误了怎么办?
TCP还设有一个保活计时器,客户端出现错误,服务器端不能一直等下去,服务器每次收到客户端的请求都会重新复位这个计时器,时间通常是2个小时,若2小时还没收到客户端的任何数据,服务器就会发送一个探测报文段,以后每隔75秒发送一次,一连发送10个探测报文仍没反应,服务器就会认为客户端出了故障,接着就关闭连接。
四、前后端的交互
当TCP连接建立好后,便可通过http等协议进行前后端的通信,但在实际的网络访问中,并非浏览器与确定ip地址的服务器之间直接通信,往往会在中间假如反向代理服务器。
反向代理服务器
对需要提供复杂功能的网站来说,通常单一的服务器资源是很难满足期望的。一般采用的方式是将多个应用服务器组成的集群由反向代理服务器提供给客户端用户使用,这些功能服务器可能具有不同类型,比如文件服务器、邮件服务器及web应用服务器,同时也可能是相同的web服务部署到多个服务器上,以实现负载均衡的效果,反向代理服务器的作用如图:
反向代理服务器根据客户的请求,从后端服务器上获取资源后提供给客户端。反向代理服务器通常的作用有:
- 负载均衡
- 安全防火墙
- 加密及SSL加速
- 数据压缩
- 解决跨域
- 对静态资源缓存
常用的反向代理服务器有Nginx、IIS、Apache
后端处理流程
经过反向代理服务器收到请求后,后端处理流程大致如下:
- 首先会有一层统一的验证环节,如跨域验证、安全校验拦截等。如果发现是不符合规则的请求,则直接返回相应的拒绝报文
- 通过验证后,进入具体的后台程序代码执行阶段,如具体的计算、数据库查询等
- 完成计算后,后台会以一个http响应数据包的形式发送回请求的前端,结束本次请求
http相关协议特性
http是建立在传输层TCP协议之上的应用层协议,在TCP层面上存在长连接和短连接的区别。所谓长连接,就是在客户端与服务器端建立的TCP连接上,可以连续发送多个数据包,但需要双方发送心跳检查包来维持这个连接。
短连接就是当客户端需要向服务器端发送请求时,会在网络层IP协议之上建立一个TCP连接,当请求发送并收到响应后,则断开此连接。如果TCP连接建立的过程频繁发生,是个很大的性能耗费,所以从http1.0开始对于连接的优化一直在进行。
在http1.0,默认使用短连接,浏览器的每一个http操作都会建立一个连接,任务结束则断开连接。
在http1.1,默认使用长连接,当一个网页的打开操作完成时,其中所建立用于传输http的TCP连接并不会断开,客户端后续的请求操作会继续使用这个已经建立好的连接。请求头中会有一行Connection: keep-alive。长连接并非永久保持,它有一个持续时间,可在服务器中进行配置。
http2.0之前,每个资源的请求都需要开启一个TCP连接,由于TCP本身有并发数的限制,这样的结果会导致请求的资源变多时,速度性能就会明显下降。为此进程会采用的优化策略会将静态资源请求进行多域名拆分,对于小图标或图片使用雪碧图等。
http2.0,可以在一个TCP连接上请求多个资源,分割成更小的帧请求,其速度性能会明显提升,之前针对http1.1限制的优化方案在http2.0中不再需要了。
http2.0除了一个连接可请求多个资源这种多复用的特性,还有以下新特性:
- 二进制分帧:在应用层和传输层之间,新加入了一个二进制分帧层,以实现低延迟和高吞吐量
- 服务器端推送:以前是一个请求带来一个响应,现在服务器可以向客户端的一个请求发出多个响应,这样便可以实现服务器端主动向客户端推送的功能
- 设置请求优先级:服务器会根据请求所设置的优先级,来决定需要多少资源处理该请求
- http头部压缩:减少报文传输体积
浏览器缓存
在基于http的前后端交互过程中,使用缓存可以使性能得到显著提升。具体的缓存策略分为两种:强缓存和协商缓存。
强缓存是当浏览器判断出本地缓存未过期时,直接读取本地缓存,无须发起http请求,此时状态为:200 from cache。在http1.1版本后通过头部的cache-control字段的max-age属性值规定的过期时长来判断缓存是否过期失效,这比之前使用expires标识的服务器过期时间更准确而且安全。
协商缓存则需要浏览器向服务器发起http请求,来判断浏览器本地缓存的文件是否仍未修改,若未修改则从缓存中读取,此时的状态码为:304。具体过程是判断浏览器头部if-none-match与服务器端的e-tag是否匹配,来判断所访问的数据是否发生改变。这相比http1.0版本通过last-modified判断上次修改文件的时间更为准确。
具体的浏览器缓存触发逻辑如图:
五、关键渲染路径(CRP)
当经历了网络请求过程,从服务器获取到了所访问的页面文件后,浏览器是如何将这些html、css、js组织在一起渲染出来的?
构建对象模型
首先浏览器会解析html和css文件,构建DOM(文档对象模型)和CSSOM(层叠样式表对象模型)。
浏览器接收读取到的html文件,是文件根据指定编码(UTF-8)的原始字节,将字节转换成字符,即原本的代码字符串,再将字符串转化为E3C标准规定的令牌结构,所谓令牌就是html中不同标签代表不同的含义。然后经过词法分析将令牌转化成定义属性和规则值的对象,最后将这些标签节点根据html表示的父子关系连接成树结构,如图:
构建CSSOM
DOM树表示文档标记的属性和关系,但未包含其中各元素经过渲染后的外观呈现,这便是CSSOM的职责,与将html转为DOM的过程类似,css也会经历从字节到字符串,然后令牌化及词法分析后构建为CSSOM,如图:
这两个对象模型的构建是会花费时间的
渲染绘制
当完成文档对象模型和层叠样式表对象模型的构建后,所得到的其实是描述最终渲染页面两个不同方面信息的对象:一个是展示的文档内容,一个是文档对象对应的样式规则,接下来就需要将两个对象模型合并为渲染树,渲染树中只包含渲染可见的节点,如图:
渲染绘制的大致步骤:
- 从生成DOM树的根节点开始向下遍历每个子节点,忽略所有不可见的节点(脚本标记不可见、CSS隐藏不可见),因为不可见的节点不会出现在渲染树中
- 在CSSOM中为每个可见的子节点找到对应的规则并应用
- 布局阶段,根据渲染树,计算它们在设备视图中的具体位置和大小,这一步输出的是一个“盒模型”
- 绘制阶段,将每个节点的具体绘制方式转化为屏幕上的实际像素
执行构建渲染树、布局及绘制过程中所需要的时间取决于实际文档的大小,文档越大,浏览器需要处理的任务就越多,样式也复杂,绘制需要的时间就越长,所以关键渲染路径执行快慢,直接影响到首屏加载时间。
当首屏渲染完成后,用户在和网站的交互过程中,有可能通过js代码提供的api更改渲染树,一旦DOM结构发生改变,这个渲染过程就会重新执行一遍。对于关键渲染路径的优化影响的不仅是首屏的加载时间,还有交互性能。
六、请求和响应优化
DNS解析
当浏览器从服务器请求资源时,必须先将跨域域名先解析为ip地址,然后浏览器才能发出请求,此过程便是DNS解析。DNS作为互联网的基础协议,其解析的速度似乎很容易网站优化人员忽视。现在大多数浏览器已经针对DNS解析进行了优化,比如DNS缓存。一次DNS解析需要耗费的时间大概为20-120ms,这个时间几乎可以忽略不计,但是网站中使用的资源依赖于多个不同的域时,时间就会成倍增加,从而增加了网站的加载时间。
一般来说,前端优化中与DNS有关的有两点:
- 减少DNS的请求次数
- 进行DNS预获取:DNS Prefetch
减少DNS请求次数
域名系统(DNS)将主机名映射到ip地址,在浏览器输入www.taobao.com时,浏览器联系的DNS解析器将返回该服务器的ip地址。DNS是有成本的,通常需要20-120ms查找主机名对应的ip,在DNS完成解析之前,浏览器无法从该主机名上下载任何内容。
缓存DNS查找以提高性能。这种缓存可以在由用户的ISP或局域网维护的特殊服务器上进行,但是在个别用户的计算机上也会发生缓存。DNS信息保留在操作系统的DNS缓存中。大多数浏览器都有自己的缓存,与操作系统的缓存分开。只要浏览器将DNS记录保留在自己的缓存中,就不会对操作系统发出记录请求进行打扰。
默认情况下,IE会缓存30分钟的DNS查找,这是由DNSCacheTimeout注册表设置的。FireFox在newwork.dnsCacheExpireation配置设置的控制下缓存DNS查找1分钟。Chrome也是1分钟。
当客户端的DNS缓存为空时(操作系统和浏览器都没有缓存),DNS查找的次数等于网页中唯一主机名的数量,包括页面的url、图像、脚本文件、样式表、flash对象等中使用的主机名。减少唯一主机名的数量就会减少DNS查找的次数。
减少域名的数量有可能减少页面中并行下载的数量。避免DNS查找会减少响应时间,但是减少并行下载可能会增加响应时间。我的原则是将这些资源划分为至少两个但不超过四个域名,这在减少DNS查找和运行并行下载之间是个折中的办法。
dns-prefetch
dns-prefetch(DNS域获取)是在请求资源之间先解析域名。域名解析和内容的载入是串行的,所以预先进行DNS解析可以减少用户等待DNS解析的时间。
dns-prefetch可以帮助开发人员掩盖DNS解析延迟。html<link>元素将rel属性设置为dns-prefetch,然后在href属性中指定要跨域的域名:
<link rel="dns-prefetch" href="//g.alicdn.com" />
淘宝网对dns-prefetch的使用:
每当站点使用跨域域上的资源时,都应在<head>元素中放置dns-prefetch提示,但是有一些注意事项:
- dns-prefetch仅对跨域域上的DNS查找有效,因此避免使用它指向自己的站点或域名,因为当执行到这一行代码时,当前域名背后的ip已经被解析了
- dns-prefetch需慎用,仅在首页使用就好,多页面重复DNS预解析会增加重复DNS查询次数
- 浏览器会进行隐式的dns-prefetch,当前正在浏览器的网页中如果有其他的域名,会自动进行预获取。如果想对页面中没有出现的域进行预获取,就要使用显示的dns-prefetch
- 虽然使用dns-prefetch能够减少DNS解析的时间,但是不能滥用。有开发者指出禁用DNS预获取能节省每月100亿的DNS查询,这是因为dns-prefetch一般在首页使用,但是预解析的并不是首页所需,要是用户不访问预解析的内容,那么此次预解析就是无用的
<meta http-equiv="x-dns-prefetch-control" content="off">
更多DNS解析优化(服务端):
- 延迟DNS缓存时间
- 尽可能使用A或AAAA记录代替CNAME
- 使用DNS加速域名
- 自己搭建DNS服务
清除DNS缓存
1、清除浏览器DNS缓存
- 清除DNS缓存:chrome://net-internals/#dns
- 有时候也需要同时清除套接字缓存池:chrome://net-internals/#sockets
2、清除系统DNS缓存
- 在Windows中查看DNS缓存记录:ipconfig/displaydns
- 在Windows中清除DNS缓存记录:ipconfig/flushdns
- 在macOS中清除DNS缓存记录:sudo killall -HUP mDNSResponder
http长连接
短连接:http协议的初始版本中,每进行一次http通过都要断开一次TCP连接。以早期的通信情况来说,因为都是些容量很小的文本传输,所以即使这样也没有多大问题。但是随着http的大量普及,文档中包含大量富文本(图片、视频等资源)的情况多了起来。当浏览器浏览一个包含多张图片的html页面时,在发送请求访问html页面资源的同时,也会要求html页面包含其他的资源,因此,每次的请求都会造成无畏的TCP连接建立和断开,增加通信开销。
为了解决短连接带来的问题,有些浏览器在请求时,用了一个非标准的Connection字段:
Connection: keep-alive
这个字段要求服务器不要关闭TCP连接,以便其他请求复用。服务器同样回应这个字段。一个复用的TCP连接就建立了,直到客户端或服务器主动关闭连接。但是,这不是标准字段,不同实现的行为可能不一致。
长连接:1997年1月,http1.1版本发布,只比1.0版本晚了半年。进一步完善http协议,知道现在还是最流行的版本。http1.1最大的变化就是引入了持久连接,即TCP连接默认不关闭,可以被多个请求复用,不用声明Connection: keep-alive。
持久连接的好处在于减少了TCP连接的重复建立和断开所造成的额外开销,减轻了服务器端的负载。另外,减少开销的那部分时间,使http请求和响应能更早的结束,这样web页面的显示速度也就相应提高了。
客户端和服务器发现对方一段时间没有活动,就可以主动关闭连接。不过,规范的做法是,客户端在最后一个请求时,发送Connection: close,明确要求服务器关闭TCP连接。
目前,对于同一个域名,大多数浏览器允许同时建立6个持久连接。
管道机制
http1.1还引入了管道机制(pipelining),即在同一个TCP连接里,客户端可以同时发送多个请求,进一步改进了http协议的效率。
之前发送请求后需要等待并接收响应,才能发送下一个请求。管线化技术出现后,不用等待响应即可直接发送下一个请求。这样可以做到同时并发多个请求,而不需要一个接一个地等待响应了,与挨个连接相比,用持久连接可以让请求更快结束。而管线化技术则比持久连接还要快,请求数越多,时间差就越明显。
举例来说,客户端需要请求两个资源。以前的做法是,在同一个TCP连接里面,先发送A请求,然后等服务器做出回应,收到后再发送B请求。管道机制则是允许浏览器同时发送A和B请求,但是服务器还是按照顺序,先回应A,再回应B。
Content-Length字段
一个TCP连接现在可以传送多个回应,势必要有一种机制,区分数据包是属于哪个回应的,这就是Content-Length字段的作用,声明本次回应的数据长度。
Content-Length: 3495
上面代码告诉浏览器,本次回应的长度是3495个字节,后面的字节属于下一个回应了。
在1.0版本中,Content-Length字段不是必要的,因为浏览器发现服务器关闭了TCP连接,就表明收到的数据包已经全了。
分块传输编码
使用Content-Length字段的前提条件是,服务器发送回应之前,必须知道回应的数据长度。对于一些很耗时的动态操作来说,这意味着服务器要等到所有操作完成,才能发送发送数据,显然这样的效率不高。更好的处理方法是,产生一块数据,就发送一块,采用“流模式”(stream)取代“缓存模式”(buffer)。
因此,1.1版本规定可以不使用Content-Length字段,而使用“分块传输编码”(chunked transferencoding)。只要请求或回应的头信息有Transfer-Encoding字段,就表明回应将由数量未定的数据块组成。
Transfer-Encoding: chunked
长连接的缺点
虽然http1.1允许复用TCP连接,但是同一个TCP连接里面,所有的数据是按次序进行的,服务器只有处理完一个回应,才会进行下一个回应。要是前面的回应特别慢,后面就会有很多请求排队等着,这称为“队头堵塞”。
为了避免这个问题,只有两种方法:
- 一是减少请求数
- 二是同时多开持久化连接
这导致了很多网页优化技巧,比如合并脚本和样式表、将图片嵌入css代码、域名分片等,如果http设计的好一些,这些额外的工作是可以避免的。
http2
2009年,谷歌公开了自研的SPDY协议,主要解决http1.1效率不高的问题。这个协议在Chrome浏览器上证明可行后,就被当做http2的基础,主要特性都在http2中得到继承。
2015年,http2发布,它不叫http2.0,因为标准委员会不打算再发布子版本了,下一个版本将是http3。
二进制协议
http1.1版的头信息肯定是文本(ASCII编码),数据体可以是文本,也可以是二进制。http2则是一个彻底的二进制协议,头信息和数据体都是二进制,并且统称为“帧”(frame):头信息帧和数据帧。
二进制协议的一个好处是,可以定义额外的帧。http2定义了近10种帧,为将来的高级应用打好了基础。如果使用文本实现这种功能,解析数据将会变得非常麻烦,二进制解析则方便的多。
多功
http2复用TCP连接,在一个连接里,客户端和浏览器都可以同时发送多个请求,而且不用按照顺序一一对应,这样就避免了“队头堵塞”。
举例来说,在同一个TCP连接里,服务器同时收到了A、B请求,于是先回应A请求,结果发现处理过程非常耗时,于是就发送A请求已经处理好的部分,接着回应B请求,完成后,再发送A请求剩下的部分。
这样双向的、实时的通信,就叫做多功。这个案例可以看出http2比http1加载资源的优势: http2.akamai.com/demo
数据流
因为http2的数据包是不按顺序发送的,同一个连接里连续的数据包,可能属于不同的回应。因此,必须要对数据包做标记,指出它属于哪个回应。
http2将每个请求的所有数据包,称为一个数据流(stream)。每个数据流都有一个独一无二的编号。数据包发送的时候,都必须标记数据流ID,用来区分它属于哪个数据流。另外还规定,客户端发出的数据流,ID一律为奇数,服务器发出的,ID为偶数。
数据流发送到一半的时候,客户端和服务端都可以发送信号,取消这个数据流。1.1版取消数据流的唯一方法,就是关闭TCP连接。这就是说,http2可以取消某一次请求,同时保证TCP连接还打开着,可以被其他请求使用。
客户端还可以指定数据流的优先级,优先级越高,服务器就会越早回应。
头信息压缩
http协议不带有状态,每次请求都必须附上所有信息。所以,请求的很多字段都是重复的,比如cookie和user agent,一模一样的内容,每次请求都必须附带,这会浪费很多带宽,也影响速度。
http2对这一点做了优化,引入了头信息压缩机制。一方面,头信息使用gzip或compress压缩后发送,另一方面,客户端和服务器同时维护一张头信息表,所有字段都会存入这个表,生成一个索引号,以后就不发送同样字段了,只发送索引号,这样就提高速度了。
服务器推送
http2允许服务器未经请求,主动向客户端发送资源,这叫做服务器推送。
常见场景:客户端请求一个网页,这个网页中包含很多静态资源。正常情况下,客户端必须收到网页后,解析html源码,发现有静态资源,再发出静态资源请求。其实,服务器可以预期到客户端请求网页后,很可能就会再请求静态资源,所以就主动把这些静态资源随着网页一起发送到客户端了。
http缓存
在任何一个前端项目中,访问服务器获取数据都是很常见的事,但是如果相同的数据被重复请求了不止一次,那么多余的请求次数必然会浪费网络带宽,以及延迟浏览器渲染所要处理的内容,从而影响用户的使用体验。如果用户使用的是按量计费的方式,那么多余的请求还会隐性地增加用户的网络流量资费。因此考虑用缓存对已获取的资源进行重用,是一种提升网站性能与用户体验的有效策略。
缓存的原理是在首次请求后保存一份请求资源的响应副本,当用户再次发起相同请求后,如果判断缓存命中则拦截请求,将之前存储的响应副本返回给用户,从而避免重新向服务器发起资源请求。
缓存的技术种类有很多,比如代理缓存、浏览器缓存、网关缓存、负载均衡器以及内容分发网络等,它们大致可以分为两类:共享缓存和私有缓存。共享缓存指的是缓存内容可被多个用户使用,如公司内部架设的web代理;私有缓存指的是只能单独被用户使用的缓存,如浏览器缓存。
http缓存应该算是前端开发中最常接触的缓存机制之一,它又可以细分为强制缓存和协商缓存,二者最大的区别在于判断缓存命中时,浏览器是否需要向服务器进行询问已协商缓存的相关信息,进而判断是否需要就响应内容进行重新请求。
强制缓存
如果浏览器判断所请求的目标资源有效命中,可以直接从强制缓存中返回请求,无须与服务器之间进行任何通信。
响应头的部分信息:
access-control-allow-origin: *
age: 734978
content-length: image/jpeg
cache-control: max-age=31536000
expires: Web, 14 Fed 2021 12:23:42 GMT
其中与强制缓存相关的两个字段是expires和cache-control,expires是在http1.1中声明的用来控制缓存失效日期时间戳的字段,它由服务器端指定后通过响应头告知浏览器,浏览器在接收到带有该字段的响应后进行缓存。
若之后浏览器再次发起相同的资源请求,便会对比expires与本地当前时间戳,如果请求的本地时间戳小于expires的值,则说明浏览器缓存的响应还未过期,可以直接使用而无需向服务器再次发起请求。当本地的时间戳大于expires值时,才会重新向服务器发起请求。
这种方式存在一个漏洞,即对本地时间戳过分依赖,如果客户端本地的事件与服务器的时间不同步,前者对客户端进行主动修改,那么对于缓存过期的判断可能就无法和预期相符。
为了解决expires判断的局限性,从http1.1开始新增了cache-control字段来对expires的功能进行扩展,在上述的代码中cache-control字段设置了max-age=31536000,它表示该资源被请求到后的31536000秒内有效,如此便可以避免服务器和客户端时间戳不同步造成的问题。除此之外,cache-control字段还可以配置一些其他值来更准确地控制缓存。
no-cache和no-store
设置no-cache并非像字面上的意思不使用缓存,其表示使用协商缓存,即对于每次发起的请求都不会再去判断强制缓存是否过期,而是直接与服务器协商来验证缓存的有效性,若缓存未过期,则会使用本地缓存。设置no-store则表示禁止使用任何缓存策略,客户端的每次请求都需要服务器给予全新的响应。no-cache和no-store是两个互斥的属性值,不能同时设置。
如发送如下响应头表示关闭缓存:
Cache-Control: no-store
指定no-cache或max-age=0表示客户端可以缓存资源,每次使用缓存资源前都必须重新验证其有效性。这意味着每次都会发起http请求,但当缓存内容仍有效时可以跳过http响应体的下载。
private和public
private和public也是cache-control的一组互斥属性值,它们用以明确响应资源是否可被代理服务器进行缓存。
- 若资源响应头中的cache-control字段设置了public,表示响应资源既可以被浏览器缓存,又可以被代理服务器缓存
- private则限制了响应资源只能被浏览器缓存,若未显示指定则默认值为private
对于应用程序中不会改变的文件,通常可以在发送响应头前添加积极缓存。这包括例如由应用程序提供的静态文件,例如图像、css和js
Cache-Control: public, max-age=31536000
max-age和s-maxage
max-age属性值会比s-maxage更常用,它表示服务器端告知客户端浏览器响应资源的过期时长。在一般项目的使用场景中基本够用,对于大型架构的项目通常会涉及使用各种代理服务器的情况,这就需要考虑缓存在代理服务器上的有效性问题,这便是s-maxage存在的意义,它表示缓存在代理服务器中的过期时长,且仅当设置了public属性值时才有效。
由此可见,cache-control能作为expires的完全替代方案,并且拥有其所不具备的一些缓存控制特性,在项目实践中使用它就足够了,目前expires还存在的唯一理由是考虑可用性方面的向下兼容。
协商缓存
顾名思义,协商缓存就是在使用本地缓存之前,需要向服务器端发起一次get请求,与之协商报错的本地缓存是否已过期。
通常是采用所请求资源最近一次的修改时间戳来判断的,为了便于理解,下面来看一个例子:假如客户端需要向服务器请求一个manifest.js的js文件资源,为了让该资源被再次请求时能通过协商缓存的机制使用本地缓存,那么首次返回该资源的响应头中应包含一个名为last-modified的字段,该字段的属性值为该js文件最近一次修改的时间戳,简略截取请求头与响应头的关键信息如下:
Request URL: http://localhost:3000/image.jpg
Request Methods: GET
last-modified: Thu, 29 Apr 2021 03:08:28 GMT
cache-control: no-cache // 使用协商缓存
当刷新网页时,由于该js文件使用的协商缓存,客户端浏览器无法确定本地缓存是否过期,所以需要向服务器发送一次get请求,进行缓存有效性的协商,此次get请求的请求头中需要包含一个ifmodified-since字段,其值是上次响应头中last-modified中的值。
当服务器收到该请求后便会对比请求资源当前的修改时间戳与if-modified-since字段的值,如果二者相同则说明缓存未过期,可继续使用本地缓存,否则服务器重新返回全新的文件资源,简略截取请求头与响应头的关键信息如下:
// 再次请求的请求头
Request URL: http://localhost:3000/image.jpg
Request Method: GET
If-Modified-Since: Thu, 29 Apr 2021 03:08:28 GMT
// 协商缓存有效的响应头
Status Code: 304 Not Modified
这里需要注意的是,协商缓存判断缓存是否有效的状态码是304,即缓存有效响应重定向到本地缓存上。这和强制缓存有所不同,强制缓存若有效,则再次请求的响应状态码是200。
last-modified的不足
通过last-modified实现的协商缓存能够满足大部分的使用场景,但存在两个明显的缺陷:
- 首先它只是根据资源最后的修改时间戳来进行判断,虽然请求的文件资源进行了编辑,但内容并没有发生任何变化,时间戳也会更新,从而导致协商缓存时关于有效性的判断验证失效,需要重新进行完整的资源请求。这会造成网络带宽资源的浪费,以及延长用户等待时间
- 其次标识文件资源修改的时间戳单位是秒,如果文件修改的速度非常快,假设在几百毫秒内完成,那么上述通过时间戳的方式来验证缓存的有效性,无法识别出该文件资源的更新
服务器无法依据资源修改的时间戳来识别出真正的更新,进而导致重新发起了请求,该重新请求却使用了缓存。
基于ETag的协商缓存
为了弥补通过时间戳判断的不足,从http1.1开始新增了一个ETag的头信息,即实体标签(Entity Tag)。其内容主要是服务器为不同资源进行哈希运算所生成的一个字符串,该字符串类似于文件指纹,只要文件内容编码存在差异,对应的ETag标签值就会不同,因此可以使用ETag对文件资源进行更精确的变化感知。示例:
// 响应头
Content-Type: image/jpeg
ETag: "c39046a17cd8354384c2de0c32ce7ca3"
Last-Modified: Fri, 12 Jul 2019 06:45:17 GMT
Content-Length: 9887
缓存决策
如何应用http缓存技术来提升网站的性能?假设在不考虑客户端缓存容量与服务器算力的理想情况下,我们当然希望客户端的缓存触发率尽可能高,留存时间尽可能长,同时还要ETag实现当资源更新时进行高效的重新验证。
但是实际情况是往往容量和算力都是有限的,因此需要制定合适的缓存策略,来利用有限的资源达到最优的性能效果。明确能力的边界,力求在边界内做到最好。
缓存决策树
在面对一个具体的缓存需求时,可以参考决策树:
首先根据资源内容的属性判断是否需要使用缓存,如果不希望对该资源开启缓存(比如涉及用户的敏感信息),则可直接设置cache-control的属性值为no-store来禁止任何缓存策略,这样请求和响应的信息就都不会被存储在对方及中间代理的磁盘系统上。
如果希望使用协商缓存,那么接下来就需要确定对缓存有效性的判断是否要与服务器进行协商,若要与服务器协商则可以为cache-control字段添加private或public来进行控制。如果之前未设置no-cache启用协商缓存,那么接下来可设置强制缓存的过期时间,即为cache-control字段设置max-age=...的属性值。最后如果启用了协商缓存,则可进一步设置请求资源的last-modified和ETag实体标签等参数。
缓存决策实例
在使用缓存优化性能的过程中,有一个问题是不可逾越的:即希望缓存能在客户端尽可能久的保持,又希望在资源修改时能进行进行更新。
这是两个互斥的诉求,使用强制缓存并定义足够长的过期时间能让缓存在客户端长期驻留,但由于强制缓存的优先级高于协商缓存,所以很难进行及时更新;若使用协商缓存,虽然能够保证及时更新,但频繁与服务器进行协商验证的响应速度肯定不及使用强制缓存快。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>http缓存策略</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<img src="photo.jpg" alt="photo" />
<script src="script.js"></script>
</body>
</html>
该html文件包含一个js文件script.js、一个样式表style.css和一个图片文件photo.jpg,若要展示出该html中的内容就需要加载出其包含的所有外链文件。据此可以进行一下设置:
- 首先html在这里属于包含其他文件的主文件,为保证当其内容发生修改时能及时更新,应当将其设置为协商缓存,即为cache-control字段添加no-cache属性值;其次是图片文件,因为网站对图片的修改基本都是更换修改,同时考虑到图片文件的数量及大小可能对客户端缓存空间造成不小的开销,所以可采用强制缓存且过期时间不宜过长,可以设置cache-control字段值为max-age=864000
- 接下来需要考虑的是样式表文件style.css,由于其属于文本文件,可能存在内容的不定期修改,又想使用强制缓存来提高重用效率,可以考虑在样式文件的命名中增加文件指纹或版本号(比如改名为style.51ad84f4.css),这样当发生文件修改后,不同的文件便会有不同的文件指纹,即需要请求的文件url不同了,因此必然会发生对资源的重新请求。同时考虑到网络中浏览器与CDN等中间代理的缓存,其过期时间可适当延长到一年,即cache-control: max-age=31536000
- 最后是js文件,可类似css文件的设置,采取文件指纹和较长的过期时间,如果js中包含了用户的私人信息而不想让中间代理缓存,可以为cache-control添加private属性值
缓存设置注意事项:
-
拆分源码,分包加载。对大型的前端应用迭代开发来说,其代码量通常很大,如果发生修改的部分集中在几个重要模块中,那么进行全量的代码更新显然会比较冗余,因此可以考虑在代码构建过程中,按照模块拆分将其打包成多个单独的文件,这样在每次修改后的更新提取时,仅需拉取发生修改的模块代码包,从而大大降低了需要下载的内容大小
-
预估资源的缓存失效。根据不同资源的不同需求特点,规划相应的缓存更新失效,为强制缓存指定合适的max-age取值,为协商缓存提供验证更新的ETag实体标签
-
控制中间代理的缓存。凡是会涉及用户隐私信息的尽量避免中间代理的缓存,如果对所有用户响应相同的资源,则可以考虑让中间代理也进行缓存
-
避免网址的冗余。缓存是根据请求资源的url进行的,不同的资源会有不同的url,所以尽量不要将相同的资源设置为不同的url
-
规划缓存的层次结构。参考缓存决策中介绍的示例,不仅是请求的资源类型,文件资源的层次结构也会对指定缓存策略有一定影响,我们应当综合考虑