# 网络请求-XHR

# XMLHttpRequest

XMLHttpRequest 是一个内建的浏览器对象,它允许使用 JavaScript 发送 HTTP 请求。

虽然它的名字里面有 “XML” 一词,但它可以操作任何数据,而不仅仅是 XML 格式。我们可以用它来上传/下载文件,跟踪进度等。

# XMLHttpRequest 基础

要发送请求,需要 3 个步骤:

    1. 创建 XMLHttpRequest:

let xhr = new XMLHttpRequest();
    1. 初始化它,通常就在 new XMLHttpRequest 之后:

xhr.open(method, URL, [async, user, password]);
    1. 发送请求。

xhr.send([body]);

随后我们可以监听 xhr 事件以获取响应

xhr.onload = function () {
  console.log(`Loaded: ${xhr.status} ${xhr.response}`);
};
// 跟踪下载进度
xhr.onprogress = function (event) {
  console.log(`Received ${event.loaded} of ${event.total}`);
};
xhr.onerror = function () {
  // 仅在根本无法发出请求时触发
  console.log(`Network Error`);
};

# 响应类型

我们可以使用 xhr.responseType 属性来设置响应格式:

  • ""
  • "text"
  • "arrayBuffer"
  • "blob"
  • "document"
  • "json"

例如,我们以 JSON 格式获取响应:

let xhr = new XMLHttpRequest();

xhr.open("GET", "/article/xmlhttprequest/example/json");

xhr.responseType = "json";

xhr.send();

// 响应为 {"message": "Hello, world!"}
xhr.onload = function () {
  let responseObj = xhr.response;
  alert(responseObj.message); // Hello, world!
};

# readyState

XMLHttpRequest 的状态(state)会随着它的处理进度变化而变化。可以通过 xhr.readyState 来了解当前状态。

规范 中提到的所有状态如下:

UNSENT = 0; // 初始状态
OPENED = 1; // open 被调用
HEADERS_RECEIVED = 2; // 接收到 response header
LOADING = 3; // 响应正在被加载(接收到一个数据包)
DONE = 4; // 请求完成

# abort

我们可以随时终止请求。调用 xhr.abort() 即可:

xhr.abort(); // 终止请求

它会触发 abort 事件,且 xhr.status 变为 0。

# 同步请求

如果在 open 方法中将第三个参数 async 设置为 false,那么请求就会以同步的方式进行。

这看起来好像不错,但是很少使用同步调用,因为它们会阻塞页面内的 JavaScript,直到加载完成。

XMLHttpRequest 允许发送自定义 header,并且可以从响应中读取 header。

// setRequestHeader设置请求Header
xhr.setRequestHeader("Content-Type", "application/json");
// getResponseHeader获取响应Header
xhr.getResponseHeader("Content-Type");
// getAllResponseHeaders返回除 Set-Cookie* 外的所有 response header
let headers = xhr
  .getAllResponseHeaders()
  .split("\r\n")
  .reduce((result, current) => {
    let [name, value] = current.split(": ");
    result[name] = value;
    return result;
  }, {});

# 上传进度

progress 事件仅在下载阶段触发。

如果我们要上传的东西很大,那么我们肯定会对跟踪上传进度感兴趣。但是 xhr.onprogress 在这里并不起作用。

这里有另一个对象,它没有方法,它专门用于跟踪上传事件:xhr.upload。

它会生成事件,类似于 xhr,但是 xhr.upload 仅在上传时触发它们

<input type="file" onchange="upload(this.files[0])" />

<script>
  function upload(file) {
    let xhr = new XMLHttpRequest();

    // 跟踪上传进度
    xhr.upload.onprogress = function (event) {
      console.log(`Uploaded ${event.loaded} of ${event.total}`);
    };

    // 跟踪完成:无论成功与否
    xhr.onloadend = function () {
      if (xhr.status == 200) {
        console.log("success");
      } else {
        console.log("error " + this.status);
      }
    };

    xhr.open("POST", "/article/xmlhttprequest/post/upload");
    xhr.send(file);
  }
