0%

从输入URL到页面加载发生了什么?

从输入url到页面加载发生了什么?

流程

1.根据域名,进行DNS域名解析

URL一般包括几大部分:

  • protocol,协议头,譬如有http,ftp等
  • host,主机域名或IP地址
  • port,端口号
  • path,目录路径
  • query,即查询参数
  • fragment,即#后的hash值,一般用来定位到某个位置

2.拿到解析的IP地址,建立TCP连接

3.向IP地址,发送HTTP请求

SSL(Secure Sockets Layer 安全套接层):主要用于web的安全传输协议,在传输层对网络连接进行加密,保障在Internet上数据传输的安全。

https与http的区别就是:在请求前,会建立ssl链接,确保接下来的通信都是加密的,无法被轻易截取分析

http端口号80

https端口号443

http请求报文是由三部分组成:请求行请求报头请求正文

请求正文: 当使用POST, PUT等方法时,通常需要客户端向服务器传递数据。这些数据就储存在请求正文中。在请求报头中有一些与请求正文相关的信息,例如: 现在的Web应用通常采用Rest架构,请求的数据格式一般为json。这时就需要设置Content-Type: application/json。

get和post的区别:

  1. 都是基于http协议的,本质都是TCP连接,没有区别,可以在URL写上参数,然后POST请求,也可以在body上写参数,然后方法使用GET请求
  2. 一般说的GET请求浏览器地址栏输入的参数有限制,首先说明一点,HTTP协议没有BODY和URL长度的限制,对其限制的大多是浏览器和服务器的原因。服务器是因为处理长URL要消耗比较多的资源,为了性能和安全(防止恶意构造长URL来攻击)考虑,会给URL长度加限制。
  3. 虽然说POST比GET相对安全,但是从传输角度来说,都是不安全的,HTTP在网络上是明文传输的,只要在网络节点上捉包,就能完整地获取数据报文,想要安全只有HTTPS。

五层因特尔协议栈

1
2
3
4
5
6
7
8
9
1.应用层(dns,http) DNS解析成IP并发送http请求

2.传输层(tcp,udp) 建立tcp连接(三次握手)

3.网络层(IP,ARP) IP寻址

4.数据链路层(PPP) 封装成帧

5.物理层(利用物理介质传输比特流) 物理传输(然后传输的时候通过双绞线,电磁波等各种介质)

4.服务器处理请求并返回HTTP报文

http响应报文也是由三部分组成:状态码响应报头响应报文

状态码:

  • 1xx:指示信息–表示请求已接收,继续处理。

    100 Continue:该状态码说明服务器收到了请求的初始部分,并且请客户端继续发送。

    101(切换协议) 请求者已要求服务器切换协议,服务器已确认并准备切换。

  • 2xx:成功–表示请求已被成功接收、理解、接受。

    200(成功)服务器已成功处理了请求。通常,这表示服务器提供了请求的网页。

    201(已创建)请求成功并且服务器创建了新的资源。

    202(已接受)服务器已接受请求,但尚未处理。

    204(无内容)服务器成功处理了请求,但没有返回任何内容。等同于请求执行成功,但是没有数据,浏览器不用刷新页面也不用导向新的页面。

    206(部分内容)服务器成功处理了部分 GET 请求。

  • 3xx:重定向–要完成请求必须进行更进一步的操作。

    301(永久移动)请求的网页已永久移动到新位置。 服务器返回此响应(对 GET 或HEAD请求的响应)时,会自动将请求者转到新位置。

    302(临时移动)服务器目前从不同位置的网页响应请求,但请求者应继续使用原有位置来进行以后的请求。

    304(未修改)自从上次请求后,请求的网页未修改过,请客户端使用本地缓存。 服务器返回此响应时,不会返回网页内容。

    浏览器需要向服务器询问一次,如果服务器端认为没有内容更新,直接返回304状态码,无需返回body内容,浏览器就会直接取缓存内容输出,这样省掉了没必要的数据传输,也就提升了访问速度。

  • 4xx:客户端错误–请求有语法错误或请求无法实现。

    400(错误请求)服务器不理解请求的语法。

    401(未授权)请求要求身份验证。对于需要登录的网页,服务器可能返回此响应。

    402 该状态码是为了将来可能的需求而预留的。

    403(禁止)服务器拒绝请求。

    404(未找到)服务器找不到请求的网页。

  • 5xx:服务器端错误–服务器未能实现合法的请求。

    500(服务器内部错误)服务器遇到错误,无法完成请求。

    503(服务不可用)服务器目前无法使用(由于超载或停机维护)。通常,这只是暂时状态。

cookie以及优化:

一般来说,cookie是不允许存放敏感信息的(千万不要明文存储用户名、密码),因为非常不安全,如果一定要强行存储,首先,一定要在cookie中设置httponly(这样就无法通过js操作了),另外可以考虑rsa等非对称加密(因为实际上,浏览器本地也是容易被攻克的,并不安全)
另外,由于在同域名的资源请求时,浏览器会默认带上本地的cookie,针对这种情况,在某些场景下是需要优化的。

譬如以下场景:

1
2
3
4
5
客户端在域名A下有cookie(这个可以是登陆时由服务端写入的)
然后在域名A下有一个页面,页面中有很多依赖的静态资源(都是域名A的,譬如有20个静态资源)
此时就有一个问题,页面加载,请求这些静态资源时,浏览器会默认带上cookie
也就是说,这20个静态资源的http请求,每一个都得带上cookie,而实际上静态资源并不需要cookie验证
此时就造成了较为严重的浪费,而且也降低了访问速度(因为内容更多了)

