Appearance
06一个网络请求是怎么进行的?
从这一讲开始,我会介绍浏览器相关的内容,比如浏览器中的网络请求过程、HTTP 协议在浏览器中的应用、浏览器中页面的渲染过程,等等。
我们知道,浏览器的主要功能是展示网页资源,包括向服务器发起请求、从服务器获取相关资源,并将网页显示在浏览器窗口中。
当我们去面试的时候,常常会被问到一个问题:在浏览器里面输入 url,按下回车键,会发生什么?
这个问题涉及浏览器中的运行机制和页面加载流程,并且这些内容也都穿插在我们日常开发中,包括前后端联调、对网页进行性能优化等。
今天我会先跟你聊一聊浏览器中网络请求是怎么进行的,这样你对整个网页渲染会有个更好的认识。
页面的请求过程
当我们打开某个网站的页面,浏览器就会发起网络请求获取该页面的资源,我们也可以从控制台看到以下的请求信息:
在 Network 面板里,我们能看到所有浏览器发起的网络请求,包括页面、图片、CSS 文件、XHR 请求等,还能看到请求的状态(200 成功、404 找不到、缓存、重定向等等)、耗时、请求头和内容、返回头和内容等。
图中第一个就是网站页面的请求,返回<html>
页面。
接下来,浏览器会加载页面,同时页面中涉及的外部资源也会根据需要,在特定的时机触发请求下载,包括我们看到的 PNG 图片、JavaScript 文件(这里没有 CSS 样式,是因为样式被直出在<html>
页面内容里了)。
回到前面的问题,实际上当我们在浏览器输入网页地址,按下回车键后,浏览器的处理过程如下:
DNS 域名解析(此处涉及 DNS 的寻址过程),找到网页的存放服务器;
浏览器与服务器建立 TCP 连接;
浏览器发起 HTTP 请求;
服务器响应 HTTP 请求,返回该页面的 HTML 内容;
浏览器解析 HTML 代码,并请求 HTML 代码中的资源(如 JavaScript、CSS、图片等,此处可能涉及 HTTP 缓存);
浏览器对页面进行渲染呈现给用户(此处涉及浏览器的渲染原理)。
HTTP 缓存和浏览器渲染原理会分别在第 7 讲和第 8 讲中讲述,今天我们主要围绕 HTTP 请求相关展开。
首先我们来看 DNS 解析过程。
DNS 解析
DNS 的全称是 Domain Name System,又称域名系统,它负责把www.qq.com
这样的域名地址翻译成一个 IP(比如14.18.180.206
),而客户端与服务器建立 TCP 连接需要通过 IP 通信。
让客户端和服务器连接并不是靠域名进行,在网络中每个终端之间实现连接和通信是通过一个唯一的 IP 地址来完成。在建立 TCP 连接前,我们需要找到建立连接的服务器,DNS 的解析过程可以让用户通过域名找到存放文件的服务器。
DNS 解析过程会进行递归查询,分别依次尝试从以下途径,按顺序地获取该域名对应的 IP 地址。
浏览器缓存
系统缓存(用户操作系统 Hosts 文件 DNS 缓存)
路由器缓存
互联网服务提供商 DNS 缓存(联通、移动、电信等互联网服务提供商的 DNS 缓存服务器)
根域名服务器
顶级域名服务器
主域名服务器
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 接口,就可以进行跨源通信。
DNS 解析完成后,浏览器获得了服务端的 IP 地址,接下来便可以向服务端发起 HTTP 请求。目前大多数 HTTP 请求都建立在 TCP 连接上,因此客户端和服务端会先建立起 TCP 连接。
TCP 连接的建立
TCP 连接的建立过程比较偏通信底层,在前端日常开发过程中不容易接触到。但有时候我们需要优化应用的加载耗时、请求耗时或是定位一些偏底层的问题(请求异常、HTTP 连接无法建立等),都会或多或少依赖这些偏底层的知识。
另外,从面试的角度看,我们需要掌握 TCP/UDP 的区别、TCP 的三次握手和四次挥手内容。
TCP 协议提供可靠传输服务,UDP 协议则可以更快地进行通信;
三次握手:指 TCP 连接的建立过程,该过程中客户端和服务端总共需要发送三个包,从而确认连接存在。
四次挥手:指 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 请求变得非常容易,我们只需要简单的创建一个请求对象实例,并对它进行操作:
java
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
进行异步回调的请求函数:
java
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 请求的编程实现。
小结
对前端开发来说,网络请求是开发过程中最基础却又常常容易被忽略的部分。很多人总认为网络请求不过是"向后台发请求,后台进行响应"这样简单的逻辑,而忽略了它在用户体验中的重要性。
实际上,在前端性能优化中,网络请求的优化往往占据了很大一部分,包括首屏直出、分包加载、数据分片拉取、使用缓存、预加载等,都是通过合理地减少网络请求内容、减少网络请求的等待耗时等方式,达到很不错的优化效果。
那么,学完本讲页面的请求过程之后,你认为可以提升页面加载速度的优化方式都有哪些呢?欢迎在留言区分享你的经验。