一个网络请求如何进行

页面的请求过程

当我们打开某个网站的页面时,浏览器会发起网络请求来获取页面的资源,我们可以从控制台network面板查看

在 Network 面板里,我们能看到所有浏览器发起的网络请求,包括页面、图片、CSS 文件、XHR 请求等,还能看到请求的状态(200 成功、404 找不到、缓存、重定向等等)、耗时、请求头和内容、返回头和内容等。

image-20210812093034705

图中第一个就是网站页面的请求,返回页面。

接下来,浏览器会加载页面,同时页面中涉及的外部资源也会根据需要,在特定的时机触发请求下载,包括我们看到的 PNG 图片、JavaScript 文件(这里没有 CSS 样式,是因为样式被直出在页面内容里了)。

浏览器的处理过程

回到前面的问题,实际上当我们在浏览器输入网页地址,按下回车键后,浏览器的处理过程如下:

  1. DNS 域名解析(此处涉及 DNS 的寻址过程),找到网页的存放服务器;

  2. 浏览器与服务器建立 TCP 连接;

  3. 浏览器发起 HTTP 请求;

  4. 服务器响应 HTTP 请求,返回该页面的 HTML 内容;

  5. 浏览器解析 HTML 代码,并请求 HTML 代码中的资源(如 JavaScript、CSS、图片等,此处可能涉及 HTTP 缓存);

  6. 浏览器对页面进行渲染呈现给用户(此处涉及浏览器的渲染原理)。

DNS 解析过程

首先我们来看 DNS 解析过程。

DNS 的全称是 Domain Name System,又称域名系统,它负责把www.qq.com这样的域名地址翻译成一个 IP(比如14.18.180.206),而客户端与服务器建立 TCP 连接需要通过 IP 通信。

让客户端和服务器连接并不是靠域名进行,在网络中每个终端之间实现连接和通信是通过一个唯一的 IP 地址来完成。在建立 TCP 连接前,我们需要找到建立连接的服务器,DNS 的解析过程可以让用户通过域名找到存放文件的服务器。

DNS 解析过程会进行递归查询,分别依次尝试从以下途径,按顺序地获取该域名对应的 IP 地址。

  • 浏览器缓存

  • 系统缓存(用户操作系统 Hosts 文件 DNS 缓存)

  • 路由器缓存

  • 互联网服务提供商 (IPS)DNS 缓存(联通、移动、电信等互联网服务提供商的 DNS 缓存服务器)

  • 根域名服务器

  • 顶级域名服务器

  • 主域名服务器

除此之外,我们在前后端联调过程中也常常需要配置 HOST,这个过程便是修改了浏览器缓存或是系统缓存。通过将特定域名指向我们自身的服务器 IP 地址,便可以实现通过域名访问本地环境、测试环境、预发布环境的服务器资源。

那为什么需要配置域名 HOST,而不直接使用 IP 地址进行访问呢?这是因为浏览器的同源策略会导致跨域问题

同源策略

同源策略要求,只有当请求的协议、域名和端口都相同的情况下,我们才可以访问当前页面的 Cookie/LocalStorage/IndexDB、获取和操作 DOM 节点,以及发送 Ajax 请求。通过同源策略的限制,可以避免恶意的攻击者盗取用户信息,从而可以保证用户信息的安全。

对于非同源的请求,我们常常称为跨域请求,需要进行跨域处理。常见的跨域解决方案有这几种。

  • 使用document.domain + iframe:只有在主域相同的时候才能使用该方法。

  • 动态创建 script(JSONP):通过指定回调函数以及函数的传参数据,让页面执行相应的脚本内容。

  • 使用location.hash + iframe:利用location.hash来进行传值。

  • 使用window.name + iframe:原理是window.name值在不同的页面(甚至不同域名)加载后依旧存在。

  • 使用window.postMessage()实现跨域通信。

  • 使用跨域资源共享 CORS(Cross-origin resource sharing)。

  • 使用 Websockets