当然了,针对这种场景,是有优化方案的(多域名拆分)。具体做法就是:

  1. 将静态资源分组,分别放到不同的域名下(如static.base.com
  2. page.base.com(页面所在域名)下请求时,是不会带上static.base.com域名的cookie的,所以就避免了浪费

dns-prefetch:DNS 预解析技术,当你浏览网页时,浏览器会在加载网页时对网页中的域名进行解析缓存,这样在你单击当前网页中的连接时就无需进行DNS 的解析,减少用户等待时间,提高用户体验。

以上是天猫中dns-prefetch的使用

长连接与短连接

tcp/ip层面:

  • 长连接:一个tcp/ip连接上可以连续发送多个数据包,在tcp连接保持期间,如果没有数据包发送,需要双方发检测包以维持此连接,一般需要自己做在线维持(类似于心跳包)
  • 短连接:通信双方有数据交互时,就建立一个tcp连接,数据发送完成后,则断开此tcp连接

http层面:

  • http1.0中,默认使用的是短连接,也就是说,浏览器每进行一次http操作,就建立一次连接,任务结束就中断连接,譬如每一个静态资源请求时都是一个单独的连接。
  • http1.1起,默认使用长连接,使用长连接会有这一行Connection: keep-alive,在长连接的情况下,当一个网页打开完成后,客户端和服务端之间用于传输http的tcp连接不会关闭,如果客户端再次访问这个服务器的页面,会继续使用这一条已经建立的连接

http 2.0

http1.1和http2.0区别:

  • http1.1中,每请求一个资源,都是需要开启一个tcp/ip连接的,所以对应的结果是,每一个资源对应一个tcp/ip请求,由于tcp/ip本身有并发数限制,所以当资源一多,速度就显著慢下来
  • http2.0中,一个tcp/ip请求可以请求多个资源,也就是说,只要一次tcp/ip请求,就可以请求若干个资源,分割成更小的帧请求,速度明显提升。

新特性:

  • 多路复用(即一个tcp/ip连接可以请求多个资源)
  • 首部压缩(http头部压缩,减少体积)
  • 二进制分帧(在应用层跟传送层之间增加了一个二进制分帧层,改进传输性能,实现低延迟和高吞吐量)
  • 服务器端推送(服务端可以对客户端的一个请求发出多个响应,可以主动通知客户端)
  • 请求优先级(如果流被赋予了优先级,它就会基于这个优先级来处理,由服务器决定需要多少资源来处理该请求。)

当客户端向服务器请求资源时,会先抵达浏览器缓存,如果浏览器有”要请求资源”的副本,就可以直接从浏览器缓存中提取,而不是从原始服务器中提取这个资源,常见的http缓存只能缓存get请求响应的资源,对于其他类型的响应则无能为力,所以后续说的请求缓存都是指GET请求。

Pragma和Cache-control共存时,Pragma的优先级是比Cache-Control高的。

在chrome浏览器中返回的200状态会有两种情况:
1、from memory cache
(从内存中获取/一般缓存更新频率较高的js、图片、字体等资源)

2、from disk cache
(从磁盘中获取/一般缓存更新频率较低的js、css等资源)

这两种情况是chrome自身的一种缓存策略,这也是为什么chrome浏览器响应的快的原因。其他浏览返回的是已缓存状态,没有标识是从哪获取的缓存。

1
当第一次请求时服务器返回的响应头中没有Cache-Control和Expires或者Cache-Control和Expires过期还或者它的属性设置为no-cache时(即不走强缓存),那么浏览器第二次请求时就会与服务器进行协商,与服务器端对比判断资源是否进行了修改更新。如果服务器端的资源没有修改,那么就会返回304状态码,告诉浏览器可以使用缓存中的数据,这样就减少了服务器的数据传输压力。如果数据有更新就会返回200状态码,服务器就会返回更新后的资源并且将缓存信息一起返回。跟协商缓存相关的header头属性有(ETag/If-Not-Match 、Last-Modified/If-Modified-Since)请求头和响应头需要成对出现

1
协商缓存的执行流程是这样的:当浏览器第一次向服务器发送请求时,会在响应头中返回协商缓存的头属性:ETag和Last-Modified,其中ETag返回的是一个hash值,Last-Modified返回的是GMT格式的最后修改时间。然后浏览器在第二次发送请求的时候,会在请求头中带上与ETag对应的If-Not-Match,其值就是响应头中返回的ETag的值,Last-Modified对应的If-Modified-Since。服务器在接收到这两个参数后会做比较,如果返回的是304状态码,则说明请求的资源没有修改,浏览器可以直接在缓存中取数据,否则,服务器会直接返回数据。

注意:
ETag/If-Not-Match是在HTTP/1.1出现的,主要是解决以下问题:

  1. Last-Modified标注的最后修改只能精确到秒级,如果某些文件在1秒钟以内,被修改多次的话,它将不能准确标注文件的修改时间
  2. 如果某些文件被修改了,但是内容并没有任何变化,而Last-Modified却改变了,导致文件没法使用缓存
  3. 有可能存在服务器没有准确获取文件修改时间,或者与代理服务器时间不一致等情形

为什么使用HTTP缓存:

  1. 减少了冗余的数据传输,节省了网费。
  2. 缓解了服务器的压力,大大提高了网站的性能
  3. 加快了客户端加载网页的速度

注意点:

  • 强缓存情况下,只要缓存还没过期,就会直接从缓存中取数据,就算服务器端有数据变化,也不会从服务器端获取了,这样就无法获取到修改后的数据。决解的办法有:在修改后的资源加上随机数,确保不会从缓存中取。

    例如:
    http://www.kimshare.club/kim/common.css?v=22324432
    http://www.kimshare.club/kim/common.2312331.css

  • 尽量减少304的请求,因为我们知道,协商缓存每次都会与后台服务器进行交互,所以性能上不是很好。从性能上来看尽量多使用强缓存。

  • 在Firefox浏览器下,使用Cache-Control: no-cache 是不生效的,其识别的是no-store。这样能达到其他浏览器使用Cache-Control: no-cache的效果。所以为了兼容Firefox浏览器,经常会写成Cache-Control: no-cache,no-store。

  • 与缓存相关的几个header属性有:Vary、Date/Age。

SUMMARY

  • 对于强制缓存,服务器通知浏览器一个缓存时间,在缓存时间内,下次请求,直接用缓存,不在时间内,执行协商缓存策略。
  • 对于协商缓存,将缓存信息中的Etag和Last-Modified通过请求发送给服务器,由服务器校验,返回304状态码时,浏览器直接使用缓存。

4845448-4b270d197649b733

5.浏览器解析渲染页面

浏览器渲染过程大体分为如下三部分:

1.浏览器会解析三个东西:

  • 一是HTML/SVG/XHTML,HTML字符串描述了一个页面的结构,浏览器会把HTML结构字符串解析转换DOM树形结构。

  • 二是CSS,解析CSS会产生CSS规则树,它和DOM结构比较像。

  • 三是Javascript脚本,等到Javascript 脚本文件加载后, 通过 DOM API 和 CSSOM API 来操作 DOM Tree 和 CSS Rule Tree。

2.解析完成后,浏览器引擎会通过DOM Tree和CSS Rule Tree来构造Rendering Tree。

  • Rendering Tree 渲染树并不等同于DOM树,渲染树只会包括需要显示的节点和这些节点的样式信息。
  • CSS 的 Rule Tree主要是为了完成匹配并把CSS Rule附加上Rendering Tree上的每个Element(也就是每个Frame)。
  • 然后,计算每个Frame 的位置,这又叫layout和reflow过程。

3.最后通过调用操作系统Native GUI的API绘制。

构建DOM

浏览器会遵守一套步骤将HTML 文件转换为 DOM 树。宏观上,可以分为几个步骤:

  • 浏览器从磁盘或网络读取HTML的原始字节,并根据文件的指定编码(例如 UTF-8)将它们转换成字符串。在网络中传输的内容其实都是 0 和 1 这些字节数据。当浏览器接收到这些字节数据以后,它会将这些字节数据转换为字符串,也就是我们写的代码。

  • 将字符串转换成Token,例如:<html><body>等。Token中会标识出当前Token是“开始标签”或是“结束标签”亦或是“文本”等信息。事实上,这就是Token要标识“起始标签”和“结束标签”等标识的作用。例如“title”Token的起始标签和结束标签之间的节点肯定是属于“head”的子节点。

    上图给出了节点之间的关系,例如:“Hello”Token位于“title”开始标签与“title”结束标签之间,表明“Hello”Token是“title”Token的子节点。同理“title”Token是“head”Token的子节点。

  • 生成节点对象并构建DOM

    事实上,构建DOM的过程中,不是等所有Token都转换完成后再去生成节点对象,而是一边生成Token一边消耗Token来生成节点对象。换句话说,每个Token被生成后,会立刻消耗这个Token创建出节点对象。注意:带有结束标签标识的Token不会创建节点对象。

接下来我们举个例子,假设有段HTML文本:

1
2
3
4
5
6
7
8
9
10
11
<html>
<head>
<title>Web page parsing</title>
</head>
<body>
<div>
<h1>Web page parsing</h1>
<p>This is an example Web page.</p>
</div>
</body>
</html>

上面这段HTML会解析成这样:

构建CSSOM

DOM会捕获页面的内容,但浏览器还需要知道页面如何展示,所以需要构建CSSOM。

构建CSSOM的过程与构建DOM的过程非常相似,当浏览器接收到一段CSS,浏览器首先要做的是识别出Token,然后构建节点并生成CSSOM。

在这一过程中,浏览器会确定下每一个节点的样式到底是什么,并且这一过程其实是很消耗资源的。因为样式你可以自行设置给某个节点,也可以通过继承获得。在这一过程中,浏览器得递归CSSOM树,然后确定具体的元素到底是什么样式。

注意:CSS匹配HTML元素是一个相当复杂和有性能问题的事情。所以,DOM树要小,CSS尽量用id和class,千万不要过渡层叠下去

构建渲染树

当我们生成 DOM 树和 CSSOM 树以后,就需要将这两棵树组合为渲染树。

在这一过程中,不是简单的将两者合并就行了。渲染树只会包括需要显示的节点和这些节点的样式信息,如果某个节点是 display: none 的,那么就不会在渲染树中显示。

我们或许有个疑惑:浏览器如果渲染过程中遇到JS文件怎么处理

渲染过程中,如果遇到<script>就停止渲染,执行JS代码。因为浏览器有GUI渲染线程与JS引擎线程,为了防止渲染出现不可预期的结果,这两个线程是互斥的关系。JavaScript的加载、解析与执行会阻塞DOM的构建,也就是说,在构建DOM时,HTML解析器若遇到了JavaScript,那么它会暂停构建DOM,将控制权移交给JavaScript引擎,等JavaScript引擎运行完毕,浏览器再从中断的地方恢复DOM构建。

也就是说,如果你想首屏渲染的越快,就越不应该在首屏就加载JS文件,这也是都建议将script标签放在body标签底部的原因。当然在当下,并不是说script标签必须放在底部,因为你可以给script标签添加defer或者 async属性(下文会介绍这两者的区别)。

JS文件不只是阻塞DOM的构建,它会导致CSSOM也阻塞DOM的构建

原本DOM和CSSOM的构建是互不影响,井水不犯河水,但是一旦引入了JavaScript,CSSOM也开始阻塞DOM的构建,只有CSSOM构建完毕后,DOM再恢复DOM构建。

这是什么情况?

这是因为JavaScript不只是可以改DOM,它还可以更改样式,也就是它可以更改CSSOM。因为不完整的CSSOM是无法使用的,如果JavaScript想访问CSSOM并更改它,那么在执行JavaScript时,必须要能拿到完整的CSSOM。所以就导致了一个现象,如果浏览器尚未完成CSSOM的下载和构建,而我们却想在此时运行脚本,那么浏览器将延迟脚本执行和DOM构建,直至其完成CSSOM的下载和构建。也就是说,在这种情况下,浏览器会先下载和构建CSSOM,然后再执行JavaScript,最后在继续构建DOM

布局与绘制

当浏览器生成渲染树以后,就会根据渲染树来进行布局(也可以叫做回流)。这一阶段浏览器要做的事情是要弄清楚各个节点在页面中的确切位置和大小。通常这一行为也被称为“自动重排”。

布局流程的输出是一个“盒模型”,它会精确地捕获每个元素在视口内的确切位置和尺寸,所有相对测量值都将转换为屏幕上的绝对像素。

布局完成后,浏览器会立即发出“Paint Setup”和“Paint”事件,将渲染树转换成屏幕上的像素。

JS中的三种加载模式

  • 正常模式:

    1
    <script src="index.js"></script>

    这种情况下JS会阻塞浏览器,浏览器必须等待 index.js 加载和执行完毕才能去做其它事情。

  • async 模式:

    1
    <script async src="index.js"></script>

    async模式下,JS不会阻塞浏览器做任何其它的事情。它的加载是异步的,当它加载结束,JS脚本会立即执行

  • defer 模式:

    1
    <script defer src="index.js"></script>

    defer模式下,JS的加载是异步的,执行是被推迟的。等整个文档解析完成、DOMContentLoaded事件即将被触发时,被标记了defer的JS文件才会开始依次执行。

从应用的角度来说,一般当我们的脚本与DOM元素和其它脚本之间的依赖关系不强时,我们会选用async;当脚本依赖于DOM元素和其它脚本的执行结果时,我们会选用defer。

基于渲染流程的 CSS 优化建议

CSS引擎查找样式表,对每条规则都按从右到左的顺序去匹配。 看如下规则:

1
#myList  li {}

这样的写法其实很常见。大家平时习惯了从左到右阅读的文字阅读方式,会本能地以为浏览器也是从左到右匹配 CSS 选择器的,因此会推测这个选择器并不会费多少力气:#myList 是一个id选择器,它对应的元素只有一个,查找起来应该很快。定位到了myList元素,等于是缩小了范围后再去查找它后代中的li元素,没毛病。

事实上,CSS选择符是从右到左进行匹配的。我们这个看似“没毛病”的选择器,实际开销相当高:浏览器必须遍历页面上每个li元素,并且每次都要去确认这个li元素的父元素id是不是myList。

经典的通配符:

1
* {}

入门CSS的时候,不少同学拿通配符清除默认样式(我曾经也是通配符用户的一员)。但这个家伙很恐怖,它会匹配所有元素,所以浏览器必须去遍历每一个元素!

这样一看,一个小小的CSS选择器,也有不少的门道!好的CSS选择器书写习惯,可以为我们带来非常可观的性能提升。根据上面的分析,我们至少可以总结出如下性能提升的方案:

  • 避免使用通配符,只对需要用到的元素进行选择。

  • 关注可以通过继承实现的属性,避免重复匹配重复定义。

  • 少用标签选择器。如果可以,用类选择器替代,举个🌰:

    错误示范:

    1
    #myList li{}

    课代表:

    1
    .myList_li {}
  • 不要画蛇添足,id和class选择器不应该被多余的标签选择器拖后腿。举个🌰:

    错误示范

    1
    .myList#title

    课代表

    1
    #title
  • 减少嵌套。后代选择器的开销是最高的,因此我们应该尽量将选择器的深度降到最低(最高不要超过三层),尽可能使用类来关联每一个标签元素。

DOM为什么这么慢?

因为收了“过路费”

把 DOM 和 JavaScript 各自想象成一个岛屿,它们之间用收费桥梁连接。——《高性能 JavaScript》

JS 是很快的,在JS中修改DOM对象也是很快的。在JS的世界里,一切是简单的、迅速的。但DOM操作并非JS 一个人的独舞,而是两个模块之间的协作。

上一节我们提到,JS 引擎和渲染引擎(浏览器内核)是独立实现的。当我们用JS去操作 DOM 时,本质上是JS 引擎和渲染引擎之间进行了“跨界交流”。这个“跨界交流”的实现并不简单,它依赖了桥接接口作为“桥梁”(如下图)。

过“桥”要收费——这个开销本身就是不可忽略的。我们每操作一次 DOM(不管是为了修改还是仅仅为了访问其值),都要过一次“桥”。过“桥”的次数一多,就会产生比较明显的性能问题。因此“减少 DOM 操作”的建议,并非空穴来风。

很多时候,我们对 DOM 的操作都不会局限于访问,而是为了修改它。当我们对 DOM 的修改会引发它外观(样式)上的改变时,就会触发回流重绘

这个过程本质上还是因为我们对 DOM 的修改触发了渲染树(Render Tree)的变化所导致的:

  • 回流:当我们对 DOM 的修改引发了 DOM 几何尺寸的变化(比如修改元素的宽、高或隐藏元素等)时,浏览器需要重新计算元素的几何属性(其他元素的几何属性和位置也会因此受到影响),然后再将计算的结果绘制出来。这个过程就是回流(也叫重排)。
  • 重绘:当我们对 DOM 的修改导致了样式的变化、却并未影响其几何属性(比如修改了颜色或背景色)时,浏览器不需重新计算元素的几何属性、直接为该元素绘制新的样式(跳过了上图所示的回流环节)。这个过程叫做重绘。

由此我们可以看出,重绘不一定导致回流,回流一定会导致重绘。硬要比较的话,回流比重绘做的事情更多,带来的开销也更大。但这两个说到底都是吃性能的,所以都不是什么善茬。我们在开发中,要从代码层面出发,尽可能把回流和重绘的次数最小化。

优化

减少 DOM 操作:少交“过路费”、避免过度渲染

1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>DOM操作测试</title>
</head>
<body>
<div id="container"></div>
</body>
</html>

此时我有一个假需求——我想往 container 元素里写10000句一样的话。如果我这么做:

1
2
3
for(var count=0;count<10000;count++){ 
document.getElementById('container').innerHTML+='<span>我是一个小测试</span>'
}

这段代码有两个明显的可优化点。

  1. 过路费交太多了。我们每一次循环都调用 DOM 接口重新获取了一次 container 元素,相当于每次循环都交了一次过路费。前后交了 10000 次过路费,但其中 9999 次过路费都可以用缓存变量的方式节省下来:

    1
    2
    3
    4
    5
    // 只获取一次container
    let container = document.getElementById('container')
    for(let count=0;count<10000;count++){
    container.innerHTML += '<span>我是一个小测试</span>'
    }
  2. 不必要的 DOM 更改太多了。我们的 10000 次循环里,修改了 10000 次 DOM 树。我们前面说过,对 DOM 的修改会引发渲染树的改变、进而去走一个(可能的)回流或重绘的过程,而这个过程的开销是很“贵”的。这么贵的操作,我们竟然重复执行了 N 多次!其实我们可以通过就事论事的方式节省下来不必要的渲染:

    1
    2
    3
    4
    5
    6
    7
    8
    let container = document.getElementById('container')
    let content = ''
    for(let count=0;count<10000;count++){
    // 先对内容进行操作
    content += '<span>我是一个小测试</span>'
    }
    // 内容处理好了,最后再触发DOM的更改
    container.innerHTML = content

DocumentFragment 接口表示一个没有父级文件的最小文档对象。它被当做一个轻量版的 Document 使用,用于存储已排好版的或尚未打理好格式的XML片段。因为 DocumentFragment 不是真实 DOM 树的一部分,它的变化不会引起 DOM 树的重新渲染的操作(reflow),且不会导致性能等问题。

在我们上面的例子里,字符串变量 content 就扮演着一个 DOM Fragment 的角色。其实无论字符串变量也好,DOM Fragment 也罢,它们本质上都作为脱离了真实 DOM 树的容器出现,用于缓存批量化的 DOM 操作。

前面我们直接用 innerHTML 去拼接目标内容,这样做固然有用,但却不够优雅。相比之下,DOM Fragment 可以帮助我们用更加结构化的方式去达成同样的目的,从而在维持性能的同时,保住我们代码的可拓展和可维护性。我们现在用 DOM Fragment 来改写上面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
let container = document.getElementById('container')
// 创建一个DOM Fragment对象作为容器
let content = document.createDocumentFragment()
for(let count=0;count<10000;count++){
// span此时可以通过DOM API去创建
let oSpan = document.createElement("span")
oSpan.innerHTML = '我是一个小测试'
// 像操作真实DOM一样操作DOM Fragment对象
content.appendChild(oSpan)
}
// 内容处理好了,最后再触发真实DOM的更改
container.appendChild(content)

我们运行这段代码,可以得到与前面两种写法相同的运行结果。
可以看出,DOM Fragment 对象允许我们像操作真实 DOM 一样去调用各种各样的 DOM API,我们的代码质量因此得到了保证。并且它的身份也非常纯粹:当我们试图将其 append 进真实 DOM 时,它会在乖乖交出自身缓存的所有后代节点后全身而退,完美地完成一个容器的使命,而不会出现在真实的 DOM 结构中。这种结构化、干净利落的特性,使得 DOM Fragment 作为经典的性能优化手段大受欢迎,这一点在 jQuery、Vue 等优秀前端框架的源码中均有体现。

渲染的时机

大家现在思考一个这样的问题:假如我想要在异步任务里进行DOM更新,我该把它包装成micro还是macro呢?

我们先假设它是一个macro任务,比如我在script脚本中用setTimeout来处理它:

1
2
// task是一个用于修改DOM的回调
setTimeout(task, 0)

现在task被推入的macro队列。但因为script脚本本身是一个macro任务,所以本次执行完script脚本之后,下一个步骤就要去处理micro队列了,再往下就去执行了一次render,对不对?

但本次render我的目标task其实并没有执行,想要修改的DOM也没有修改,因此这一次的render其实是一次无效的render。

macro不ok,我们转向micro试试看。我用Promise来把task包装成是一个micro任务:

1
Promise.resolve().then(task)

那么我们结束了对script脚本的执行,是不是紧接着就去处理micro-task队列了?micro-task处理完,DOM 修改好了,紧接着就可以走render流程了——不需要再消耗多余的一次渲染,不需要再等待一轮事件循环,直接为用户呈现最即时的更新结果。

因此,我们更新DOM的时间点,应该尽可能靠近渲染的时机。当我们需要在异步任务中实现DOM修改时,把它包装成micro任务是相对明智的选择

回流与重绘

回流(reflow)

Render Tree中部分或全部元素的尺寸、结构、或某些属性发生改变时,浏览器重新渲染部分或全部文档的过程称为回流。

会导致回流的操作:

  • 最“贵”的操作:改变 DOM 元素的几何属性

    这个改变几乎可以说是“牵一发动全身”——当一个DOM元素的几何属性发生变化时,所有和它相关的节点(比如父子节点、兄弟节点等)的几何属性都需要进行重新计算,它会带来巨大的计算量。

    常见的几何属性有 width、height、padding、margin、left、top、border 等等。

  • “价格适中”的操作:改变 DOM 树的结构

    这里主要指的是节点的增减、移动等操作。浏览器引擎布局的过程,顺序上可以类比于树的前序遍历——它是一个从上到下、从左到右的过程。通常在这个过程中,当前元素不会再影响其前面已经遍历过的元素。

  • 最容易被忽略的操作:获取一些特定属性的值

    当你要用到像这样的属性:offsetTop、offsetLeft、 offsetWidth、offsetHeight、scrollTop、scrollLeft、scrollWidth、scrollHeight、clientTop、clientLeft、clientWidth、clientHeight 时,你就要注意了!

    “像这样”的属性,到底是像什么样?——这些值有一个共性,就是需要通过即时计算得到。因此浏览器为了获取这些值,也会进行回流。

重绘(Repaint)

当页面中元素样式的改变并不影响它在文档流中的位置时(例如:colorbackground-colorvisibility等),浏览器会将新样式赋予给元素并重新绘制它,这个过程称为重绘。

如何规避回流与重绘

CSS

  • 避免使用table布局。
  • 尽可能在DOM树的最末端改变class
  • 避免设置多层内联样式。
  • 将动画效果应用到position属性为absolutefixed的元素上。
  • 避免使用CSS表达式(例如:calc())。

JavaScript

  • 避免频繁操作样式,最好一次性重写style属性,或者将样式列表定义为class并一次性更改class属性。
  • 避免频繁操作DOM,创建一个documentFragment,在它上面应用所有DOM操作,最后再把它添加到文档中。
  • 也可以先为元素设置display: none,操作结束后再把它显示出来。因为在display属性为none的元素上进行的DOM操作不会引发回流和重绘。
  • 避免频繁读取会引发回流/重绘的属性,如果确实需要多次使用,就用一个变量缓存起来。
  • 对具有复杂动画的元素使用绝对定位,使它脱离文档流,否则会引起父元素及后续元素频繁回流。

1.将导致回流与重绘的元素缓存起来,避免频繁改动

有时我们想要通过多次计算得到一个元素的布局位置,我们可能会这样做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
<style>
#el {
width: 100px;
height: 100px;
background-color: yellow;
position: absolute;
}
</style>
</head>
<body>
<div id="el"></div>
<script>
// 获取el元素
const el = document.getElementById('el')
// 这里循环判定比较简单,实际中或许会拓展出比较复杂的判定需求
for(let i=0;i<10;i++) {
el.style.top = el.offsetTop + 10 + "px";
el.style.left = el.offsetLeft + 10 + "px";
}
</script>
</body>
</html>