</script>

# 凭据

就像 fetch 一样,默认情况下不会将 cookie 和 HTTP 授权发送到其他域。要启用它们,可以将 xhr.withCredentials 设置为 true:

let xhr = new XMLHttpRequest();
xhr.withCredentials = true;

# 总结

// 允许携带凭据信息
xhr.withCredentials = true;
// 跟踪上传进度
xhr.upload.onprogress = function (event) {
  console.log(`Uploaded ${event.loaded} of ${event.total}`);
};
// 跟踪下载进度
xhr.onprogress = function (event) {
  console.log(`Received ${event.loaded} of ${event.total}`);
};

# 可恢复的文件上传

连接断开后如何恢复上传?这里没有对此的内建选项,但是我们有实现它的一些方式。

# 不太实用的进度事件

我们有 xhr.upload.onprogress 来跟踪上传进度。

不幸的是,它不会帮助我们在此处恢复上传,因为它会在数据 被发送 时触发,但是服务器是否接收到了?浏览器并不知道。

这就是为什么此事件仅适用于显示一个好看的进度条。

要恢复上传,我们需要 确切地 知道服务器接收的字节数。而且只有服务器能告诉我们,因此,我们将发出一个额外的请求。

# 自定义算法实现

首先,创建一个文件 id,以唯一地标识我们要上传的文件

let fileId = file.name + "-" + file.size + "-" + +file.lastModifiedDate;

向服务器发送一个请求,询问它已经有了多少字节,像这样

let response = await fetch("status", {
  headers: {
    "X-File-Id": fileId
  }
});

// 服务器已有的字节数
let startByte = +(await response.text());

然后,我们可以使用 Blob 和 slice 方法来发送从 startByte 开始的文件

xhr.open("POST", "upload", true);
// 文件 id,以便服务器知道我们要恢复的是哪个文件
xhr.setRequestHeader("X-File-Id", fileId);
// 发送我们要从哪个字节开始恢复,因此服务器知道我们正在恢复
xhr.setRequestHeader("X-Start-Byte", startByte);

// ...
// 文件可以是来自 input.files[0],或者另一个源
xhr.send(file.slice(startByte));

# 轮询

轮询是与服务器保持持久连接的最简单的方式,它很容易实现,在很多场景下也很好用。

# 常规轮询

从服务器获取新信息的最简单的方式是定期轮询。例如,每 10 秒一次。

这可行,但是也有些缺点:

  • 消息传递的延迟最多为 10 秒(两个请求之间)。
  • 即使没有消息,服务器也会每隔 10 秒被请求轰炸一次,就性能而言,这是一个很大的负担。

# 长轮询

所谓“长轮询”是轮询服务器的一种更好的方式。它也很容易实现,并且可以无延迟地传递消息。

其关键就在于和服务器之间建立一个挂起的链接。如果连接丢失,可能是因为网络错误,浏览器会立即发送一个新请求。

实现长轮询的客户端 subscribe 函数的示例代码:

async function subscribe() {
  let response = await fetch("/subscribe"); // 服务器在需要的时候返回响应

  if (response.status == 502) {
    // 状态 502 是连接超时错误 让我们重新连接
    await subscribe();
  } else if (response.status != 200) {
    // 一个 error —— 让我们显示它
    showMessage(response.statusText);
    await new Promise((resolve) => setTimeout(resolve, 1000));
    await subscribe(); // 一秒后重新连接
  } else {
    // 获取到正常数据 —— 让我们显示它
    let message = await response.text();
    showMessage(message);
    await subscribe(); // 再次调用 subscribe() 以获取下一条消息
  }
}

subscribe();

# 使用场景

在消息很少的情况下,长轮询很有效。如果消息比较频繁,首选另一种方法,例如:Websocket 或 Server Sent Events。