其中,CORS 作为现在的主流解决方案,它允许浏览器向跨源服务器,发出 XMLHttpRequest 请求,从而克服了 Ajax 只能同源使用的限制。实现 CORS 通信的关键是服务器,只要服务端实现了 CORS 接口,就可以进行跨源通信。

TCP连接

TCP协议全城是传输控制协议,是一种面向连接的,可靠的,基于字节流的传输层通信协议。

这里的传输层位于TCP/IP协议族中,自底向上分别是数据链路层,网络层,传输层,应用层。

DNS 解析完成后,浏览器获得了服务端的 IP 地址,接下来便可以向服务端发起 HTTP 请求。目前大多数 HTTP 请求都建立在 TCP 连接上,因此客户端和服务端会先建立起 TCP 连接。

TCP 连接的建立

TCP 连接的建立过程比较偏通信底层,在前端日常开发过程中不容易接触到。但有时候我们需要优化应用的加载耗时、请求耗时或是定位一些偏底层的问题(请求异常、HTTP 连接无法建立等),都会或多或少依赖这些偏底层的知识。

另外,从面试的角度看,我们需要掌握 TCP/UDP 的区别、TCP 的三次握手和四次挥手内容。

  • TCP 协议提供可靠传输服务,UDP 协议则可以更快地进行通信;

  • 三次握手:指 TCP 连接的建立过程,该过程中客户端和服务端总共需要发送三个包,从而确认连接存在。

  • 四次挥手:指 TCP 连接的断开过程,该过程中需要客户端和服务端总共发送四个包以,从而确认连接关闭。

三次握手
一些概念

TCP客户进程:客户端

TCP服务进程:服务器

SYN:同步位,SYN=1表示这是一个TCP连接请求报文段

SYN=1的报文段不能携带数据,但要消耗掉一个序号

seq:序号字段,设置为一个初始值x,作为TCP客户进程所选择的初始序号。

ACK:确认位

ack

SYN_SENT同步已发送

SYN_RCVD同步已接收

ESTABLISHED:连接已建立

最开始两端的TCP进程都处于关闭状态CLOSED

一开始服务器进程首先创建传输控制块,用来存储TCP连接中的一些重要信息,

  • TCP连接表
  • 指向发送和接收缓存的指针
  • 指向重传队列的指针
  • 当前发送和接收序号

然后准备接收TCP客户进程的连接请求,TCP服务进程进入监听状态,也就是处于listen状态,等待TCP客户进程的连接请求,被动等待,称为被动打开连接

握手过程
  1. 第一次:TCP客户进程也是首先先创建传输控制块,然后向服务器发送TCP连接请求报文段,并进入同步已发送状态(SYN_SENT),TCP连接请求报文段首部中的同步位SYN设置为1,表示这是一个TCP连接请求报文段,序号字段seq设置为一个初始值x,作为TCP客户进程选择的初始序号。注意:SYN=1的报文段不能携带数据,但要消耗掉一个序号。由于TCP连接是由TCP客户进程主动发起的,所以称为(主动打开连接)。
  2. 第二次:TCP服务进程收到TCP连接请求报文段后,如果同意建立连接,则向客户端发送TCP请求确认报文段,并进入同步已接收状态SYN_RCVD。该报文段首部中的同步位SYN和确认位ACK都设置为1,表示这是一个TCP连接请求确认报文段。序号字段seq设置为一个初始值y,作为TCP服务器进程所选择的初始序列号,确认号字段ack的值设置为x+1,这是对TCP客户进程所选择的初始序号的确认。这个报文段也不能携带数据。也消耗一个序号。
  3. 第三次:TCP客户进程收到TCP连接请求确认报文段后,还要向TCP服务器进程发送一个普通的TCP确认报文段,并进入连接已建立状态ESTABLISHED,该报文段首部中的确认位ACK被设置为1,表示这是一个普通的TCP确认报文段,序号字段seq被设置为x+1,因为TCP客户进程发送的第一个TCP报文段的序号为x,并且不携带数据,所以第二个报文段的序号为x+1,TCP规定,普通的TCP确认报文可以携带数据,但如果不携带数据,则不消耗序列号。在这种情况下,所发送的下一个数据包文段的序号仍是x+1,确认号字段设置为y+1,这是对TCP服务器进程所选择的初始序号的确认。