这样做,每次循环都需要获取多次“敏感属性”,是比较糟糕的。我们可以将其以 JS 变量的形式缓存起来,待计算完毕再提交给浏览器发出重计算请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 缓存offsetLeft与offsetTop的值
const el = document.getElementById('el')
let offLeft = el.offsetLeft, offTop = el.offsetTop

// 在JS层面进行计算
for(let i=0;i<10;i++) {
offLeft += 10
offTop += 10
}

// 一次性将计算结果应用到DOM上
el.style.left = offLeft + "px"
el.style.top = offTop + "px"

2.避免逐条改变样式,使用类名去合并样式

比如我们可以把这段单纯的代码:

1
2
3
4
5
const container = document.getElementById('container')
container.style.width = '100px'
container.style.height = '200px'
container.style.border = '10px solid red'
container.style.color = 'red'

优化成一个有 class 加持的样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
<style>
.basic_style {
width: 100px;
height: 200px;
border: 10px solid red;
color: red;
}
</style>
</head>
<body>
<div id="container"></div>
<script>
const container = document.getElementById('container')
container.classList.add('basic_style')
</script>
</body>
</html>

前者每次单独操作,都去触发一次渲染树更改,从而导致相应的回流与重绘过程。

合并之后,等于我们将所有的更改一次性发出,用一个style请求解决掉了。

3.将 DOM “离线”

我们上文所说的回流和重绘,都是在“该元素位于页面上”的前提下会发生的。一旦我们给元素设置 display: none,将其从页面上“拿掉”,那么我们的后续操作,将无法触发回流与重绘——这个将元素“拿掉”的操作,就叫做 DOM 离线化。

仍以我们上文的代码片段为例:

1
2
3
4
5
6
const container = document.getElementById('container')
container.style.width = '100px'
container.style.height = '200px'
container.style.border = '10px solid red'
container.style.color = 'red'
...(省略了许多类似的后续操作)

离线化后就是这样:

1
2
3
4
5
6
7
8
let container = document.getElementById('container')
container.style.display = 'none'
container.style.width = '100px'
container.style.height = '200px'
container.style.border = '10px solid red'
container.style.color = 'red'
...(省略了许多类似的后续操作)
container.style.display = 'block'

有的同学会问,拿掉一个元素再把它放回去,这不也会触发一次昂贵的回流吗?这话不假,但我们把它拿下来了,后续不管我操作这个元素多少次,每一步的操作成本都会非常低。当我们只需要进行很少的 DOM 操作时,DOM 离线化的优越性确实不太明显。一旦操作频繁起来,这“拿掉”和“放回”的开销都将会是非常值得的。

4.Flush 队列:浏览器并没有那么简单

以我们现在的知识基础,理解上面的优化操作并不难。那么现在我问大家一个问题:

1
2
3
4
5
let container = document.getElementById('container')
container.style.width = '100px'
container.style.height = '200px'
container.style.border = '10px solid red'
container.style.color = 'red'

这段代码里,浏览器进行了多少次的回流或重绘呢?

“width、height、border是几何属性,各触发一次回流;color只造成外观的变化,会触发一次重绘。”——如果你立刻这么想了,说明你是个能力不错的同学,认真阅读了前面的内容。那么我们现在立刻跑一跑这段代码,看看浏览器怎么说:

这里为大家截取有“Layout”和“Paint”出镜的片段(这个图是通过 Chrome 的 Performance 面板得到的,后面会教大家用这个东西)。我们看到浏览器只进行了一次回流和一次重绘——和我们想的不一样啊,为啥呢?

因为现代浏览器是很聪明的。浏览器自己也清楚,如果每次 DOM 操作都即时地反馈一次回流或重绘,那么性能上来说是扛不住的。于是它自己缓存了一个 flush 队列,把我们触发的回流与重绘任务都塞进去,待到队列里的任务多起来、或者达到了一定的时间间隔,或者“不得已”的时候,再将这些任务一口气出队。因此我们看到,上面就算我们进行了 4 次 DOM 更改,也只触发了一次 Layout 和一次 Paint。

大家这里尤其小心这个“不得已”的时候。前面我们在介绍回流的“导火索”的时候,提到过有一类属性很特别,它们有很强的“即时性”。当我们访问这些属性时,浏览器会为了获得此时此刻的、最准确的属性值,而提前将 flush 队列的任务出队——这就是所谓的“不得已”时刻。具体是哪些属性值,我们已经在“最容易被忽略的操作”这个小模块介绍过了,此处不再赘述。

6.关闭TCP连接

缓存

1.DNS缓存

什么是DNS?

全称 Domain Name System ,即域名系统。

万维网上作为域名和IP地址相互映射的一个分布式数据库,能够使用户更方便的访问互联网,而不用去记住能够被机器直接读取的IP数串。DNS协议运行在UDP协议之上,使用端口号53。

域名解析

简单的说,通过域名,最终得到该域名对应的IP地址的过程叫做域名解析(或主机名解析)。

1
www.dnscache.com (域名)  - DNS解析 -> 11.222.33.444 (IP地址)

有dns的地方,就有缓存。浏览器、操作系统、Local DNS、根域名服务器,它们都会对DNS结果做一定程度的缓存。

DNS查询过程如下:

  1. 首先搜索浏览器自身的DNS缓存,如果存在,则域名解析到此完成。
  2. 如果浏览器自身的缓存里面没有找到对应的条目,那么会尝试读取操作系统的hosts文件看是否存在对应的映射关系,如果存在,则域名解析到此完成。
  3. 如果本地hosts文件不存在映射关系,则查找本地DNS服务器(ISP服务器,或者自己手动设置的DNS服务器),如果存在,域名到此解析完成。
  4. 如果本地DNS服务器还没找到的话,它就会向根服务器发出请求,进行递归查询。

2.CDN缓存

什么是CDN?

全称 Content Delivery Network,即内容分发网络。

10年前,还没有火车票代售点一说,12306.cn更是无从说起。那时候火车票还只能在火车站的售票大厅购买,而我所在的小县城并不通火车,火车票都要去市里的火车站购买,而从我家到县城再到市里,来回就是4个小时车程,简直就是浪费生命。后来就好了,小县城里出现了火车票代售点,甚至乡镇上也有了代售点,可以直接在代售点购买火车票,方便了不少,全市人民再也不用在一个点苦逼的排队买票了。

简单的理解CDN就是这些代售点(缓存服务器)的承包商,他为买票者提供了便利,帮助他们在最近的地方(最近的CDN节点)用最短的时间(最短的请求时间)买到票(拿到资源),这样去火车站售票大厅排队的人也就少了。也就减轻了售票大厅的压力(起到分流作用,减轻服务器负载压力)。

用户在浏览网站的时候,CDN会选择一个离用户最近的CDN边缘节点来响应用户的请求,这样海南移动用户的请求就不会千里迢迢跑到北京电信机房的服务器(假设源站部署在北京电信机房)上了。

CDN缓存

关于CDN缓存,在浏览器本地缓存失效后,浏览器会向CDN边缘节点发起请求。类似浏览器缓存,CDN边缘节点也存在着一套缓存机制。CDN边缘节点缓存策略因服务商不同而不同,但一般都会遵循http标准协议,通过http响应头中的

1
Cache-control: max-age   //后面会提到

的字段来设置CDN边缘节点数据缓存时间。

当浏览器向CDN节点请求数据时,CDN节点会判断缓存数据是否过期,若缓存数据并没有过期,则直接将缓存数据返回给客户端;否则,CDN节点就会向服务器发出回源请求,从服务器拉取最新数据,更新本地缓存,并将最新数据返回给客户端。 CDN服务商一般会提供基于文件后缀、目录多个维度来指定CDN缓存时间,为用户提供更精细化的缓存管理。

CDN 优势

  1. CDN节点解决了跨运营商和跨地域访问的问题,访问延时大大降低。
  2. 大部分请求在CDN边缘节点完成,CDN起到了分流作用,减轻了源服务器的负载。

3.浏览器缓存(http缓存)

对于一个数据请求来说,可以分为发起网络请求、后端处理、浏览器响应三个步骤。浏览器缓存可以帮助我们在第一和第三步骤中优化性能。比如说直接使用缓存而不发起请求,或者发起了请求但后端存储的数据和前端一致,那么就没有必要再将数据回传回来,这样就减少了响应数据。

缓存位置

从缓存位置上来说分为四种,并且各自有优先级,当依次查找缓存且都没有命中的时候,才会去请求网络。

  1. Service Worker

    Service Worker 是运行在浏览器背后的独立线程,一般可以用来实现缓存功能。使用 Service Worker的话,传输协议必须为 HTTPS。因为 Service Worker 中涉及到请求拦截,所以必须使用 HTTPS 协议来保障安全。

    Service Worker 实现缓存功能一般分为三个步骤:首先需要先注册 Service Worker,然后监听到 install 事件以后就可以缓存需要的文件,那么在下次用户访问的时候就可以通过拦截请求的方式查询是否存在缓存,存在缓存的话就可以直接读取缓存文件,否则就去请求数据。

  2. Memory Cache

    Memory Cache 也就是内存中的缓存,主要包含的是当前中页面中已经抓取到的资源,例如页面上已经下载的样式、脚本、图片等。读取内存中的数据肯定比磁盘快,内存缓存虽然读取高效,可是缓存持续性很短,会随着进程的释放而释放。 一旦我们关闭 Tab 页面,内存中的缓存也就被释放了

    那么既然内存缓存这么高效,我们是不是能让数据都存放在内存中呢?
    这是不可能的。计算机中的内存一定比硬盘容量小得多,操作系统需要精打细算内存的使用,所以能让我们使用的内存必然不多。

    当我们访问过页面以后,再次刷新页面,可以发现很多数据都来自于内存缓存。

    内存缓存中有一块重要的缓存资源是preloader相关指令(例如<link rel="prefetch">)下载的资源。总所周知preloader的相关指令已经是页面优化的常见手段之一,它可以一边解析js/css文件,一边网络请求下一个资源。

    需要注意的事情是,内存缓存在缓存资源时并不关心返回资源的HTTP缓存头Cache-Control是什么值,同时资源的匹配也并非仅仅是对URL做匹配,还可能会对Content-Type,CORS等其他特征做校验

  3. Disk Cache

    Disk Cache 也就是存储在硬盘中的缓存,读取速度慢点,但是什么都能存储到磁盘中,比之 Memory Cache 胜在容量和存储时效性上

    在所有浏览器缓存中,Disk Cache 覆盖面基本是最大的。它会根据 HTTP Herder 中的字段判断哪些资源需要缓存,哪些资源可以不请求直接使用,哪些资源已经过期需要重新请求。并且即使在跨站点的情况下,相同地址的资源一旦被硬盘缓存下来,就不会再次去请求数据。

    浏览器会把哪些文件丢进内存中?哪些丢进硬盘中?

    • 对于大文件来说,大概率是不存储在内存中的,反之优先
    • 当前系统内存使用率高的话,文件优先存储进硬盘
  4. Push Cache

    Push Cache(推送缓存)是 HTTP/2 中的内容,当以上三种缓存都没有命中时,它才会被使用。它只在会话(Session)中存在,一旦会话结束就被释放,并且缓存时间也很短暂,在Chrome浏览器中只有5分钟左右,同时它也并非严格执行HTTP头中的缓存指令。

    • 所有的资源都能被推送,并且能够被缓存,但是 Edge 和 Safari 浏览器支持相对比较差
    • 可以推送 no-cache 和 no-store 的资源
    • 一旦连接被关闭,Push Cache 就被释放
    • 多个页面可以使用同一个HTTP/2的连接,也就可以使用同一个Push Cache。这主要还是依赖浏览器的实现而定,出于对性能的考虑,有的浏览器会对相同域名但不同的tab标签使用同一个HTTP连接。
    • Push Cache 中的缓存只能被使用一次
    • 浏览器可以拒绝接受已经存在的资源推送
    • 你可以给其他域名推送资源

如果以上四种缓存都没有命中的话,那么只能发起请求来获取资源了。

那么为了性能上的考虑,大部分的接口都应该选择好缓存策略,通常浏览器缓存策略分为两种:强缓存和协商缓存,并且缓存策略都是通过设置HTTP Header来实现的

缓存过程分析

浏览器与服务器通信的方式为应答模式,即是:浏览器发起HTTP请求 – 服务器响应该请求。那么浏览器第一次向服务器发起该请求后拿到请求结果,会根据响应报文中HTTP头的缓存标识,决定是否缓存结果,是则将请求结果和缓存标识存入浏览器缓存中,简单的过程如下图:

  • 浏览器每次发起请求,都会先在浏览器缓存中查找该请求的结果以及缓存标识
  • 浏览器每次拿到返回的请求结果都会将该结果和缓存标识存入浏览器缓存中

根据是否需要向服务器重新发起HTTP请求将缓存过程分为两个部分,分别是强缓存和协商缓存。

强缓存

强缓存:不会向服务器发送请求,直接从缓存中读取资源,在chrome控制台的Network选项中可以看到该请求返回200的状态码,并且Size显示from disk cache或from memory cache。强缓存可以通过设置两种 HTTP Header 实现:Expires 和 Cache-Control。

  1. Expires

    缓存过期时间,用来指定资源到期的时间,是服务器端的具体的时间点。也就是说,Expires=max-age + 请求时间,需要和Last-modified结合使用。Expires是Web服务器响应消息头字段,在响应http请求时告诉浏览器在过期时间前浏览器可以直接从浏览器缓存取数据,而无需再次请求。

    Expires 是 HTTP/1 的产物,受限于本地时间,如果修改了本地时间,可能会造成缓存失效Expires: Wed, 22 Oct 2018 08:41:00 GMT表示资源会在 Wed, 22 Oct 2018 08:41:00 GMT 后过期,需要再次请求。

  2. Cache-Control

    在HTTP/1.1中,Cache-Control是最重要的规则,主要用于控制网页缓存。比如当Cache-Control:max-age=300时,则代表在这个请求正确返回时间(浏览器也会记录下来)的5分钟内再次加载资源,就会命中强缓存。

    Cache-Control 可以在请求头或者响应头中设置,并且可以组合使用多种指令:

    1. public所有内容都将被缓存(客户端和代理服务器都可缓存)。具体来说响应可被任何中间节点缓存,如 Browser <– proxy1 <– proxy2 <– Server,中间的proxy可以缓存资源,比如下次再请求同一资源proxy1直接把自己缓存的东西给 Browser 而不再向proxy2要。

    2. private所有内容只有客户端可以缓存,Cache-Control的默认取值。具体来说,表示中间节点不允许缓存,对于Browser <– proxy1 <– proxy2 <– Server,proxy 会老老实实把Server 返回的数据发送给proxy1,自己不缓存任何数据。当下次Browser再次请求时proxy会做好请求转发而不是自作主张给自己缓存的数据。

    3. no-cache:客户端缓存内容,是否使用缓存则需要经过协商缓存来验证决定。表示不使用 Cache-Control的缓存控制方式做前置验证,而是使用 Etag 或者Last-Modified字段来控制缓存。需要注意的是,no-cache这个名字有一点误导。设置了no-cache之后,并不是说浏览器就不再缓存数据,只是浏览器在使用缓存数据时,需要先确认一下数据是否还跟服务器保持一致。

    4. no-store:所有内容都不会被缓存,即不使用强制缓存,也不使用协商缓存

    5. max-age:max-age=xxx (xxx is numeric)表示缓存内容将在xxx秒后失效

    6. s-maxage(单位为s):同max-age作用一样,只在代理服务器中生效(比如CDN缓存)。比如当s-maxage=60时,在这60秒中,即使更新了CDN的内容,浏览器也不会进行请求。max-age用于普通缓存,而s-maxage用于代理缓存。s-maxage的优先级高于max-age。如果存在s-maxage,则会覆盖掉max-age和Expires header。

    7. max-stale:能容忍的最大过期时间。max-stale指令标示了客户端愿意接收一个已经过期了的响应。如果指定了max-stale的值,则最大容忍时间为对应的秒数。如果没有指定,那么说明浏览器愿意接收任何age的响应(age表示响应由源站生成或确认的时间与当前时间的差值)。

    8. min-fresh:能够容忍的最小新鲜度。min-fresh标示了客户端不愿意接受新鲜度不多于当前的age加上min-fresh设定的时间之和的响应。

      从图中我们可以看到,我们可以将多个指令配合起来一起使用,达到多个目的。比如说我们希望资源能被缓存下来,并且是客户端和代理服务器都能缓存,还能设置缓存失效时间等等。

  3. Expires和Cache-Control两者对比

    其实这两者差别不大,区别就在于Expires是http1.0的产物,Cache-Control是http1.1的产物,两者同时存在的话,Cache-Control优先级高于Expires;在某些不支持HTTP1.1的环境下,Expires就会发挥用处。所以Expires其实是过时的产物,现阶段它的存在只是一种兼容性的写法。