TCP服务器进程收到确认报文段后也进入连接已建立状态。

现在TCP双方都进入了连接已建立状态。

TCP三次握手

问题:为什么TCP客户进程最后还要发送一个普通的TCP确认报文段?

防止已失效的连接请求报文段又传送到TCP服务器进程,因而导致错误。

四次挥手

FIN连接释放报文

  1. 第一次:客户端发送一个连接释放报文FIN,并且停止发送数据,设置报文FIN=1,报文中会指定一个序列号seq=u。此时客户端处于FIN_WAIT_1状态。(终止待待1)

  2. 第二次:服务端收到连接释放报文FIN报文后,此时服务端处于CLOSE_WAIT状态。然后会发送确认报文ACK,把客户端发送的序列号+1作为ACK报文的序列号ack=u+1seq=v表明已经收到客户端的报文了,不再接收客户端发送的数据了,此时客户端处于FIN_WAIT_2状态。

  3. 第三次:服务端将最后的数据发送完毕后,如果服务端同意断开连接,就向客户端发送连接释放报文FIN=1,ACK=1,ack=u+1,服务器又发送了一些数据后停止,序列号为seq=w此时服务器处于LAST_ACK状态,

  4. 第四次:客户端收到释放报文FIN后,发送一个确认ACK报文作为应答ACK=1,把服务器的序列号+1作为自己ACK报文的序列号ack=w+1,而自己的序列号是seq=u+1,此时客户端处于TIME_WAIT状态。该状态会持续 2MSL(最大段生存期,指报文段在网络中生存的时间,超时会被抛弃) 时间,若该时间段内没有 B 的重发请求的话,就进入 CLOSED 状态。当 B 收到确认应答ACK后,也便进入 CLOSED 状态。

详细版本

TCP通过四报文挥手来释放连接

数据传输结束后,TCP通信的双方都可以释放连接。

释放之前,双方都处于连接已建立状态,假设使用TCP客户进程的应用进程通知其主动关闭TCP连接,TCP客户进程会发送TCP连接释放报文段,并进入终止等待1状态,该报文首部中的终止位FIN,和确认位ACK都设置为1,表示这是一个TCP连接释放报文段,同时也对之前收到的报文段进行确认,序号seq字段的值设置为u,它等于TCP客户进程之前已传送过的数据的最后一个字节的序号加1,注意:TCP规定终止位FIN等于1的报文段即使不携带数据,也会消耗一个序号,确认号字段ack的值设置为v,它等于TCP客户进程之前已经收到的数据的最后一个字节的序号加一。

TCP服务器进程收到TCP连接释放报文段后,会发送一个普通的TCP确认报文段并进入关闭等待状态。该报文段首部中的确认位ACK的值设置为1,表示这是一个普通的TCP确认报文段。序号seq字段的值设置为v,它等于TCP服务器进程之前已传送过的数据的最后一个字节的序号加一,这也与之前收到的TCP连接释放报文段中的确认号匹配。确认号ack字段的值设置为u+1,这是对TCP连接释放报文段的确认,TCP服务器进程此时通知高层应用进程:TCP客户进程要断开与自己的TCP连接,此时,从TCP客户进程到TCP服务器进程这个方向的连接就释放了。此时TCP连接属于半关闭状态。也就是TCP客户进程没有数据需要发送了,但TCP服务器进程如果还有数据要发送,TCP客户进程仍要接收,也就是说从TCP服务器进程到TCP客户进程这个方向的连接并未关闭。

TCP客户进程收到TCP确认报文段后进入终止等待2状态,等待TCP服务器进程发出的TCP连接释放报文段。若使用TCP服务器进程的应用进程已经没有数据要发送了,应用进程就通知其TCP服务器进程释放连接。由于TCP连接释放是由TCP客户进程主动发起的,因此TCP服务器进程对TCP连接的释放称为被动关闭连接。