强缓存判断是否缓存的依据来自于是否超出某个时间或者某个时间段,而不关心服务器端文件是否已经更新,这可能会导致加载文件不是服务器端最新的内容,那我们如何获知服务器端内容是否已经发生了更新呢?此时我们需要用到协商缓存策略。

协商缓存

协商缓存就是强制缓存失效后,浏览器携带缓存标识向服务器发起请求,由服务器根据缓存标识决定是否使用缓存的过程,主要有以下两种情况

  • 协商缓存生效,返回304和Not Modified
  • 商缓存失效,返回200和请求结果

协商缓存可以通过设置两种 HTTP Header 实现:Last-Modified 和 ETag 。

  1. Last-Modified和If-Modified-Since

    浏览器在第一次访问资源时,服务器返回资源的同时,在response header中添加 Last-Modified的header,值是这个资源在服务器上的最后修改时间,浏览器接收后缓存文件和header;

    浏览器下一次请求这个资源,浏览器检测到有 Last-Modified这个header,于是添加If-Modified-Since这个header,值就是Last-Modified中的值;服务器再次收到这个资源请求,会根据 If-Modified-Since 中的值与服务器中这个资源的最后修改时间对比,如果没有变化,返回304和空的响应体,直接从缓存读取,如果If-Modified-Since的时间小于服务器中这个资源的最后修改时间,说明文件有更新,于是返回新的资源文件和200。

    但是Last-Modified存在一些弊端

    • 如果本地打开缓存文件,即使没有对文件进行修改,但还是会造成Last-Modified被修改,服务端不能命中缓存导致发送相同的资源

    • 因为Last-Modified只能以秒计时,如果在不可感知的时间内修改完成文件,那么服务端会认为资源还是命中了,不会返回正确的资源

      既然根据文件修改时间来决定是否缓存尚有不足,能否可以直接根据文件内容是否修改来决定缓存策略?所以在HTTP/1.1出现了 ETagIf-None-Match

  2. ETag和If-None-Match

    Etag是服务器响应请求时,返回当前资源文件的一个唯一标识(由服务器生成),只要资源有变化,Etag就会重新生成。浏览器在下一次加载资源向服务器发送请求时,会将上一次返回的Etag值放到request header里的If-None-Match里,服务器只需要比较客户端传来的If-None-Match跟自己服务器上该资源的ETag是否一致,就能很好地判断资源相对客户端而言是否被修改过了。如果服务器发现ETag匹配不上,那么直接以常规GET 200回包形式将新的资源(当然也包括了新的ETag)发给客户端;如果ETag是一致的,则直接返回304知会客户端直接使用本地缓存即可。

  3. 两者之间对比

    • 首先在精确度上,Etag要优于Last-Modified。

      Last-Modified的时间单位是秒,如果某个文件在1秒内改变了多次,那么他们的Last-Modified其实并没有体现出来修改,但是Etag每次都会改变确保了精度;如果是负载均衡的服务器,各个服务器生成的Last-Modified也有可能不一致。

    • 第二在性能上,Etag要逊于Last-Modified,毕竟Last-Modified只需要记录时间,而Etag需要服务器通过算法来计算出一个hash值。

    • 第三在优先级上,服务器校验优先考虑Etag。

缓存机制

强制缓存优先于协商缓存进行,若强制缓存(Expires和Cache-Control)生效则直接使用缓存,若不生效则进行协商缓存(Last-Modified / If-Modified-Since和Etag / If-None-Match),协商缓存由服务器决定是否使用缓存,若协商缓存失效,那么代表该请求的缓存失效,返回200,重新返回资源和缓存标识,再存入浏览器缓存中;生效则返回304,继续使用缓存。具体流程图如下:

如果什么缓存策略都没设置,那么浏览器会怎么处理?

对于这种情况,浏览器会采用一个启发式的算法,通常会取响应头中的 Date 减去 Last-Modified 值的 10% 作为缓存时间。

实际场景应用缓存策略

1.频繁变动的资源

Cache-Control: no-cache

对于频繁变动的资源,首先需要使用Cache-Control: no-cache 使浏览器每次都请求服务器,然后配合 ETag 或者 Last-Modified 来验证资源是否有效。这样的做法虽然不能节省请求数量,但是能显著减少响应数据大小。

2.不常变化的资源

Cache-Control: max-age=31536000

通常在处理这类资源时,给它们的 Cache-Control 配置一个很大的 max-age=31536000 (一年),这样浏览器之后请求相同的 URL 会命中强制缓存。而为了解决更新的问题,就需要在文件名(或者路径)中添加 hash, 版本号等动态字符,之后更改动态字符,从而达到更改引用 URL 的目的,让之前的强制缓存失效 (其实并未立即失效,只是不再使用了而已)。
在线提供的类库 (如 jquery-3.3.1.min.js, lodash.min.js 等) 均采用这个模式。

用户行为对浏览器缓存的影响

所谓用户行为对浏览器缓存的影响,指的就是用户在浏览器如何操作时,会触发怎样的缓存策略。主要有 3 种:

  • 打开网页,地址栏输入地址: 查找 disk cache 中是否有匹配。如有则使用;如没有则发送网络请求。
  • 普通刷新 (F5):因为 TAB 并没有关闭,因此 memory cache 是可用的,会被优先使用(如果匹配的话)。其次才是 disk cache。
  • 强制刷新 (Ctrl + F5):浏览器不使用缓存,因此发送的请求头部均带有 Cache-control: no-cache(为了兼容,还带了 Pragma: no-cache),服务器直接返回 200 和最新内容。

存储

我们先来通过表格学习下这几种存储方式的区别:

从下表可以看到,cookie 已经不建议用于存储。如果没有大量数据存储需求的话,可以使用 localStorage 和 sessionStorage 。对于不怎么改变的数据尽量使用 localStorage 存储,否则可以用 sessionStorage 存储。

特性 cookie localStorage sessionStorage indexDB
数据生命周期 一般由服务器生成,可以设置过期时间 除非被清理,否则一直存在 页面关闭就清理 除非被清理,否则一直存在
数据存储大小 4K 5M 5M 无限
与服务端通信 每次都会携带在 header 中,对于请求性能影响 不参与 不参与 不参与

Cookie 的本职工作并非本地存储,而是“维持状态”。 因为HTTP协议是无状态的,HTTP协议自身不对请求和响应之间的通信状态进行保存。通俗来说,服务器不知道用户上一次做了什么,这严重阻碍了交互式Web应用程序的实现。在典型的网上购物场景中,用户浏览了几个页面,买了一盒饼干和两瓶饮料。最后结帐时,由于HTTP的无状态性,不通过额外的手段,服务器并不知道用户到底买了什么,于是就诞生了Cookie。

在刚才的购物场景中,当用户选购了第一项商品,服务器在向用户发送网页的同时,还发送了一段Cookie,记录着那项商品的信息。当用户访问另一个页面,浏览器会把Cookie发送给服务器,于是服务器知道他之前选购了什么。用户继续选购饮料,服务器就在原来那段Cookie里追加新的商品信息。结帐时,服务器读取发送来的Cookie就行了。

cookie生成方式有两种:

  1. http response header中的set-cookie

    domain被设置为设置Cookie页面的主机名,我们也可以手动设置domain的值。

    1
    Set-Cookie: id=a3fWa; Expires=Wed, 21 Oct 2018 07:28:00 GMT;//可以指定一个特定的过期时间(Expires)或有效期(Max-Age)
  2. js中可以通过document.cookie可以读写cookie,以键值对的形式展示

    1
    document.cookie='age=20;domain=.baidu.com'

    Domain标识指定了哪些域名可以接受Cookie。如果没有设置domain,就会自动绑定到执行语句的当前域。 如果设置为”.baidu.com”,则所有以”baidu.com”结尾的域名都可以访问该Cookie。

缺陷:

  1. Cookie 不够大

    这里需注意:各浏览器的cookie每一个name=value的value值大概在4k,所以4k并不是一个域名下所有的cookie共享的,而是一个name的大小。

  2. 过多的 Cookie 会带来巨大的性能浪费

    cookie是用来维护用户信息的,而域名(domain)下所有请求都会携带cookie,但对于静态文件的请求,携带cookie信息根本没有用,此时可以通过cdn(存储静态文件的)的域名和主站的域名分开来解决。

  3. 由于在HTTP请求中的Cookie是明文传递的,所以安全性成问题,除非用HTTPS。

对于 cookie 来说,我们还需要注意安全性。

属性 作用
value 如果用于保存用户登录态,应该将该值加密,不能使用明文的用户标识
http-only 不能通过 JS 访问 Cookie,减少 XSS 攻击
secure 只能在协议为 HTTPS 的请求中携带
same-site 规定浏览器不能在跨域请求中携带 Cookie,减少 CSRF 攻击

为了弥补 Cookie 的局限性,让“专业的人做专业的事情”,Web Storage 出现了。

HTML5中新增了本地存储的解决方案—-Web Storage,它分成两类:sessionStorage和localStorage。这样有了WebStorage后,cookie能只做它应该做的事情了——作为客户端与服务器交互的通道,保持客户端状态。

localStorage

  • 保存的数据长期存在,下一次访问该网站的时候,网页可以直接读取以前保存的数据。
  • 大小为5M左右
  • 仅在客户端使用,不和服务端进行通信
  • 接口封装较好

基于上面的特点,LocalStorage可以作为浏览器本地缓存方案,用来提升网页首屏渲染速度(根据第一请求返回时,将一些不变信息直接存储在本地)。