TCP服务器进程发送TCP连接释放报文段进入最后确认状态。该报文首部中的终止位FIN和确认位ACK的值都被设置为1,表示这是一个TCP连接释放报文段,同时也对之前收到的报文段进行确认。

现在假定序号字段seq字段的值为w,这是因为在半关闭状态下,TCP服务器进程可能又发送了一些数据,确认号ack字段的值为u+1,这是对之前收到的TCP连接释放报文段的重复确认。

TCP客户进程收到TCP连接释放报文段后,必须针对该报文段发送的普通TCP确认报文段,之后进入时间等待状态TIME_WAIT,该报文段首部中的确认位ACK的值被设置为1,表示这是一个普通的TCP确认报文段。序号seq字段的值设置为u+1,这是因为TCP客户进程之前发送的TCP连接释放报文虽然不携带数据,但消耗掉一个序号,确认号ack字段的值设置为w+1,这是对所收到的TCP连接释放报文段的确认。TCP服务器进程收到该报文段后进入关闭状态。而TCP客户进程还要经过2MSL后才能进入关闭状态。MSL:最长报文段寿命,RFC793文档建议为2分钟。也就是说TCP客户进程进入时间等待状态后,还要经过4分钟才能进入关闭状态。MSL值可根据不同使用情况来设置。

为什么不直接进入关闭状态?

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

当客户端和服务端建立起 TCP 连接之后,HTTP 服务器会监听客户端发起的请求,此时客户端会发起 HTTP 请求。

HTTP 请求与 TCP 协议

由客户端发起的 HTTP 请求,服务器收到后会进行回复,回复内容通常包括 HTTP 状态、响应消息等,更具体的会在下一讲 HTTP 协议中进行介绍。

前面说过,目前大多数 HTTP 请求都是基于 TCP 协议。TCP 协议的目的是提供可靠的数据传输,它用来确保可靠传输的途径主要包括两个:

  • 乱序重建:通过对数据包编号来对其排序,从而使得另一端接收数据时,可以重新根据编号还原顺序。

  • 丢包重试:可通过发送方是否得到响应,来检测出丢失的数据并重传这些数据。

通过以上方式,TCP 在传输过程中不会丢失或破坏任何数据,这也是即使出现网络故障也不会损坏文件下载的原因。

因此,目前大多数 HTTP 连接基于 TCP 协议。不过,在 HTTP/3 中底层支撑是 QUIC 协议,该协议使用的是 UDP 协议。因为 UDP 协议丢弃了 TCP 协议中所有的错误检查内容,因此可以更快地进行通信,更常用于直播和在线游戏的应用。

也就是说,HTTP/3 基于 UDP 协议实现了数据的快速传输,同时通过 QUIC 协议保证了数据的可靠传输,最终实现了又快又可靠的通信。

除了以上的内容,其实我们还可以去了解关于 TCP/IP 协议的分层模型、IP 寻址过程,以及 IP 协议又是如何将数据包准确无误地传递这些内容,也需要关注 HTTP/2、HTTP/3、HTTPS 这些协议的设计变更了什么、又解决了什么。

或许这些内容对于大多数前端开发来说,都很少会直接接触。但它就像乘法口诀在高考数学题中的角色,基本上所有题目中都会使用到,但我们很少会认为自己是因为掌握了乘法口诀才能顺利解答题目。

同样的,我们对网络请求的认知也常常忽略了底层 TCP/IP 知识,基本上围绕着“前端发起了请求,后台就能收到”“请求没有按预期结果返回,要么是请求包内容有误,要么后台服务异常”这样的理解去进行处理。

但如果某一天,我们的应用整体请求耗时突然变长,这个过程中前端和后台都没有时间上能关联的发布单,我们到底应该如何进行定位呢?如果我们对一个网络请求的完整流程不够了解,又怎么定位到底是哪个步骤出现问题了呢?甚至我们都不会想到,将 HTTP 切换到 HTTPS 也可能会影响到请求耗时。