1
2
3
4
5
6
<script>
if(window.localStorage){
localStorage.setItem('name','world'
localStorage.setItem('gender','female'
}
</script>
1
2
3
4
5
6
7
8
9
10
<body>
<div id="name"></div>
<div id="gender"></div>
<script>
var name=localStorage.getItem('name')
var gender=localStorage.getItem('gender')
document.getElementById('name').innerHTML=name
document.getElementById('gender').innerHTML=gender
</script>
</body>

使用场景:图片内容丰富的电商网站会用它来存储 Base64 格式的图片字符串。

sessionStorage

sessionStorage保存的数据用于浏览器的一次会话,当会话结束(通常是该窗口关闭),数据被清空;sessionStorage 特别的一点在于,即便是相同域名下的两个页面,只要它们不在同一个浏览器窗口中打开,那么它们的 sessionStorage 内容便无法共享;localStorage 在所有同源窗口中都是共享的;cookie也是在所有同源窗口中都是共享的。除了保存期限的长短不同,SessionStorage的属性和方法与LocalStorage完全一样。

  • 会话级别的浏览器存储
  • 大小为5M左右
  • 仅在客户端使用,不和服务端进行通信
  • 接口封装较好

基于上面的特点,sessionStorage 可以有效对表单信息进行维护,比如刷新时,表单信息不丢失。

Cookie的作用是与服务器进行交互,作为HTTP规范的一部分而存在 ,而Web Storage仅仅是为了在本地“存储”数据而生。

  • 共同点:都是保存在浏览器端,且都遵循同源策略。
  • 不同点:在于生命周期与作用域的不同

作用域:localStorage只要在相同的协议、相同的主机名、相同的端口下,就能读取/修改到同一份localStorage数据。sessionStorage比localStorage更严苛一点,除了协议、主机名、端口外,还要求在同一窗口(也就是浏览器的标签页)下

生命周期:localStorage 是持久化的本地存储,存储在其中的数据是永远不会过期的,使其消失的唯一办法是手动删除;而 sessionStorage 是临时性的本地存储,它是会话级别的存储,当会话结束(页面被关闭)时,存储内容也随之被释放。

localStorage和sessionStorage都具有相同的操作方法,例如有setItem,getItem,removeItem,clear等。

localStorage和sessionStorage的key和length属性实现遍历,例如下面的代码:

1
2
3
4
5
6
var storage = window.localStorage; 
for (var i=0, len = storage.length; i < len; i++){
var key = storage.key(i);
var value = storage.getItem(key);
console.log(key + "=" + value);
}

说到底,Web Storage 是对 Cookie 的拓展,它只能用于存储少量的简单数据。当遇到大规模的、结构复杂的数据时,Web Storage 也爱莫能助了。这时候我们就要清楚我们的终极大 boss——IndexedDB!

IndexedDB

IndexedDB 是一种低级API,用于客户端存储大量结构化数据(包括文件和blobs)。该API使用索引来实现对该数据的高性能搜索。IndexedDB 是一个运行在浏览器上的非关系型数据库。既然是数据库了,那就不是 5M、10M 这样小打小闹级别了。理论上来说,IndexedDB 是没有存储上限的(一般来说不会小于 250M)。它不仅可以存储字符串,还可以存储二进制数据。

  • 键值对储存

    IndexedDB 内部采用对象仓库(object store)存放数据。所有类型的数据都可以直接存入,包括 JavaScript 对象。对象仓库中,数据以”键值对”的形式保存,每一个数据记录都有对应的主键,主键是独一无二的,不能有重复,否则会抛出一个错误。

  • 异步

    IndexedDB 操作时不会锁死浏览器,用户依然可以进行其他操作,这与 LocalStorage 形成对比,后者的操作是同步的。异步设计是为了防止大量数据的读写,拖慢网页的表现。

  • 支持事务

    IndexedDB 支持事务(transaction),这意味着一系列操作步骤之中,只要有一步失败,整个事务就都取消,数据库回滚到事务发生之前的状态,不存在只改写一部分数据的情况。

  • 同源限制

    IndexedDB 受到同源限制,每一个数据库对应创建它的域名。网页只能访问自身域名下的数据库,而不能访问跨域的数据库。

  • 储存空间大

    IndexedDB 的储存空间比 LocalStorage 大得多,一般来说不少于 250MB,甚至没有上限。

  • 支持二进制储存

    IndexedDB 不仅可以储存字符串,还可以储存二进制数据(ArrayBuffer 对象和 Blob 对象)。

操作:在IndexedDB大部分操作并不是我们常用的调用方法,返回结果的模式,而是请求——响应的模式。

  1. 建立打开IndexedDB —-window.indexedDB.open("testDB")

    我们得到的是一个IDBOpenDBRequest对象,而我们希望得到的DB对象在其result属性中

    除了result,IDBOpenDBRequest接口定义了几个重要属性:

    onerror: 请求失败的回调函数句柄

    onsuccess:请求成功的回调函数句柄

    onupgradeneeded:请求数据库版本变化句柄

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    <script>
    function openDB(name){
    var request=window.indexedDB.open(name)//建立打开IndexedDB
    request.onerror=function (e){
    console.log('open indexdb error')
    }
    request.onsuccess=function (e){
    myDB.db=e.target.result//这是一个 IDBDatabase对象,这就是IndexedDB对象
    console.log(myDB.db)//此处就可以获取到db实例
    }
    }
    var myDB={
    name:'testDB',
    version:'1',
    db:null
    }
    openDB(myDB.name)
    </script>
  2. 关闭IndexedDB—-indexdb.close()

  3. 删除IndexedDB—-window.indexedDB.deleteDatabase(indexdb)

HTTP和HTTPS

HTTP

HTTP是Hyper Text Transfer Protocol(超文本传输协议)的缩写。它是一个应用层协议,由请求和响应构成,是一个标准的客户端服务器模型。HTTP是一个无状态的协议。

1.HTTP协议的特点

  • 无连接
    • 限制每次连接只处理一个请求
  • 无状态
    • 协议对于事务处理没有记忆能力。
  • 简单快速
    • 客户向服务器请求服务时,只需传送请求方法和路径。
  • 灵活
    • HTTP允许传输任意类型的数据对象。正在传输的类型由Content-Type加以标记。

2.请求报文

  • 请求行
    • 请求类型
    • 要访问的资源
    • HTTP协议版本号
  • 请求头
    • 用来说明服务器要使用的附加信息(一些键值对)
    • 例如:User-Agent、 Accept、Content-Type、Connection
  • 空行
    • 分割请求头与请求体
  • 请求体
    • 可以添加任意的其他数据

3.响应报文

  • 状态行
    • 状态码
    • 状态消息
    • HTTP协议版本号
  • 消息报头
    • 说明客户端要使用的一些附加信息
    • 如:Content-Type、charset、响应的时间
  • 响应正文
    • 返回给客户端的文本信息

4.HTTP 方法

  • GET
    • 获取资源
  • POST
    • 传输资源
  • PUT
    • 更新资源
  • DELETE
    • 删除资源
  • HEAD
    • 获取报文首部

5.Post 和 Get 的区别

  • GET在浏览器回退时是无害的,而POST会再次提交
  • Get请求能缓存,Post不能
  • Post相对Get相对安全一些,因为Get请求都包含在URL中,而且会被浏览器保存记录,Post不会。但是再抓包的情况下都是一样的。
  • Post 可以通过 request body来传输比 Get 更多的数据
  • URL有长度限制,会影响 Get 请求,但是这个长度限制是浏览器规定的
  • Post 支持更多的编码类型且不对数据类型限制

先引入副作用和幂等的概念。 副作用指对服务器上的资源做改变,搜索是无副作用的,注册是副作用的。 幂等指发送 M 和 N 次请求(两者不相同且都大于 1),服务器上资源的状态一致,比如注册 10 个和 11 个帐号是不幂等的,对文章进行更改 10 次和 11 次是幂等的。 在规范的应用场景上说,Get 多用于无副作用,幂等的场景,例如搜索关键字。Post 多用于副作用,不幂等的场景,例如注册。

6.常见状态码

1XX 指示信息

表示请求已接收,继续处理

2XX 成功

  • 200 OK
  • 204 No content,表示请求成功,但响应报文不含实体的主体部分
  • 205 Reset Content,表示请求成功,但响应报文不含实体的主体部分,但是与 204 响应不同在于要求请求方重置内容
  • 206 Partial Content,进行范围请求

3XX 重定向

  • 301 永久性重定向,表示资源已被分配了新的 URL
  • 302 临时性重定向,表示资源临时被分配了新的 URL
  • 303 表示资源存在着另一个 URL,应使用 GET 方法获取资源
  • 304 未修改,重定位到浏览器。自从上次请求后,请求的网页未修改过。服务器返回此响应时,不会返回网页内容。如果网页自请求者上次请求后再也没有更改过,您应将服务器配置为返回此响应(称为 If-Modified-Since HTTP 标头)。服务器可以告诉 Googlebot 自从上次抓取后网页没有变更,进而节省带宽和开销。
  • 307 临时重定向,和302含义类似,但是期望客户端保持请求方法不变向新的地址发出请求

4XX 客户端错误

  • 404 在服务器上没有找到请求的资源
  • 403 forbidden,表示对请求资源的访问被服务器拒绝
  • 400 请求报文存在语法错误
  • 401 表示发送的请求需要有通过 HTTP 认证的认证信息

5XX 服务器错误

  • 500 表示服务器端在执行请求时发生了错误
  • 501 表示服务器不支持当前请求所需要的某个功能
  • 503 表明服务器暂时处于超负载或正在停机维护,无法处理请求

7.HTTP持久连接(HTTP1.1支持)

HTTP协议采用“请求-应答”模式,并且HTTP是基于TCP进行连接的。普通模式(非keep-alive)时,每个请求或应答都需要建立一个连接,完成之后立即断开。

当使用Conection: keep-alive模式(又称持久连接、连接重用)时,keep-alive使客户端道服务器端连接持续有效,即不关闭底层的TCP连接,当出现对服务器的后继请求时,keep-alive功能避免重新建立连接。

8.HTTP管线化 (HTTP1.1支持)

管线化后,请求和响应不再是依次交替的了。他可以支持一次性发送多个请求,并一次性接收多个响应。串行化单线程处理,如果前面一旦有某请求超时,后续请求只能被阻塞,也就是常说的线头阻塞。

  • 管线化机制通过持久连接完成,仅HTTP/1.1支持此技术
  • 只有get与head请求可以进行管线化,POST有限制
  • 初次创建连接时不应该启动管线机制,因为对方(服务器)不一定支持HTTP/1.1版本的协议
  • 管线化不会影响响应到来的顺序,即响应返回的顺序并未改变

9.HTTP数据协商

在客户端向服务端发送请求的时候,客户端会申明可以接受的数据格式和数据相关的一些限制是什么样的;服务端在接受到这个请求时他会根据这个信息进行判断到底返回怎样的数据。

请求

  • Accept
    • 在请求中使用Accept可申明想要的数据格式
  • Accept-Encoding
    • 告诉服务端使用什么的方式来进行压缩
    • 例如:gzip、deflate、br
  • Accept-Language
    • 描述语言信息
  • User-Agent
    • 用来描述客户端浏览器相关信息
    • 可以用来区分PC端页面和移动端页面

响应

  • Content-Type
    • 对应Accept,从请求中的Accept支持的数据格式中选一种来返回
  • Content-Encoding
    • 对应 Accept-Encoding,指服务端到底使用的是那种压缩方式
  • Content-Language
    • 对应Accept-Language

form 表单中enctype数据类型

enctype 属性规定在将表单数据发送到服务器之前如何对其进行编码。

默认是对表单数据以 “application/x-www-form-urlencoded” 进行编码。这意味着在发送前对所有字符进行编码(把 “+” 转换为空格,把特殊字符转换为 ASCII 十六进制值)。

  • application/x-www-form-urlencoded
    • key=value&key=value 格式
  • multipart/form-data
    • 用于提交文件
    • multipart表示请求是由多个部分组成(因为上传文件的时候文件不能以字符串形式提交,需要单独分出来)
    • boundary 用来分隔不同部分
  • text/plain

10.HTTP Redirect 重定向

  • 302 暂时重定向
    • 浏览器每次访问都要先去目标网址访问,再重定向到新的网址
  • 301 永久重定向
    • 当浏览器收到的HTTP状态码为301时,下次访问对应网址就直接调整到新的网址,不会再访问原网址

HTTP2

HTTP 2.0 相比于 HTTP 1.X,可以说是大幅度提高了 web 的性能。

HTTP2采用二进制格式传输,取代了HTTP1.x的文本格式,二进制格式解析更高效。 多路复用代替了HTTP1.x的序列和阻塞机制,所有的相同域名请求都通过同一个TCP连接并发完成。

1.二进制传输

HTTP 2.0 中所有加强性能的核心点在于此。在之前的 HTTP 版本中,我们是通过文本的方式传输数据。在 HTTP 2.0 中引入了新的编码机制,所有传输的数据都会被分割,并采用二进制格式编码。

2.多路复用

HTTP1.x中,并发多个请求需要多个TCP连接,浏览器为了控制资源会有6-8个TCP连接都限制。 HTTP2中

  • 同域名下所有通信都在单个连接上完成,消除了因多个 TCP 连接而带来的延时和内存消耗。
  • 单个连接上可以并行交错的请求和响应,之间互不干扰

在 HTTP 2.0 中,有两个非常重要的概念,分别是帧(frame)和流(stream)。

帧代表着最小的数据单位,每个帧会标识出该帧属于哪个流,流也就是多个帧组成的数据流。

多路复用,就是在一个 TCP 连接中可以存在多条流。 换句话说,也就是可以发送多个请求,对端可以通过帧中的标识知道属于哪个请求。通过这个技术,可以避免 HTTP 旧版本中的队头阻塞问题,极大的提高传输性能。

3.Header 压缩

在 HTTP 1.X 中,我们使用文本的形式传输 header,在 header 携带 cookie 的情况下,可能每次都需要重复传输几百到几千的字节。

在 HTTP 2.0 中,使用了 d压缩格式对传输的 header 进行编码,减少了 header 的大小。并在两端维护了索引表,用于记录出现过的 header ,后面在传输过程中就可以传输已经记录过的 header 的键名,对端收到数据后就可以通过键名找到对应的值。

4.服务端 Push

在 HTTP 2.0 中,服务端可以在客户端某个请求后,主动推送其他资源。

可以想象以下情况,某些资源客户端是一定会请求的,这时就可以采取服务端 push 的技术,提前给客户端推送必要的资源,这样就可以相对减少一点延迟时间。当然在浏览器兼容的情况下你也可以使用 prefetch。

5.HTTP首部

通用字段 作用
Cache-Control 控制缓存的行为
Connection 浏览器想要优先使用的连接类型,比如 keep-alive
Date 创建报文时间
Pragma 报文指令
Via 代理服务器相关信息
Transfer-Encoding 传输编码方式
Upgrade 要求客户端升级协议
Warning 在内容中可能存在错误
请求字段 作用
Accept 能正确接收的媒体类型
Accept-Charset 能正确接收的字符集
Accept-Encoding 能正确接收的编码格式列表
Accept-Language 能正确接收的语言列表
Expect 期待服务端的指定行为
From 请求方邮箱地址
Host 服务器的域名
If-Match 两端资源标记比较
If-Modified-Since 本地资源未修改返回 304(比较时间)
If-None-Match 本地资源未修改返回 304(比较标记)
User-Agent 客户端信息
Max-Forwards 限制可被代理及网关转发的次数
Proxy-Authorization 向代理服务器发送验证信息
Range 请求某个内容的一部分
Referer 表示浏览器所访问的前一个页面
TE 传输编码方式
响应字段 作用
Accept-Ranges 是否支持某些种类的范围
Age 资源在代理缓存中存在的时间
ETag 资源标识
Location 客户端重定向到某个 URL
Proxy-Authenticate 向代理服务器发送验证信息
Server 服务器名字
WWW-Authenticate 获取资源需要的验证信息
实体字段 作用
Allow 资源的正确请求方式
Content-Encoding 内容的编码格式
Content-Language 内容使用的语言
Content-Length request body 长度
Content-Location 返回数据的备用地址
Content-MD5 Base64加密格式的内容 MD5检验值
Content-Range 内容的位置范围
Content-Type 内容的媒体类型
Expires 内容的过期时间
Last_modified 内容的最后修改时间

HTTPS

http默认采用80作为通讯端口,对于传输采用不加密的方式,https默认采用443,对于传输的数据进行加密传输。

密码学基础

明文: 明文指的是未被加密过的原始数据。

密文:明文被某种加密算法加密之后,会变成密文,从而确保原始数据的安全。密文也可以被解密,得到原始的明文。

密钥:密钥是一种参数,它是在明文转换为密文或将密文转换为明文的算法中输入的参数。密钥分为对称密钥与非对称密钥,分别应用在对称加密和非对称加密上。

对称加密

对称加密又叫做私钥加密,即信息的发送方和接收方使用同一个密钥去加密和解密数据。对称加密的特点是算法公开、加密和解密速度快,适合于对大数据量进行加密,常见的对称加密算法有DES、3DES、TDEA、Blowfish、RC5和IDEA。

1
2
其加密过程如下:明文 + 加密算法 + 私钥 => 密文
解密过程如下:密文 + 解密算法 + 私钥 => 明文

对称加密中用到的密钥叫做私钥,私钥表示个人私有的密钥,即该密钥不能被泄露。

其加密过程中的私钥与解密过程中用到的私钥是同一个密钥,这也是称加密之所以称之为“对称”的原因。由于对称加密的算法是公开的,所以一旦私钥被泄露,那么密文就很容易被破解,所以对称加密的缺点是密钥安全管理困难。

非对称加密

非对称加密也叫做公钥加密。非对称加密与对称加密相比,其安全性更好。对称加密的通信双方使用相同的密钥,如果一方的密钥遭泄露,那么整个通信就会被破解。而非对称加密使用一对密钥,即公钥和私钥,且二者成对出现。私钥被自己保存,不能对外泄露。公钥指的是公共的密钥,任何人都可以获得该密钥。用公钥或私钥中的任何一个进行加密,用另一个进行解密。

  • 被公钥加密过的密文只能被私钥解密,过程如下:
1
明文 + 加密算法 + 公钥 => 密文, 密文 + 解密算法 + 私钥 => 明文
  • 被私钥加密过的密文只能被公钥解密,过程如下:
1
明文 + 加密算法 + 私钥 => 密文, 密文 + 解密算法 + 公钥 => 明文

由于加密和解密使用了两个不同的密钥,这就是非对称加密“非对称”的原因。 非对称加密的缺点是加密和解密花费时间长、速度慢,只适合对少量数据进行加密。 在非对称加密中使用的主要算法有:RSA、Elgamal、Rabin、D-H、ECC(椭圆曲线加密算法)等。

HTTPS通信过程

HTTPS协议 = HTTP协议 + SSL/TLS协议,在HTTPS数据传输的过程中,需要用SSL/TLS对数据进行加密和解密,需要用HTTP对加密后的数据进行传输,由此可以看出HTTPS是由HTTP和SSL/TLS一起合作完成的。

SSL的全称是Secure Sockets Layer,即安全套接层协议,是为网络通信提供安全及数据完整性的一种安全协议。SSL协议在1994年被Netscape发明,后来各个浏览器均支持SSL,其最新的版本是3.0。

TLS的全称是Transport Layer Security,即安全传输层协议,最新版本的TLS(Transport Layer Security,传输层安全协议)是IETF(Internet Engineering Task Force,Internet工程任务组)制定的一种新的协议,它建立在SSL 3.0协议规范之上,是SSL 3.0的后续版本。在TLS与SSL3.0之间存在着显著的差别,主要是它们所支持的加密算法不同,所以TLS与SSL3.0不能互操作。虽然TLS与SSL3.0在加密算法上不同,但是在我们理解HTTPS的过程中,我们可以把SSL和TLS看做是同一个协议。

HTTPS为了兼顾安全与效率,同时使用了对称加密和非对称加密。数据是被对称加密传输的,对称加密过程需要客户端的一个密钥,为了确保能把该密钥安全传输到服务器端,采用非对称加密对该密钥进行加密传输,总的来说,对数据进行对称加密,对称加密所要使用的密钥通过非对称加密传输

HTTPS在传输的过程中会涉及到三个密钥:

  • 服务器端的公钥和私钥,用来进行非对称加密
  • 客户端生成的随机密钥,用来进行对称加密

一个HTTPS请求实际上包含了两次HTTP传输,可以细分为8步。

  1. 客户端向服务器发起HTTPS请求,连接到服务器的443端口
  2. 服务器端有一个密钥对,即公钥和私钥,是用来进行非对称加密使用的,服务器端保存着私钥,不能将其泄露,公钥可以发送给任何人。
  3. 服务器将自己的公钥发送给客户端。
  4. 客户端收到服务器端的公钥之后,会对公钥进行检查,验证其合法性,如果发现发现公钥有问题,那么HTTPS传输就无法继续。严格的说,这里应该是验证服务器发送的数字证书的合法性。如果公钥合格,那么客户端会生成一个随机值,这个随机值就是用于进行对称加密的密钥,我们将该密钥称之为client key,即客户端密钥,这样在概念上和服务器端的密钥容易进行区分。然后用服务器的公钥对客户端密钥进行非对称加密,这样客户端密钥就变成密文了,至此,HTTPS中的第一次HTTP请求结束。
  5. 客户端会发起HTTPS中的第二个HTTP请求,将加密之后的客户端密钥发送给服务器。
  6. 服务器接收到客户端发来的密文之后,会用自己的私钥对其进行非对称解密,解密之后的明文就是客户端密钥,然后用客户端密钥对数据进行对称加密,这样数据就变成了密文。
  7. 然后服务器将加密后的密文发送给客户端。
  8. 客户端收到服务器发送来的密文,用客户端密钥对其进行对称解密,得到服务器发送的数据。这样HTTPS中的第二个HTTP请求结束,整个HTTPS传输完成。

XSS和CSRF

XSS跨站脚本攻击

XSS ( Cross Site Scripting ) 是指恶意攻击者利用网站没有对用户提交数据进行转义处理或者过滤不足的缺点,进而添加一些代码,嵌入到web页面中去。使别的用户访问都会执行相应的嵌入代码。

从而盗取用户资料、利用用户身份进行某种动作或者对访问者进行病毒侵害的一种攻击方式。

XSS攻击的危害:

  1. 获取页面数据
  2. 获取cookie
  3. 劫持前端逻辑
  4. 发送请求
  5. 偷取网站任意数据
  6. 偷取用户资料
  7. 偷取用户密码和登陆态
  8. 欺骗用户

反射型

通过url参数直接注入。

发出请求时,XSS代码出现在URL中,作为输入提交到服务器端,服务端解析后返回,XSS代码随响应内容一起传回给浏览器,最后浏览器执行XSS代码。这个过程像一次反射,故叫做反射型XSS。

举个例子

一个链接,里面的query字段中包含一个script标签,这个标签的src就是恶意代码,用户点击了这个链接后会先向服务器发送请求,服务器返回时也携带了这个XSS代码,然后浏览器将查询的结果写入Html,这时恶意代码就被执行了。

存储型

存储型XSS会被保存到数据库,在其他用户访问(前端)到这条数据时,这个代码会在访问用户的浏览器端执行。

举个例子

比如攻击者在一篇文章的评论中写入了script标签,这个评论被保存数据库,当其他用户看到这篇文章时就会执行这个脚本。

XSS防御

对于 XSS 攻击来说,通常有两种方式可以用来防御。

  • 转义字符
  • CSP 内容安全策略

CSRF跨站请求伪造

(Cross Site Request Forgy)CSRF中文名为跨站请求伪造。原理就是攻击者构造出一个后端请求地址,诱导用户点击或者通过某些途径自动发起请求。如果用户是在登录状态下的话,后端就以为是用户在操作,从而进行相应的逻辑。

举个例子,假设网站中有一个通过 GET 请求提交用户评论的接口,那么攻击者就可以在钓鱼网站中加入一个图片,图片的地址就是评论接口

1
<img src="http://www.domain.com/xxx?comment='attack'"/>

如何防御?

  1. Get 请求不对数据进行修改
  2. 不让第三方网站访问到用户Cookie
  3. 阻止第三方网站请求接口
  4. 请求时附带验证信息,比如验证码或者Token
  • SameSite
    • 可以对Cookie设置SameSite属性。该属性表示Cookie不随着跨域请求发送,可以很大程度减少CSRF的攻击,但是该属性目前并不是所有浏览器都兼容。
  • Token验证
    • cookie是发送时自动带上的,而不会主动带上Token,所以在每次发送时主动发送Token
  • Referer验证
    • 对于需要防范 CSRF 的请求,我们可以通过验证 Referer 来判断该请求是否为第三方网站发起的。
  • 隐藏令牌
    • 主动在HTTP头部中添加令牌信息

危害:

  • 利用用户登录态,用户不知情,完成业务请求
  • 盗取用户资金,冒充用户发帖背锅,损坏网站名誉

SQL注入

所谓SQL注入,就是通过把SQL命令插入到Web表单提交或输入域名或页面请求的查询字符串,后台执行SQL语句时直接把前端传入的字段拿来做SQL查询。

防御

  • 永远不要信任用户的输入。
  • 永远不要使用动态拼装sql
  • 不要把机密信息直接存放

TCP和UDP

UDP 与 TCP 的区别是什么?

UDP 协议 是面向无连接的,不需要在正式传递数据之前先连接起双方。 UDP 协议只是数据报文的搬运工,不保证有序且不丢失的传递到对端,并且UDP 协议也没有任何控制流量的算法,总的来说 UDP 相较于 TCP 更加的轻便。一般可以用于直播、即时通讯、即时游戏等。

TCP 无论是建立连接还是断开连接都需要先需要进行握手。在传输数据的过程中,通过各种算法保证数据的可靠性,当然带来的问题就是相比 UDP 来说不那么的高效。

三次握手与四次挥手

简单的说:

  • 第一次握手
    • SYN = 1, seq(client) = x
    • 客户端向服务端发送连接请求报文段。该报文段中包含自身的数据通讯初始序号。请求发送后,客户端便进入 SYN-SENT 状态。
  • 第二次握手
    • SYN = 1,ACK = 1,确认序号 = x+1, seq(server) = y
    • 服务端收到连接请求报文段后,如果同意连接,则会发送一个应答,该应答中也会包含自身的数据通讯初始序号,发送完成后便进入 SYN-RECEIVED 状态
  • 第三次握手
    • ACK = 1,确认序号 = y+1, seq(client) = x + 1
    • 客户端收到连接同意的应答后,还要向服务端发送一个确认报文。客户端发完这个报文段后便进入ESTABLISHED 状态,服务端收到这个应答后也进入 ESTABLISHED 状态,此时连接建立成功。

当SYN=1,ACK=0时,表示当前报文段是一个连接请求报文。当SYN=1,ACK=1时,表示当前报文段是一个同意建立连接的应答报文。

为什么不用两次握手?

主要是为了防止已经失效的连接请求报文突然又传送到了服务器,从而产生错误。

假设有这样一种场景, 客户端发送的第一个请求连接并且没有丢失,但是被滞留的时间太长。由于TCP的客户端迟迟没有收到确认报文,以为服务器没有收到,此时重新向服务器发送报文。 而现在第一个请求到达服务端,这个请求已经报废了,但是又会建立连接。

如果采用的是三次握手,就算是那一次失效的报文传送过来了,服务端接受到了那条失效报文并且回复了确认报文,但是客户端不会再次发出确认。由于服务器收不到确认,就知道客户端并没有请求连接。

TCP 是全双工的,在断开连接时两端都需要发送 FIN 和 ACK。

  • 第一次挥手
    • 若客户端 A 认为数据发送完成,则它需要向服务端 B 发送连接释放请求。
  • 第二次挥手
    • B 收到连接释放请求后,会告诉应用层要释放 TCP 链接。然后会发送 ACK 包,并进入 CLOSE_WAIT 状态,表示 A 到 B 的连接已经释放,不接收 A 发的数据了。但是因为 TCP 连接时双向的,所以 B 仍旧可以发送数据给 A。
  • 第三次挥手
    • B 如果此时还有没发完的数据会继续发送,完毕后会向 A 发送连接释放请求,然后 B 便进入LAST-ACK状态。
    • PS:通过延迟确认的技术(通常有时间限制,否则对方会误认为需要重传),可以将第二次和第三次握手合并,延迟 ACK 包的发送。
  • 第四次挥手
    • A 收到释放请求后,向 B 发送确认应答,此时 A 进入 TIME-WAIT 状态。该状态会持续 2MSL(最大段生存期,指报文段在网络中生存的时间,超时会被抛弃) 时间,若该时间段内没有 B 的重发请求的话,就进入 CLOSED 状态。当 B 收到确认应答后,也便进入 CLOSED 状态。

为什么 A 要进入 TIME-WAIT 状态,等待 2MSL 时间后才进入 CLOSED 状态?

为了保证 B 能收到 A 的确认应答。若 A 发完确认应答后直接进入 CLOSED 状态,如果确认应答因为网络问题一直没有到达,那么会造成 B 不能正常关闭。

为什么建立连接是三次握手,关闭连接确是四次挥手呢?

建立连接的时候, 服务器在LISTEN状态下,收到建立连接请求的SYN报文后,把ACK和SYN放在一个报文里发送给客户端。

而关闭连接时,服务器收到对方的FIN报文时,仅仅表示对方不再发送数据了但是还能接收数据,而自己也未必全部数据都发送给对方了

ARQ (超时重传)协议

通过确认和超时机制保证了数据的正确送达,ARQ 协议包含停止等待 ARQ连续 ARQ

停止等待 ARQ

正常传输过程

只要 A 向 B 发送一段报文,都要停止发送并启动一个定时器,等待对端回应,在定时器时间内接收到对端应答就取消定时器并发送下一段报文。

当报文丢失或出错:

报文传输的过程中丢包: 这时候超过定时器设定的时间就会再次发送丢包的数据直到对端响应,所以需要每次都备份发送的数据。

传输过程中报文出错: 对端会抛弃该报文并等待 A 端重传。

PS:一般定时器设定的时间都会大于一个 RTT 的平均时间。

ACK 超时或丢失:

对端传输的应答也可能出现丢失或超时的情况。那么超过定时器时间 A 端照样会重传报文。这时候 B 端收到相同序号的报文会丢弃该报文并重传应答,直到 A 端发送下一个序号的报文。

这个协议的缺点就是传输效率低,在良好的网络环境下每次发送报文都得等待对端的 ACK 。

连续 ARQ

在连续 ARQ 中,发送端拥有一个发送窗口,可以在没有收到应答的情况下持续发送窗口内的数据,这样相比停止等待 ARQ 协议来说减少了等待时间,提高了效率。

累计确认

连续 ARQ 中,接收端会持续不断收到报文。如果和停止等待 ARQ 中接收一个报文就发送一个应答一样,就太浪费资源了。通过累计确认,可以在收到多个报文以后统一回复一个应答报文。报文中的 ACK 可以用来告诉发送端这个序号之前的数据已经全部接收到了,下次请发送这个序号 + 1的数据。

但是累计确认也有一个弊端。在连续接收报文时,可能会遇到接收到序号 5 的报文后,并未接到序号 6 的报文,然而序号 7 以后的报文已经接收。遇到这种情况时,ACK 只能回复 6,这样会造成发送端重复发送数据。

滑动窗口

上面讲到了发送窗口。在 TCP 中,两端都维护着窗口:分别为发送端窗口和接收端窗口。

发送端窗口包含已发送但未收到应答的数据和可以发送但是未发送的数据。

发送端窗口是由接收窗口剩余大小决定的。接收方会把当前接收窗口的剩余大小写入应答报文,发送端收到应答后根据该值和当前网络拥塞情况设置发送窗口的大小,所以发送窗口的大小是不断变化的。

当发送端接收到应答报文后,会随之将窗口进行滑动

滑动窗口实现了流量控制。接收方通过报文告知发送方还可以发送多少数据,从而保证接收方能够来得及接收数据。

Zero 窗口

在发送报文的过程中,可能会遇到对端出现零窗口的情况。在该情况下,发送端会停止发送数据,并启动 persistent timer 。该定时器会定时发送请求给对端,让对端告知窗口大小。在重试次数超过一定次数后,可能会中断 TCP 链接。

拥塞处理

拥塞处理和流量控制不同,后者是作用于接收方,保证接收方来得及接受数据。而前者是作用于网络,防止过多的数据拥塞网络,避免出现网络负载过大的情况。

拥塞处理包括了四个算法,分别为:慢开始,拥塞避免,快速重传,快速恢复。

慢开始算法

慢开始算法,顾名思义,就是在传输开始时将发送窗口从1开始指数级扩大,从而避免一开始就传输大量数据导致网络拥塞。

慢开始算法步骤具体如下

  1. 连接初始设置拥塞窗口(Congestion Window) 为 1 MSS(一个分段的最大数据量)
  2. 每过一个 RTT (往返时延) 就将窗口大小乘二
  3. 指数级增长肯定不能没有限制的,所以有一个阈值限制,当窗口大小大于阈值时就会启动拥塞避免算法

拥塞避免算法

拥塞避免算法相比简单点,每过一个 RTT 窗口大小只加一,这样能够避免指数级增长导致网络拥塞,慢慢将大小调整到最佳值。

在传输过程中可能定时器超时的情况,这时候 TCP 会认为网络拥塞了,会马上进行以下步骤:

  • 将阈值设为当前拥塞窗口的一半
  • 将拥塞窗口设为 1 MSS
  • 启动拥塞避免算法

快速重传一般和快恢复一起出现。一旦接收端收到的报文出现失序的情况,接收端只会回复最后一个顺序正确的报文序号(没有 Sack 的情况下)。如果收到三个重复的 ACK,无需等待定时器超时再重发而是启动快速重传。具体算法分为两种:

RTT与RTO

  • RTT

    (Round Trip Time)

    • 一个连接的往返时间,即数据发送时刻到接收到确认的时刻的差值。
  • RTO

    (Retransmission Time Out)

    • 重传超时时间,即从数据发送时刻算起,超过这个时间便执行重传。
  • RTT和RTO 的关系是

    • 由于网络波动的不确定性,每个RTT都是动态变化的,所以RTO也应随着RTT动态变化。

TCP 小结

为什么TCP这么复杂?

因为既要保证可靠性, 同时又要尽可能提高性能

保证可靠性的机制

  • 校验和
  • 序列号(按序到达)
  • 确认应答
  • 超时重传
  • 连接管理
  • 流量控制
  • 拥塞控制

提高性能的机制

  • 滑动窗口
  • 快速重传
  • 延迟应答
  • 捎带应答

定时器

  • 超时重传定时器
  • 保活定时器
  • TIME_WAIT定时器

基于 TCP 的应用层协议

  • HTTP
  • HTTPS
  • SSH
  • Telnet
  • FTP
  • SMTP

Cookie和Session

应用场景

cookie: 登录网站,第一天输入用户名密码登录了,第二天再打开很多情况下就直接打开了。这个时候用到的一个机制就是cookie。

session: session一个场景是购物车,添加了商品之后客户端处可以知道添加了哪些商品,而服务器端如何判别呢,所以也需要存储一些信息就用到了session。

服务器通过设置set-cookie这个响应头,将cookie信息返回给浏览器,浏览器将响应头中的cookie信息保存在本地,当下次向服务器发送HTTP请求时,浏览器会自动将保存的这些cookie信息添加到请求头中。

通过cookie,服务器就会识别出浏览器,从而保证返回的数据是这个用户的。

  • 通过set-cookie设置
  • 下次请求会自动带上
  • 键值对,可设置多个

cookie属性

  • max-age
    • 过期时间有多长
    • 默认在浏览器关闭时失效
  • expires
    • 到哪个时间点过期
  • secure
    • 表示这个cookie只会在https的时候才会发送
  • HttpOnly
    • 设置后无法通过在js中使用document.cookie访问
    • 保障安全,防止攻击者盗用用户cookie
  • domain
    • 表示该cookie对于哪个域是有效的。

session

  • 存放在服务器的一种用来存放用户数据的类似HashTable的结构
  • 浏览器第一次发送请求时,服务器自动生成了HashTable和SessionID来唯一标识这个hash表,并将sessionID存放在cookie中通过响应发送到浏览器。浏览器第二次发送请求会将前一次服务器响应中的sessionID随着cookie发送到服务器上,服务器从请求中提取sessionID,并和保存的所有sessionID进行对比,找到这个用户对应的hash表。
    • 一般这个值是有时间限制的,超时后销毁,默认30min
  • 当用户在应用程序的web页面间挑转时,存储在session对象中的变量不会丢失而是在整个用户会话中一直存在下去。
  • session依赖于cookie,因为sessionID是存放在cookie中的。

sesssion与cookie的区别

  • cookie存在客户端,session存在于服务端。
  • cookie在客户端中存放,容易伪造,不如session安全
  • session会消耗大量服务器资源,cookie在每次HTTP请求中都会带上,影响网络性能
  • 域的支持范围不一样,比方说a.com的Cookie在a.com下都能用,而www.a.com的Session在api.a.com下都不能用
-------------本文结束感谢您的阅读-------------