下面,我们就来看一下 HTTP 请求在前端开发过程中是如何进行编程实现的,这就不得不提到 Ajax 请求了。

Ajax 请求

Ajax请求这个词会频繁出现在我们的工作对话内容中,但它并不是 JavaScript的规范,而是 Jesse James Garrett 提出的新术语:Asynchronous JavaScript and XML,意思是用 JavaScript执行异步网络请求。

网络请求的发展

对于浏览器来说,网络请求是用来从服务端获取需要的信息,然后解析协议和内容,来进行页面渲染或者是信息获取的过程。

在很久以前,我们的网络请求除了静态资源(HTML/CSS/JavaScript 等)文件的获取,主要用于表单的提交。我们在完成表单内容的填写之后,点击提交按钮,接下来表单开始提交,浏览器就会刷新页面,然后在新页面里告诉你操作是成功了还是失败了。

除了页面跳转刷新会影响用户体验,在表单提交过程中,使用同步请求会阻塞进程。此时用户无法继续操作页面,导致页面呈现假死状态,使得用户体验变得糟糕。

为了避免这种情况,我们开始使用异步请求 + 回调的方式,来进行请求处理,这就是 Ajax

随着时间发展,Ajax 的应用越来越广,如今使用 Ajax 已经是前端开发的基本操作。但 Ajax 是一种解决方案,在前端中的具体实现依赖使用XMLHttpRequest相关 API。页面开始支持局部更新、动态加载,甚至还有懒加载、首屏加载等等,都是以XMLHttpRequest为前提。

XMLHttpRequest

XMLHttpRequest让发送一个 HTTP 请求变得非常容易,我们只需要简单的创建一个请求对象实例,并对它进行操作:

var request = new XMLHttpRequest(); // 新建XMLHttpRequest对象

request.onreadystatechange = function () {
  // 状态发生变化时,函数被回调
  if (request.readyState == 4) {
    // 成功完成
    // 判断响应结果:
    if (request.status == 200) {
      // 成功,通过responseText拿到响应的文本
      console.log(request.responseText);
    } else {
      // 失败,根据响应码判断失败原因:
      console.log(request.status);
    }
  }
};

// 发送请求
// open的参数:
// 一:请求方法,包括get/post等
// 二:请求地址
// 三:表示是否异步请求,若为false则是同步请求,会阻塞进程
request.open("GET", "/api/categories", true);
request.send();

上面是处理一个 HTTP 请求的方法。我们通常会将它封装成一个通用的方法,方便调用。上面例子中我们根据返回的request.status 是否为200来判断是否成功,但实际上200-400(不包括400)的范围,都可以算是成功的,因为其中还包括使用缓存、重定向等情况。

我们将其封装起来,同时使用 ES6 的Promise的方式,我们可以将其变成一个通过Peomise进行异步回调的请求函数:

function Ajax({ method, url, params, contentType }) {
  const xhr = new XMLHttpRequest();
  const formData = new FormData();
  Object.keys(params).forEach((key) => {
    formData.append(key, params[key]);
  });
  return new Promise((resolve, reject) => {
    try {
      xhr.open(method, url, false);
      xhr.setRequestHeader("Content-Type", contentType);
      xhr.onreadystatechange = function () {
        if (xhr.readyState === 4) {
          if (xhr.status >= 200 && xhr.status < 400) {
            // 这里我们使用200-400来判断
            resolve(xhr.responseText);
          } else {
            // 返回请求信息
            reject(xhr);
          }
        }
      };
      xhr.send(formData);
    } catch (err) {
      reject(err);
    }
  });
}

通过这样简单的封装,我们就可以以 Promise 的方式来发起 Ajax请求。

但在具体的项目使用过程中,我们通常还需要考虑更多的问题,比如防抖节流、失败重试、缓存能力、浏览器兼容性、参数处理等。

这就是 HTTP 请求的编程实现。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值