Skip to content

Fetch

JavaScript 可以将网络请求发送到服务器,并在需要时加载新信息。 对于来自 JavaScript 的网络请求,有一个总称术语 “AJAX”(Asynchronous JavaScript And XML 的简称)。 fetch() 方法是一种现代通用的方法,那么我们就从它开始吧。旧版本的浏览器不支持它(可以 polyfill),但是它在现代浏览器中的支持情况很好。 基本语法:let promise = fetch(url, [options])

  • url —— 要访问的 URL。
  • options —— 可选参数:method,header 等。 没有 options,这就是一个简单的 GET 请求,下载 url 的内容。 第一阶段,当服务器发送了响应头(response header),fetch 返回的 promise 就使用内建的 Response class 对象来对响应头进行解析。 在这个阶段,我们可以通过检查响应头,来检查 HTTP 状态以确定请求是否成功,当前还没有响应体(response body)。 如果 fetch 无法建立一个 HTTP 请求,例如网络问题,亦或是请求的网址不存在,那么 promise 就会 reject。异常的 HTTP 状态,例如 404 或 500,不会导致出现 error。
  • status —— HTTP 状态码,例如 200。
  • ok —— 布尔值,如果 HTTP 状态码为 200-299,则为 true
JavaScript
let response = await fetch(url);

if (response.ok) { // 如果 HTTP 状态码为 200-299
  // 获取 response body(此方法会在下面解释)
  let json = await response.json();
} else {
  alert("HTTP-Error: " + response.status);
}

第二阶段,为了获取 response body,我们需要使用一个其他的方法调用。Response 提供了多种基于 promise 的方法,来以不同的格式访问 body: - response.text() —— 读取 response,并以文本形式返回 response, - response.json() —— 将 response 解析为 JSON 格式, - response.formData() —— 以 FormData 对象(在 下一章 有解释)的形式返回 response, - response.blob() —— 以 Blob(具有类型的二进制数据)形式返回 response, - response.arrayBuffer() —— 以 ArrayBuffer(低级别的二进制数据)形式返回 response, - 另外,response.body 是 ReadableStream 对象,它允许你逐块读取 body,我们稍后会用一个例子解释它。 我们从 GitHub 获取最新 commits 的 JSON 对象:

JavaScript
let url = 'https://api.github.com/repos/javascript-tutorial/en.javascript.info/commits';
let response = await fetch(url);

let commits = await response.json(); // 读取 response body,并将其解析为 JSON 格式

alert(commits[0].author.login);

也可以使用纯 promise 语法,不使用 await

JavaScript
fetch('https://api.github.com/repos/javascript-tutorial/en.javascript.info/commits')
  .then(response => response.json())
  .then(commits => alert(commits[0].author.login));

要获取响应文本,可以使用 await response.text() 代替 .json()

JavaScript
let response = await fetch('https://api.github.com/repos/javascript-tutorial/en.javascript.info/commits');

let text = await response.text(); // 将 response body 读取为文本

alert(text.slice(0, 80) + '...');

作为一个读取为二进制格式的演示示例,让我们 fetch 并显示一张 “fetch” 规范 中的图片(Blob 操作的有关内容请见 Blob

JavaScript
let response = await fetch('/article/fetch/logo-fetch.svg');

let blob = await response.blob(); // 下载为 Blob 对象

// 为其创建一个 <img>
let img = document.createElement('img');
img.style = 'position:fixed;top:10px;left:10px;width:100px';
document.body.append(img);

// 显示它
img.src = URL.createObjectURL(blob);

setTimeout(() => { // 3 秒后将其隐藏
  img.remove();
  URL.revokeObjectURL(img.src);
}, 3000);

我们只能选择一种读取 body 的方法。如果我们已经使用了 response.text() 方法来获取 response,那么如果再用 response.json(),则不会生效,因为 body 内容已经被处理过了。

  1. Response header
    1. Response header 位于 response.headers 中的一个类似于 Map 的 header 对象。
    2. 它不是真正的 Map,但是它具有类似的方法,我们可以按名称(name)获取各个 header,或迭代它们:
JavaScript
let response = await fetch('https://api.github.com/repos/javascript-tutorial/en.javascript.info/commits');

// 获取一个 header
alert(response.headers.get('Content-Type')); // application/json; charset=utf-8

// 迭代所有 header
for (let [key, value] of response.headers) {
  alert(`${key} = ${value}`);
}
  1. Request header
    1. 要在 fetch 中设置 request header,我们可以使用 headers 选项。它有一个带有输出 header 的对象,
JavaScript
let response = fetch(protectedUrl, {
  headers: {
    Authentication: 'secret'
  }
});

……但是有一些我们无法设置的 header(详见 forbidden HTTP headers): - Accept-CharsetAccept-Encoding - Access-Control-Request-Headers - Access-Control-Request-Method - Connection - Content-Length - CookieCookie2 - Date - DNT - Expect - Host - Keep-Alive - Origin - Referer - TE - Trailer - Transfer-Encoding - Upgrade - Via - Proxy-* - Sec-* 3. POST 请求 1. 要创建一个 POST 请求,或者其他方法的请求,我们需要使用 fetch 选项: - method —— HTTP 方法,例如 POST, - body —— request body,其中之一: - 字符串(例如 JSON 编码的), - FormData 对象,以 multipart/form-data 形式发送数据, - Blob/BufferSource 发送二进制数据, - URLSearchParams,以 x-www-form-urlencoded 编码形式发送数据,很少使用。

JavaScript
let user = {
  name: 'John',
  surname: 'Smith'
};

let response = await fetch('/article/fetch/post/user', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json;charset=utf-8'
  },
  body: JSON.stringify(user)
});

let result = await response.json();
alert(result.message);
  1. 发送图片
    1. 我们同样可以使用 Blob 或 BufferSource 对象通过 fetch 提交二进制数据。
html
<body style="margin:0">
  <canvas id="canvasElem" width="100" height="80" style="border:1px solid"></canvas>

  <input type="button" value="Submit" onclick="submit()">

  <script>
    canvasElem.onmousemove = function(e) {
      let ctx = canvasElem.getContext('2d');
      ctx.lineTo(e.clientX, e.clientY);
      ctx.stroke();
    };

    async function submit() {
      let blob = await new Promise(resolve => canvasElem.toBlob(resolve, 'image/png'));
      let response = await fetch('/article/fetch/post/image', {
        method: 'POST',
        body: blob
      });

      // 服务器给出确认信息和图片大小作为响应
      let result = await response.json();
      alert(result.message);
    }

  </script>
</body>

这里我们没有手动设置 Content-Type header,因为 Blob 对象具有内建的类型(这里是 image/png,通过 toBlob 生成的)。对于 Blob 对象,这个类型就变成了 Content-Type 的值。 可以在不使用 async/await 的情况下重写 submit() 函数:

JavaScript
function submit() {
  canvasElem.toBlob(function(blob) {
    fetch('/article/fetch/post/image', {
      method: 'POST',
      body: blob
    })
      .then(response => response.json())
      .then(result => alert(JSON.stringify(result, null, 2)))
  }, 'image/png');
}

FormData

  1. 构造函数
JavaScript
let formData = new FormData([form]);

FormData 的特殊之处在于网络方法(network methods),例如 fetch 可以接受一个 FormData 对象作为 body。它会被编码并发送出去,带有 Content-Type: multipart/form-data

  1. 发送一个简单的表单
html
<form id="formElem">
  <input type="text" name="name" value="John">
  <input type="text" name="surname" value="Smith">
  <input type="submit">
</form>

<script>
  formElem.onsubmit = async (e) => {
    e.preventDefault();

    let response = await fetch('/article/formdata/post/user', {
      method: 'POST',
      body: new FormData(formElem)
    });

    let result = await response.json();

    alert(result.message);
  };
</script>
  1. FormData 方法
    1. 可以使用以下方法修改 FormData 中的字段:
      • formData.append(name, value) —— 添加具有给定 name 和 value 的表单字段,
      • formData.append(name, blob, fileName) —— 添加一个字段,就像它是 <input type="file">,第三个参数 fileName 设置文件名(而不是表单字段名),因为它是用户文件系统中文件的名称,
      • formData.delete(name) —— 移除带有给定 name 的字段,
      • formData.get(name) —— 获取带有给定 name 的字段值,
      • formData.has(name) —— 如果存在带有给定 name 的字段,则返回 true,否则返回 false
    2. 一个表单可以包含多个具有相同 name 的字段,因此,多次调用 append 将会添加多个具有相同名称的字段。
    3. 还有一个 set 方法,语法与 append 相同。不同之处在于 .set 移除所有具有给定 name 的字段,然后附加一个新字段。因此,它确保了只有一个具有这种 name 的字段,其他的和 append 一样:
      • formData.set(name, value)
      • formData.set(name, blob, fileName)
JavaScript
let formData = new FormData();
formData.append('key1', 'value1');
formData.append('key2', 'value2');

// 列出 key/value 对
for(let [name, value] of formData) {
  alert(`${name} = ${value}`); // key1 = value1,然后是 key2 = value2
}
  1. 发送带有文件的表单
    1. 表单始终以 Content-Type: multipart/form-data 来发送数据,这个编码允许发送文件。因此 <input type="file"> 字段也能被发送,类似于普通的表单提交。
html
<form id="formElem">
  <input type="text" name="firstName" value="John">
  Picture: <input type="file" name="picture" accept="image/*">
  <input type="submit">
</form>

<script>
  formElem.onsubmit = async (e) => {
    e.preventDefault();

    let response = await fetch('/article/formdata/post/user-avatar', {
      method: 'POST',
      body: new FormData(formElem)
    });

    let result = await response.json();

    alert(result.message);
  };
</script>
  1. 发送具有 Blob 数据的表单
    1. 以 Blob 发送一个动态生成的二进制数据,例如图片,是很简单的。我们可以直接将其作为 fetch 参数的 body
    2. 通常更方便的发送图片的方式不是单独发送,而是将其作为表单的一部分,并带有附加字段(例如 “name” 和其他 metadata)一起发送。
    3. 并且,服务器通常更适合接收多部分编码的表单(multipart-encoded form),而不是原始的二进制数据。
    4. 这个例子使用 FormData 将一个来自 <canvas> 的图片和一些其他字段一起作为一个表单提交:
html
<body style="margin:0">
  <canvas id="canvasElem" width="100" height="80" style="border:1px solid"></canvas>

  <input type="button" value="Submit" onclick="submit()">

  <script>
    canvasElem.onmousemove = function(e) {
      let ctx = canvasElem.getContext('2d');
      ctx.lineTo(e.clientX, e.clientY);
      ctx.stroke();
    };

    async function submit() {
      let imageBlob = await new Promise(resolve => canvasElem.toBlob(resolve, 'image/png'));

      let formData = new FormData();
      formData.append("firstName", "John");
      formData.append("image", imageBlob, "image.png");

      let response = await fetch('/article/formdata/post/image-form', {
        method: 'POST',
        body: formData
      });
      let result = await response.json();
      alert(result.message);
    }

  </script>
</body>

请注意图片 Blob 是如何添加的:

JavaScript
formData.append("image", imageBlob, "image.png");

Fetch:下载进度

fetch 方法允许去跟踪 下载 进度。 到目前为止,fetch 方法无法跟踪 上传 进度。 要跟踪下载进度,我们可以使用 response.body 属性。它是 ReadableStream —— 一个特殊的对象,它可以逐块(chunk)提供 body。在 Streams API 规范中有对 ReadableStream 的详细描述。 与 response.text()response.json() 和其他方法不同,response.body 给予了对进度读取的完全控制,我们可以随时计算下载了多少。

JavaScript
// 代替 response.json() 以及其他方法
const reader = response.body.getReader();

// 在 body 下载时,一直为无限循环
while(true) {
  // 当最后一块下载完成时,done 值为 true
  // value 是块字节的 Uint8Array
  const {done, value} = await reader.read();

  if (done) {
    break;
  }

  console.log(`Received ${value.length} bytes`)
}

await reader.read() 调用的结果是一个具有两个属性的对象:

  • done —— 当读取完成时为 true,否则为 false
  • value —— 字节的类型化数组:Uint8Array。 我们在循环中接收响应块(response chunk),直到加载完成,也就是:直到 done 为 true。 要将进度打印出来,我们只需要将每个接收到的片段 value 的长度(length)加到 counter 即可。
JavaScript
// Step 1:启动 fetch,并获得一个 reader
let response = await fetch('https://api.github.com/repos/javascript-tutorial/en.javascript.info/commits?per_page=100');

const reader = response.body.getReader();

// Step 2:获得总长度(length)
const contentLength = +response.headers.get('Content-Length');

// Step 3:读取数据
let receivedLength = 0; // 当前接收到了这么多字节
let chunks = []; // 接收到的二进制块的数组(包括 body)
while(true) {
  const {done, value} = await reader.read();

  if (done) {
    break;
  }

  chunks.push(value);
  receivedLength += value.length;

  console.log(`Received ${receivedLength} of ${contentLength}`)
}

// Step 4:将块连接到单个 Uint8Array
let chunksAll = new Uint8Array(receivedLength); // (4.1)
let position = 0;
for(let chunk of chunks) {
  chunksAll.set(chunk, position); // (4.2)
  position += chunk.length;
}

// Step 5:解码成字符串
let result = new TextDecoder("utf-8").decode(chunksAll);

// 我们完成啦!
let commits = JSON.parse(result);
alert(commits[0].author.login);

解释下这个过程:

  1. 我们像往常一样执行 fetch,但不是调用 response.json(),而是获得了一个流读取器(stream reader)response.body.getReader()。 请注意,我们不能同时使用这两种方法来读取相同的响应。要么使用流读取器,要么使用 reponse 方法来获取结果。
  2. 在读取数据之前,我们可以从 Content-Length header 中得到完整的响应长度。 跨源请求中可能不存在这个 header(请参见 Fetch:跨源请求),并且从技术上讲,服务器可以不设置它。但是通常情况下它都会在那里。
  3. 调用 await reader.read(),直到它完成。 我们将响应块收集到数组 chunks 中。这很重要,因为在使用完(consumed)响应后,我们将无法使用 response.json() 或者其他方式(你可以试试,将会出现 error)去“重新读取”它。
  4. 最后,我们有了一个 chunks —— 一个 Uint8Array 字节块数组。我们需要将这些块合并成一个结果。但不幸的是,没有单个方法可以将它们串联起来,所以这里需要一些代码来实现:
    1. 我们创建 chunksAll = new Uint8Array(receivedLength) —— 一个具有所有数据块合并后的长度的同类型数组。
    2. 然后使用 .set(chunk, position) 方法,从数组中一个个地复制这些 chunk
  5. 我们的结果现在储存在 chunksAll 中。但它是一个字节数组,不是字符串。 要创建一个字符串,我们需要解析这些字节。可以使用内建的 TextDecoder 对象完成。然后,我们可以 JSON.parse 它,如果有必要的话。

Fetch:中止(Abort)

fetch 返回一个 promise。JavaScript 通常并没有“中止” promise 的概念。那么我们怎样才能取消一个正在执行的 fetch 呢?例如,如果用户在我们网站上的操作表明不再需要某个执行中的 fetch。 为此有一个特殊的内建对象:AbortController。它不仅可以中止 fetch,还可以中止其他异步任务。

  1. AbortController 对象
    1. 创建一个控制器(controller):let controller = new AbortController();
    2. 控制器是一个极其简单的对象。
      • 它具有单个方法 abort()
      • 和单个属性 signal,我们可以在这个属性上设置事件监听器。
    3. 当 abort() 被调用时:
      • controller.signal 就会触发 abort 事件。
      • controller.signal.aborted 属性变为 true
    4. 通常,我们需要处理两部分:
      1. 一部分是通过在 controller.signal 上添加一个监听器,来执行可取消操作。
      2. 另一部分是触发取消:在需要的时候调用 controller.abort()
JavaScript
let controller = new AbortController();
let signal = controller.signal;

// 执行可取消操作部分
// 获取 "signal" 对象,
// 并将监听器设置为在 controller.abort() 被调用时触发
signal.addEventListener('abort', () => alert("abort!"));

// 另一部分,取消(在之后的任何时候):
controller.abort(); // 中止!

// 事件触发,signal.aborted 变为 true
alert(signal.aborted); // true

我们可以自己在代码中实现相同类型的事件监听,而不需要 AbortController 对象。但是有价值的是,fetch 知道如何与 AbortController 对象一起工作。它们是集成在一起的。

  1. 与 fetch 一起使用
    1. 为了能够取消 fetch,请将 AbortController 的 signal 属性作为 fetch 的一个可选参数(option)进行传递:
JavaScript
let controller = new AbortController();
fetch(url, {
  signal: controller.signal
});

fetch 方法知道如何与 AbortController 一起工作。它会监听 signal 上的 abort 事件。现在,想要中止 fetch,调用 controller.abort() 即可。 3. AbortController 是可伸缩的 1. AbortController 是可伸缩的。它允许一次取消多个 fetch。

JavaScript
let urls = [...];
let controller = new AbortController();

let ourJob = new Promise((resolve, reject) => { // 我们的任务
  ...
  controller.signal.addEventListener('abort', reject);
});

let fetchJobs = urls.map(url => fetch(url, { // fetches
  signal: controller.signal
}));

// 等待完成我们的任务和所有 fetch
let results = await Promise.all([...fetchJobs, ourJob]);

// controller.abort() 被从任何地方调用,
// 它都将中止所有 fetch 和 ourJob

Fetch:跨源请求

如果我们向另一个网站发送 fetch 请求,则该请求可能会失败。 这里的核心概念是 源(origin)—— 域(domain)/端口(port)/协议(protocol)的组合。 跨源请求 —— 那些发送到其他域(即使是子域)、协议或端口的请求 —— 需要来自远程端的特殊 header。 这个策略被称为 “CORS”:跨源资源共享(Cross-Origin Resource Sharing)。

  1. 为什么需要 CORS?跨源请求简史

    1. CORS 的存在是为了保护互联网免受黑客攻击。
    2. 使用表单
      1. 其中一种和其他服务器通信的方法是在那里提交一个 <form>。人们将它提交到 <iframe>,只是为了停留在当前页面。
    html
    	<!-- 表单目标 -->
    	<iframe name="iframe"></iframe>
    	
    	<!-- 表单可以由 JavaScript 动态生成并提交 -->
    	<form target="iframe" method="POST" action="http://another.com/…">
    	  ...
    	</form>

    即使没有网络方法,也可以向其他网站发出 GET/POST 请求,因为表单可以将数据发送到任何地方。但是由于禁止从其他网站访问 <iframe> 中的内容,因此就无法读取响应。

    1. 使用 script
      1. 另一个技巧是使用 script 标签。script 可以具有任何域的 src,例如 <script src="http://another.com/…">。也可以执行来自任何网站的 script
      2. 如果一个网站,例如 another.com 试图公开这种访问方式的数据,则会使用所谓的 “JSONP (JSON with padding)” 协议。
      3. 首先,我们先声明一个全局函数来接收数据,例如 gotWeather
      JavaScript
      	// 1. 声明处理天气数据的函数
      	function gotWeather({ temperature, humidity }) {
      	  alert(`temperature: ${temperature}, humidity: ${humidity}`);
      	}
      1. 然后我们创建一个特性(attribute)为 src="http://another.com/weather.json?callback=gotWeather" 的 <script> 标签,使用我们的函数名作为它的 callback URL-参数。
        JavaScript
        let script = document.createElement('script');
        script.src = `http://another.com/weather.json?callback=gotWeather`;
        document.body.append(script);
      2. 远程服务器 another.com 动态生成一个脚本,该脚本调用 gotWeather(...),发送它想让我们接收的数据。
      JavaScript
      // 我们期望来自服务器的回答看起来像这样:
      gotWeather({
        temperature: 25,
        humidity: 78
      });
      1. 当远程脚本加载并执行时,gotWeather 函数将运行,并且因为它是我们的函数,我们就有了需要的数据。
  2. 安全请求

    1. 有两种类型的跨源请求:
      1. 安全请求
      2. 其他请求
    2. 如果一个请求满足下面这两个条件,则该请求是安全的:
      1. 安全的方法:GET,POST 或 HEAD
      2. 安全的 header —— 仅允许自定义下列 header:
        • Accept
        • Accept-Language
        • Content-Language
        • Content-Type 的值为 application/x-www-form-urlencodedmultipart/form-data 或 text/plain
    3. 本质区别在于,可以使用 <form> 或 <script> 进行安全请求,而无需任何其他特殊方法。
  3. 用于安全请求的 CORS

    1. 如果一个请求是跨源的,浏览器始终会向其添加 Origin header。
JavaScript
GET /request
Host: anywhere.com
Origin: https://javascript.info
...

正如你所看到的,Origin 包含了确切的源(domain/protocol/port),没有路径(path)。服务器可以检查 Origin,如果同意接受这样的请求,就会在响应中添加一个特殊的 header Access-Control-Allow-Origin。该 header 包含了允许的源(在我们的示例中是 https://javascript.info),或者一个星号 *。然后响应成功,否则报错。

  1. Response header
    1. 对于跨源请求,默认情况下,JavaScript 只能访问“安全的” response header:
      • Cache-Control
      • Content-Language
      • Content-Type
      • Expires
      • Last-Modified
      • Pragma
      • 访问任何其他 response header 都将导致 error。
    2. 请注意:列表中没有 Content-Length header!
  2. “非安全”请求
    1. 我们可以使用任何 HTTP 方法:不仅仅是 GET/POST,也可以是 PATCHDELETE 及其他。
    2. 预检请求使用 OPTIONS 方法,它没有 body,但是有三个 header:
      • Access-Control-Request-Method header 带有非安全请求的方法。
      • Access-Control-Request-Headers header 提供一个以逗号分隔的非安全 HTTP-header 列表。
      • Origin header 说明请求来自哪里。(如 https://javascript.info
    3. 如果服务器同意处理请求,那么它会进行响应,此响应的状态码应该为 200,没有 body,具有 header:
      • Access-Control-Allow-Origin 必须为 * 或进行请求的源(例如 https://javascript.info)才能允许此请求。
      • Access-Control-Allow-Methods 必须具有允许的方法。
      • Access-Control-Allow-Headers 必须具有一个允许的 header 列表。
      • 另外,header Access-Control-Max-Age 可以指定缓存此权限的秒数。因此,浏览器不是必须为满足给定权限的后续请求发送预检。
    4. 在一个跨源 PATCH 请求的例子中一步一步地看它是如何工作的(此方法经常被用于更新数据):
JavaScript
let response = await fetch('https://site.com/service.json', {
  method: 'PATCH',
  headers: {
    'Content-Type': 'application/json',
    'API-Key': 'secret'
  }
});

有三个理由解释为什么它不是一个安全请求(其实一个就够了): - 方法 PATCH - Content-Type 不是这三个中之一:application/x-www-form-urlencodedmultipart/form-datatext/plain。 - “非安全” API-Key header。 6. Step 1 预检请求(preflight request) 1. 在发送我们的请求前,浏览器会自己发送如下所示的预检请求:

JavaScript
OPTIONS /service.json
Host: site.com
Origin: https://javascript.info
Access-Control-Request-Method: PATCH
Access-Control-Request-Headers: Content-Type,API-Key
  • 方法:OPTIONS
  • 路径 —— 与主请求完全相同:/service.json
  • 特殊跨源头:
    • Origin —— 来源。
    • Access-Control-Request-Method —— 请求方法。
    • Access-Control-Request-Headers —— 以逗号分隔的“非安全” header 列表。
  1. Step 2 预检响应(preflight response)
    1. 服务应响应状态 200 和 header:
      • Access-Control-Allow-Origin: https://javascript.info
      • Access-Control-Allow-Methods: PATCH
      • Access-Control-Allow-Headers: Content-Type,API-Key
JavaScript
200 OK
Access-Control-Allow-Origin: https://javascript.info
Access-Control-Allow-Methods: PUT,PATCH,DELETE
Access-Control-Allow-Headers: API-Key,Content-Type,If-Modified-Since,Cache-Control
Access-Control-Max-Age: 86400
  1. Step 3 实际请求(actual request)
    1. 主请求具有 Origin header(因为它是跨源的):
JavaScript
PATCH /service.json
Host: site.com
Content-Type: application/json
API-Key: secret
Origin: https://javascript.info
  1. Step 4 实际响应(actual response)
    1. 服务器不应该忘记在主响应中添加 Access-Control-Allow-Origin。成功的预检并不能免除此要求:
JavaScript
Access-Control-Allow-Origin: https://javascript.info
  1. 凭据(Credentials) 1. 默认情况下,由 JavaScript 代码发起的跨源请求不会带来任何凭据(cookies 或者 HTTP 认证(HTTP authentication))。 2. 这对于 HTTP 请求来说并不常见。通常,对 http://site.com 的请求附带有该域的所有 cookie。但是由 JavaScript 方法发出的跨源请求是个例外。

Fetch API

JavaScript
let promise = fetch(url, {
  method: "GET", // POST,PUT,DELETE,等。
  headers: {
    // 内容类型 header 值通常是自动设置的
    // 取决于 request body
    "Content-Type": "text/plain;charset=UTF-8"
  },
  body: undefined // string,FormData,Blob,BufferSource,或 URLSearchParams
  referrer: "about:client", // 或 "" 以不发送 Referer header,
  // 或者是当前源的 url
  referrerPolicy: "no-referrer-when-downgrade", // no-referrer,origin,same-origin...
  mode: "cors", // same-origin,no-cors
  credentials: "same-origin", // omit,include
  cache: "default", // no-store,reload,no-cache,force-cache,或 only-if-cached
  redirect: "follow", // manual,error
  integrity: "", // 一个 hash,像 "sha256-abcdef1234567890"
  keepalive: false, // true
  signal: undefined, // AbortController 来中止请求
  window: window // null
});
  1. referrer,referrerPolicy
    1. 通常来说,这个 header 是被自动设置的,并包含了发出请求的页面的 url。在大多数情况下,它一点也不重要,但有时出于安全考虑,删除或缩短它是有意义的。
    2. referrer 选项允许设置任何 Referer(在当前域的),或者移除它。
    JavaScript
    fetch('/page', {
      referrer: "" // 没有 Referer header
    });

referrerPolicy 选项为 Referer 设置一般的规则。 3. 请求分为 3 种类型: 1. 同源请求。 2. 跨源请求。 3. 从 HTTPS 到 HTTP 的请求 (从安全协议到不安全协议)。 4. 与 referrer 选项允许设置确切的 Referer 值不同,referrerPolicy 告诉浏览器针对各个请求类型的一般的规则。 5. 可能的值在 Referrer Policy 规范中有详细描述: - "no-referrer-when-downgrade" —— 默认值:除非我们从 HTTPS 发送请求到 HTTP(到安全性较低的协议),否则始终会发送完整的 Referer。 - "no-referrer" —— 从不发送 Referer。 - "origin" —— 只发送在 Referer 中的域,而不是完整的页面 URL,例如,只发送 http://site.com 而不是 http://site.com/path。 - "origin-when-cross-origin" —— 发送完整的 Referer 到相同的源,但对于跨源请求,只发送域部分(同上)。 - "same-origin" —— 发送完整的 Referer 到相同的源,但对于跨源请求,不发送 Referer。 - "strict-origin" —— 只发送域,对于 HTTPS→HTTP 请求,则不发送 Referer。 - "strict-origin-when-cross-origin" —— 对于同源情况下则发送完整的 Referer,对于跨源情况下,则只发送域,如果是 HTTPS→HTTP 请求,则什么都不发送。 - "unsafe-url" —— 在 Referer 中始终发送完整的 url,即使是 HTTPS→HTTP 请求。 6. Referrer policy 不仅适用于 fetch 1. 在 规范 中描述的 referrer policy,不仅适用于 fetch,它还具有全局性。 2. 特别是,可以使用 Referrer-Policy HTTP header,或者为每个链接设置 <a rel="noreferrer">,来为整个页面设置默认策略(policy)。

同源跨源HTTPS→HTTP
"no-referrer"---
"no-referrer-when-downgrade" 或 ""(默认)完整的 url完整的 url-
"origin"仅域仅域仅域
"origin-when-cross-origin"完整的 url仅域仅域
"same-origin"完整的 url--
"strict-origin"仅域仅域-
"strict-origin-when-cross-origin"完整的 url仅域-
"unsafe-url"完整的 url完整的 url完整的 url
  1. mode
    1. mode 选项是一种安全措施,可以防止偶发的跨源请求:
      • "cors" —— 默认值,允许跨源请求,如 Fetch:跨源请求 一章所述,
      • "same-origin" —— 禁止跨源请求,
      • "no-cors" —— 只允许安全的跨源请求。
  2. credentials
    1. credentials 选项指定 fetch 是否应该随请求发送 cookie 和 HTTP-Authorization header。
      • "same-origin" —— 默认值,对于跨源请求不发送,
      • "include" —— 总是发送,需要来自跨源服务器的 Access-Control-Allow-Credentials,才能使 JavaScript 能够访问响应,详细内容在 Fetch:跨源请求 一章有详细介绍,
      • "omit" —— 不发送,即使对于同源请求。
  3. cache
    1. 默认情况下,fetch 请求使用标准的 HTTP 缓存。就是说,它遵从 ExpiresCache-Control header,发送 If-Modified-Since,等。就像常规的 HTTP 请求那样。
    2. 使用 cache 选项可以忽略 HTTP 缓存或者对其用法进行微调:
      • "default" —— fetch 使用标准的 HTTP 缓存规则和 header,
      • "no-store" —— 完全忽略 HTTP 缓存,如果我们设置 header If-Modified-SinceIf-None-MatchIf-Unmodified-SinceIf-Match,或 If-Range,则此模式会成为默认模式,
      • "reload" —— 不从 HTTP 缓存中获取结果(如果有),而是使用响应填充缓存(如果 response header 允许此操作),
      • "no-cache" —— 如果有一个已缓存的响应,则创建一个有条件的请求,否则创建一个普通的请求。使用响应填充 HTTP 缓存,
      • "force-cache" —— 使用来自 HTTP 缓存的响应,即使该响应已过时(stale)。如果 HTTP 缓存中没有响应,则创建一个常规的 HTTP 请求,行为像正常那样,
      • "only-if-cached" —— 使用来自 HTTP 缓存的响应,即使该响应已过时(stale)。如果 HTTP 缓存中没有响应,则报错。只有当 mode 为 same-origin 时生效。
  4. redirect
    1. 通常来说,fetch 透明地遵循 HTTP 重定向,例如 301,302 等。
      • "follow" —— 默认值,遵循 HTTP 重定向,
      • "error" —— HTTP 重定向时报错,
      • "manual" —— 允许手动处理 HTTP 重定向。在重定向的情况下,我们将获得一个特殊的响应对象,其中包含 response.type="opaqueredirect" 和归零/空状态以及大多数其他属性。
  5. integrity
    1. integrity 选项允许检查响应是否与已知的预先校验和相匹配。
JavaScript
fetch('http://site.com/file', {
  integrity: 'sha256-abcdef'
});
  1. keepalive
    1. keepalive 选项表示该请求可能会在网页关闭后继续存在。
JaVAScript
window.onunload = function() {
  fetch('/analytics', {
    method: 'POST',
    body: "statistics",
    keepalive: true
  });
};

它有一些限制: - 我们无法发送兆字节的数据:keepalive 请求的 body 限制为 64KB。 - 如果我们需要收集有关访问的大量统计信息,我们则应该将其定期以数据包的形式发送出去,这样就不会留下太多数据给最后的 onunload 请求了。 - 此限制是被应用于当前所有 keepalive 请求的总和的。换句话说,我们可以并行执行多个 keepalive 请求,但它们的 body 长度之和不得超过 64KB。 - 如果文档(document)已卸载(unloaded),我们就无法处理服务器响应。因此,在我们的示例中,因为 keepalive,所以 fetch 会成功,但是后续的函数将无法正常工作。 - 在大多数情况下,例如发送统计信息,这不是问题,因为服务器只接收数据,并通常向此类请求发送空的响应。

URL 对象

内建的 URL 类提供了用于创建和解析 URL 的便捷接口。

  1. 创建 URL 对象
    1. 创建一个新 URL 对象的语法:new URL(url, [base])
      • url —— 完整的 URL,或者仅路径(如果设置了 base),
      • base —— 可选的 base URL:如果设置了此参数,且参数 url 只有路径,则会根据这个 base 生成 URL。
    JaVAScript
    let url = new URL('https://javascript.info/url');
    
    alert(url.protocol); // https:
    alert(url.host);     // javascript.info
    alert(url.pathname); // /url
    • href 是完整的 URL,与 url.toString() 相同
    • protocol 以冒号字符 : 结尾
    • search —— 以问号 ? 开头的一串参数
    • hash 以哈希字符 # 开头
    • 如果存在 HTTP 身份验证,则这里可能还会有 user 和 password 属性:http://login:password@site.com(图片上没有,很少被用到)。
    1. 我们可以将 URL 对象传递给网络(和大多数其他)方法,而不是字符串
      1. 我们可以在 fetch 或 XMLHttpRequest 中使用 URL 对象,几乎可以在任何需要 URL 字符串的地方都能使用 URL 对象。
  2. SearchParams “?…”
    1. 如果参数中包含空格,非拉丁字母等(具体参见下文),参数就需要被编码。
    2. 因此,有一个 URL 属性用于解决这个问题:url.searchParamsURLSearchParams 类型的对象。
      • append(name, value) —— 按照 name 添加参数,
      • delete(name) —— 按照 name 移除参数,
      • get(name) —— 按照 name 获取参数,
      • getAll(name) —— 获取相同 name 的所有参数(这是可行的,例如 ?user=John&user=Pete),
      • has(name) —— 按照 name 检查参数是否存在,
      • set(name, value) —— set/replace 参数,
      • sort() —— 按 name 对参数进行排序,很少使用,
      • ……并且它是可迭代的,类似于 Map
JaVAScript
let url = new URL('https://google.com/search');

url.searchParams.set('q', 'test me!'); // 添加带有一个空格和一个 ! 的参数

alert(url); // https://google.com/search?q=test+me%21

url.searchParams.set('tbs', 'qdr:y'); // 添加带有一个冒号 : 的参数

// 参数会被自动编码
alert(url); // https://google.com/search?q=test+me%21&tbs=qdr%3Ay

// 遍历搜索参数(被解码)
for(let [name, value] of url.searchParams) {
  alert(`${name}=${value}`); // q=test me!,然后是 tbs=qdr:y
}
  1. 编码(encoding)
    1. RFC3986 标准定义了 URL 中允许哪些字符,不允许哪些字符。
    JaVAScript
    // 在此示例中使用一些西里尔字符
    
    let url = new URL('https://ru.wikipedia.org/wiki/Тест');
    
    url.searchParams.set('key', 'ъ');
    alert(url); //https://ru.wikipedia.org/wiki/%D0%A2%D0%B5%D1%81%D1%82?key=%D1%8A
  2. 编码字符串
    1. 在过去,在出现 URL 对象之前,人们使用字符串作为 URL。
    2. 用于编码/解码 URL 的内建函数:
    3. 在 URL 中 :?=&# 这类字符是被允许的。
    4. 另一方面,对于 URL 的单个组件,例如一个搜索参数,则必须对这些字符进行编码,以免破坏 URL 的格式。
      • encodeURI 仅编码 URL 中完全禁止的字符。
      • encodeURIComponent 也编码这类字符,此外,还编码 #$&+,/:;=? 和 @ 字符。
    5. encode* 与 URL 之间的编码差异
      1. URL 和 URLSearchParams 基于最新的 URL 规范:RFC3986,而 encode* 函数是基于过时的 RFC2396

XMLHttpRequest

XMLHttpRequest 是一个内建的浏览器对象,它允许使用 JavaScript 发送 HTTP 请求。 它可以操作任何数据,而不仅仅是 XML 格式。我们可以用它来上传/下载文件,跟踪进度等。 我们有一个更为现代的方法叫做 fetch,它的出现使得 XMLHttpRequest 在某种程度上被弃用。 出于以下三种原因,我们还在使用 XMLHttpRequest: 1. 历史原因:我们需要支持现有的使用了 XMLHttpRequest 的脚本。 2. 我们需要兼容旧浏览器,并且不想用 polyfill(例如为了使脚本更小)。 3. 我们需要做一些 fetch 目前无法做到的事情,例如跟踪上传进度。

  1. XMLHttpRequest 基础
    1. XMLHttpRequest 有两种执行模式:同步(synchronous)和异步(asynchronous)。
    2. 异步模式:
      1. 创建 XMLHttpRequest:let xhr = new XMLHttpRequest(); 此构造器没有参数。
      2. 初始化它,通常就在 new XMLHttpRequest 之后: xhr.open(method, URL, [async, user, password])
        • method —— HTTP 方法。通常是 "GET" 或 "POST"
        • URL —— 要请求的 URL,通常是一个字符串,也可以是 URL 对象。
        • async —— 如果显式地设置为 false,那么请求将会以同步的方式处理,我们稍后会讲到它。
        • userpassword —— HTTP 基本身份验证(如果需要的话)的登录名和密码。
      3. 发送请求。xhr.send([body])
      4. 监听 xhr 事件以获取响应。
        • load —— 当请求完成(即使 HTTP 状态为 400 或 500 等),并且响应已完全下载。
        • error —— 当无法发出请求,例如网络中断或者无效的 URL。
        • progress —— 在下载响应期间定期触发,报告已经下载了多少。
JaVAScript
// 1. 创建一个 new XMLHttpRequest 对象
let xhr = new XMLHttpRequest();

// 2. 配置它:从 URL /article/.../load GET-request
xhr.open('GET', '/article/xmlhttprequest/example/load');

// 3. 通过网络发送请求
xhr.send();

// 4. 当接收到响应后,将调用此函数
xhr.onload = function() {
  if (xhr.status != 200) { // 分析响应的 HTTP 状态
    alert(`Error ${xhr.status}: ${xhr.statusText}`); // 例如 404: Not Found
  } else { // 显示结果
    alert(`Done, got ${xhr.response.length} bytes`); // response 是服务器响应
  }
};

xhr.onprogress = function(event) {
  if (event.lengthComputable) {
    alert(`Received ${event.loaded} of ${event.total} bytes`);
  } else {
    alert(`Received ${event.loaded} bytes`); // 没有 Content-Length
  }

};

xhr.onerror = function() {
  alert("Request failed");
};

URL 搜索参数(URL search parameters)

  1. 响应类型
    1. 我们可以使用 xhr.responseType 属性来设置响应格式:
      • ""(默认)—— 响应格式为字符串,
      • "text" —— 响应格式为字符串,
      • "arraybuffer" —— 响应格式为 ArrayBuffer(对于二进制数据,请参见 ArrayBuffer,二进制数组),
      • "blob" —— 响应格式为 Blob(对于二进制数据,请参见 Blob),
      • "document" —— 响应格式为 XML document(可以使用 XPath 和其他 XML 方法)或 HTML document(基于接收数据的 MIME 类型)
      • "json" —— 响应格式为 JSON(自动解析)
    JaVAScript
    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!
    };
  2. readyState
    1. XMLHttpRequest 的状态(state)会随着它的处理进度变化而变化。可以通过 xhr.readyState 来了解当前状态。
JaVAScript
UNSENT = 0; // 初始状态
OPENED = 1; // open 被调用
HEADERS_RECEIVED = 2; // 接收到 response header
LOADING = 3; // 响应正在被加载(接收到一个数据包)
DONE = 4; // 请求完成

可以使用 readystatechange 事件来跟踪它们:

JaVAScript
xhr.onreadystatechange = function() {
  if (xhr.readyState == 3) {
    // 加载中
  }
  if (xhr.readyState == 4) {
    // 请求完成
  }
};
  1. 中止请求(Aborting)
    1. 我们可以随时终止请求。调用 xhr.abort() 即可:它会触发 abort 事件,且 xhr.status 变为 0
  2. 同步请求
    1. 如果在 open 方法中将第三个参数 async 设置为 false,那么请求就会以同步的方式进行。
    2. 换句话说,JavaScript 执行在 send() 处暂停,并在收到响应后恢复执行。这有点儿像 alert 或 prompt 命令。
JaVAScript
let xhr = new XMLHttpRequest();

xhr.open('GET', '/article/xmlhttprequest/hello.txt', false);

try {
  xhr.send();
  if (xhr.status != 200) {
    alert(`Error ${xhr.status}: ${xhr.statusText}`);
  } else {
    alert(xhr.response);
  }
} catch(err) { // 代替 onerror
  alert("Request failed");
}
  1. HTTP-header
    1. XMLHttpRequest 允许发送自定义 header,并且可以从响应中读取 header。
    2. HTTP-header 有三种方法:
      1. setRequestHeader(name, value):使用给定的 name 和 value 设置 request header。
      2. getResponseHeader(name):获取具有给定 name 的 header(Set-Cookie 和 Set-Cookie2 除外)。
      3. getAllResponseHeaders():返回除 Set-Cookie 和 Set-Cookie2 外的所有 response header。
      4. 例如:xhr.setRequestHeader('Content-Type', 'application/json');
    3. Header 的限制:一些 header 是由浏览器专门管理的,例如 Referer 和 Host。 完整列表请见 规范
    4. 不能移除 headerXMLHttpRequest 的另一个特点是不能撤销 setRequestHeader
  2. POST,FormData
    1. 要建立一个 POST 请求,我们可以使用内建的 FormData 对象。
      1. xhr.open('POST', ...) —— 使用 POST 方法。
      2. xhr.send(formData) 将表单发送到服务器。
html
<form name="person">
  <input name="name" value="John">
  <input name="surname" value="Smith">
</form>

<script>
  // 从表单预填充 FormData
  let formData = new FormData(document.forms.person);

  // 附加一个字段
  formData.append("middle", "Lee");

  // 将其发送出去
  let xhr = new XMLHttpRequest();
  xhr.open("POST", "/article/xmlhttprequest/post/user");
  xhr.send(formData);

  xhr.onload = () => alert(xhr.response);
</script>
  1. 上传进度
    1. progress 事件仅在下载阶段触发。
    2. 如果我们 POST 一些内容,XMLHttpRequest 首先上传我们的数据(request body),然后下载响应。
    3. 这里有另一个对象,它没有方法,它专门用于跟踪上传事件:xhr.upload
    4. 它会生成事件,类似于 xhr,但是 xhr.upload 仅在上传时触发它们:
      • loadstart —— 上传开始。
      • progress —— 上传期间定期触发。
      • abort —— 上传中止。
      • error —— 非 HTTP 错误。
      • load —— 上传成功完成。
      • timeout —— 上传超时(如果设置了 timeout 属性)。
      • loadend —— 上传完成,无论成功还是 error。
    JaVAScript
    xhr.upload.onprogress = function(event) {
      alert(`Uploaded ${event.loaded} of ${event.total} bytes`);
    };
    
    xhr.upload.onload = function() {
      alert(`Upload finished successfully.`);
    };
    
    xhr.upload.onerror = function() {
      alert(`Error during the upload: ${xhr.status}`);
    };

带有进度指示的文件上传:

html
<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>
  1. 跨源请求
    1. XMLHttpRequest 可以使用和 fetch 相同的 CORS 策略进行跨源请求。
    2. 就像 fetch 一样,默认情况下不会将 cookie 和 HTTP 授权发送到其他域。要启用它们,可以将 xhr.withCredentials 设置为 true
JaVAScript
let xhr = new XMLHttpRequest();
xhr.withCredentials = true;

xhr.open('POST', 'http://anywhere.com/request');
...

可恢复的文件上传

使用 fetch 方法来上传文件相当容易。连接断开后如何恢复上传?这里没有对此的内建选项,但是我们有实现它的一些方式。对于大文件(如果我们可能需要恢复),可恢复的上传应该带有上传进度提示。由于 fetch 不允许跟踪上传进度,我们将会使用 XMLHttpRequest

  1. 不太实用的进度事件
    1. 不会帮助我们在此处恢复上传,因为它会在数据 被发送 时触发,但是服务器是否接收到了?浏览器并不知道。
    2. 它是由本地网络代理缓冲的(buffered),或者可能是远程服务器进程刚刚终止而无法处理它们,亦或是它在中间丢失了,并没有到达服务器。
    3. 要恢复上传,我们需要 确切地 知道服务器接收的字节数。而且只有服务器能告诉我们,因此,我们将发出一个额外的请求。
  2. 算法
    1. 首先,创建一个文件 id,以唯一地标识我们要上传的文件:
    JaVAScript
    let fileId = file.name + '-' + file.size + '-' + file.lastModified;
    在恢复上传时需要用到它,以告诉服务器我们要恢复的内容。 如果名称,或大小,或最后一次修改时间发生了更改,则将有另一个 fileId。 2. 向服务器发送一个请求,询问它已经有了多少字节:
    JaVAScript
    let response = await fetch('status', {
      headers: {
        'X-File-Id': fileId
      }
    });
    
    // 服务器已有的字节数
    let startByte = +await response.text();
    1. 然后,我们可以使用 Blob 和 slice 方法来发送从 startByte 开始的文件:
    JaVAScript
    xhr.open("POST", "upload", true);
    
    // 文件 id,以便服务器知道我们要恢复的是哪个文件
    xhr.setRequestHeader('X-File-Id', fileId);
    
    // 发送我们要从哪个字节开始恢复,因此服务器知道我们正在恢复
    xhr.setRequestHeader('X-Start-Byte', startByte);
    
    xhr.upload.onprogress = (e) => {
      console.log(`Uploaded ${startByte + e.loaded} of ${startByte + e.total}`);
    };
    
    // 文件可以是来自 input.files[0],或者另一个源
    xhr.send(file.slice(startByte));

长轮询(Long polling)

长轮询是与服务器保持持久连接的最简单的方式,它不使用任何特定的协议,例如 WebSocket 或者 Server Sent Event。

  1. 常规轮询
    1. 从服务器获取新信息的最简单的方式是定期轮询。也就是说,定期向服务器发出请求:“你好,我在这儿,你有关于我的任何信息吗?”例如,每 10 秒一次。
    2. 作为响应,服务器首先通知自己,客户端处于在线状态,然后 —— 发送目前为止的消息包。
    3. 有些缺点:
      1. 消息传递的延迟最多为 10 秒(两个请求之间)。
      2. 即使没有消息,服务器也会每隔 10 秒被请求轰炸一次,即使用户切换到其他地方或者处于休眠状态,也是如此。就性能而言,这是一个很大的负担。
  2. 长轮询
    1. 流程:
      1. 请求.发送到服务器。
      2. 服务器在有消息之前不会关闭连接。
      3. 当消息出现时 —— 服务器将对其请求作出响应。
      4. 浏览器立即发出一个新的请求。
    JaVAScript
    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);
        // 再次调用 subscribe() 以获取下一条消息
        await subscribe();
      }
    }
    
    subscribe();
    1. 服务器应该可以处理许多挂起的连接
      1. 某些服务器架构是每个连接对应一个进程,导致进程数和连接数一样多,而每个进程都会消耗相当多的内存。因此,过多的连接会消耗掉全部内存。
      2. 使用 Node.js 编写的服务端程序通常不会出现此类问题。
      3. 使用像 PHP 和 Ruby 语言编写的后端程序会经常遇到这个问题。
  3. 示例:聊天
    1. browser.js
    javascript
    // Sending messages, a simple POST
    function PublishForm(form, url) {
    
      function sendMessage(message) {
        fetch(url, {
          method: 'POST',
          body: message
        });
      }
    
      form.onsubmit = function() {
        let message = form.message.value;
        if (message) {
          form.message.value = '';
          sendMessage(message);
        }
        return false;
      };
    }
    
    // Receiving messages with long polling
    function SubscribePane(elem, url) {
    
      function showMessage(message) {
        let messageElem = document.createElement('div');
        messageElem.append(message);
        elem.append(messageElem);
      }
    
      async function subscribe() {
        let response = await fetch(url);
    
        if (response.status == 502) {
          // Connection timeout
          // happens when the connection was pending for too long
          // let's reconnect
          await subscribe();
        } else if (response.status != 200) {
          // Show Error
          showMessage(response.statusText);
          // Reconnect in one second
          await new Promise(resolve => setTimeout(resolve, 1000));
          await subscribe();
        } else {
          // Got message
          let message = await response.text();
          showMessage(message);
          await subscribe();
        }
      }
    
      subscribe();
    
    }
    1. server.js
    javascript
    let http = require('http');
    let url = require('url');
    let querystring = require('querystring');
    let static = require('node-static');
    
    let fileServer = new static.Server('.');
    
    let subscribers = Object.create(null);
    
    function onSubscribe(req, res) {
      let id = Math.random();
    
      res.setHeader('Content-Type', 'text/plain;charset=utf-8');
      res.setHeader("Cache-Control", "no-cache, must-revalidate");
    
      subscribers[id] = res;
    
      req.on('close', function() {
        delete subscribers[id];
      });
    
    }
    
    function publish(message) {
    
      for (let id in subscribers) {
        let res = subscribers[id];
        res.end(message);
      }
    
      subscribers = Object.create(null);
    }
    
    function accept(req, res) {
      let urlParsed = url.parse(req.url, true);
    
      // new client wants messages
      if (urlParsed.pathname == '/subscribe') {
        onSubscribe(req, res);
        return;
      }
    
      // sending a message
      if (urlParsed.pathname == '/publish' && req.method == 'POST') {
        // accept POST
        req.setEncoding('utf8');
        let message = '';
        req.on('data', function(chunk) {
          message += chunk;
        }).on('end', function() {
          publish(message); // publish it to everyone
          res.end("ok");
        });
    
        return;
      }
    
      // the rest is static
      fileServer.serve(req, res);
    
    }
    
    function close() {
      for (let id in subscribers) {
        let res = subscribers[id];
        res.end();
      }
    }
    
    // -----------------------------------
    
    if (!module.parent) {
      http.createServer(accept).listen(8080);
      console.log('Server running on port 8080');
    } else {
      exports.accept = accept;
    
      if (process.send) {
         process.on('message', (msg) => {
           if (msg === 'shutdown') {
             close();
           }
         });
      }
    
      process.on('SIGINT', close);
    }
    1. index.html
    html
    <!DOCTYPE html>
    <script src="browser.js"></script>
    
    All visitors of this page will see messages of each other.
    
    <form name="publish">
      <input type="text" name="message" />
      <input type="submit" value="Send" />
    </form>
    
    <div id="subscribe">
    </div>
    
    <script>
      new PublishForm(document.forms.publish, 'publish');
      // random url parameter to avoid any caching issues
      new SubscribePane(document.getElementById('subscribe'), 'subscribe?random=' + Math.random());
    </script>
  4. 使用场景
    1. 在消息很少的情况下,长轮询很有效。如果消息比较频繁,那么上面描绘的请求-接收(requesting-receiving)消息的图表就会变成锯状状(saw-like)。
    2. 每个消息都是一个单独的请求,并带有 header,身份验证开销(authentication overhead)等。
    3. 因此,在这种情况下,首选另一种方法,例如:Websocket 或 Server Sent Events

WebSocket

在 RFC 6455 规范中描述的 WebSocket 协议,提供了一种在浏览器和服务器之间建立持久连接来交换数据的方法。数据可以作为“数据包”在两个方向上传递,而无需中断连接也无需额外的 HTTP 请求。对于需要连续数据交换的服务,例如网络游戏,实时交易系统等,WebSocket 尤其有用。

  1. 一个简单例子

    1. let socket = new WebSocket("ws://javascript.info");
    2. 同样也有一个加密的 wss:// 协议。类似于 WebSocket 中的 HTTPS。
    3. 始终使用 wss://
      1. wss:// 协议不仅是被加密的,而且更可靠。
      2. 因为 ws:// 数据不是加密的,对于任何中间人来说其数据都是可见的。并且,旧的代理服务器不了解 WebSocket,它们可能会因为看到“奇怪的” header 而中止连接。
      3. 另一方面,wss:// 是基于 TLS 的 WebSocket,类似于 HTTPS 是基于 TLS 的 HTTP),传输安全层在发送方对数据进行了加密,在接收方进行解密。因此,数据包是通过代理加密传输的。它们看不到传输的里面的内容,且会让这些数据通过。
    4. 一旦 socket 被建立,我们就应该监听 socket 上的事件。一共有 4 个事件:
      • open —— 连接已建立,
      • message —— 接收到数据,
      • error —— WebSocket 错误,
      • close —— 连接已关闭。
    JaVAScript
    let socket = new WebSocket("wss://javascript.info/article/websocket/demo/hello");
    
    socket.onopen = function(e) {
      alert("[open] Connection established");
      alert("Sending to server");
      socket.send("My name is John");
    };
    
    socket.onmessage = function(event) {
      alert(`[message] Data received from server: ${event.data}`);
    };
    
    socket.onclose = function(event) {
      if (event.wasClean) {
        alert(`[close] Connection closed cleanly, code=${event.code} reason=${event.reason}`);
      } else {
        // 例如服务器进程被杀死或网络中断
        // 在这种情况下,event.code 通常为 1006
        alert('[close] Connection died');
      }
    };
    
    socket.onerror = function(error) {
      alert(`[error] ${error.message}`);
    };
  2. 建立 WebSocket

    1. 当 new WebSocket(url) 被创建后,它将立即开始连接。
    2. 在连接期间,浏览器(使用 header)问服务器:“你支持 WebSocket 吗?”如果服务器回复说“我支持”,那么通信就以 WebSocket 协议继续进行,该协议根本不是 HTTP。
    3. 这是由 new WebSocket("wss://javascript.info/chat") 发出的请求的浏览器 header 示例。
    JaVAScript
    GET /chat
    Host: javascript.info
    Origin: https://javascript.info
    Connection: Upgrade
    Upgrade: websocket
    Sec-WebSocket-Key: Iv8io/9s+lYFgZWcXczP8Q==
    Sec-WebSocket-Version: 13
    • Origin —— 客户端页面的源,例如 https://javascript.info。WebSocket 对象是原生支持跨源的。没有特殊的 header 或其他限制。旧的服务器无法处理 WebSocket,因此不存在兼容性问题。但 Origin header 很重要,因为它允许服务器决定是否使用 WebSocket 与该网站通信。
    • Connection: Upgrade —— 表示客户端想要更改协议。
    • Upgrade: websocket —— 请求的协议是 “websocket”。
    • Sec-WebSocket-Key —— 浏览器随机生成的安全密钥。
    • Sec-WebSocket-Version —— WebSocket 协议版本,当前为 13。 无法模拟 WebSocket 握手 我们不能使用 XMLHttpRequest 或 fetch 来进行这种 HTTP 请求,因为不允许 JavaScript 设置这些 header。
  3. 扩展和子协议

    1. WebSocket 可能还有其他 header,Sec-WebSocket-Extensions 和 Sec-WebSocket-Protocol,它们描述了扩展和子协议
    2. 例如:
      1. Sec-WebSocket-Extensions: deflate-frame 表示浏览器支持数据压缩。扩展与传输数据有关,扩展了 WebSocket 协议的功能。Sec-WebSocket-Extensions header 由浏览器自动发送,其中包含其支持的所有扩展的列表。
      2. Sec-WebSocket-Protocol: soap, wamp 表示我们不仅要传输任何数据,还要传输 SOAP 或 WAMP(“The WebSocket Application Messaging Protocol”)协议中的数据。WebSocket 子协议已经在 IANA catalogue 中注册。因此,此 header 描述了我们将要使用的数据格式。
  4. 数据传输

    1. WebSocket 通信由 “frames”(即数据片段)组成,可以从任何一方发送,并且有以下几种类型:
      • “text frames” —— 包含各方发送给彼此的文本数据。
      • “binary data frames” —— 包含各方发送给彼此的二进制数据。
      • “ping/pong frames” 被用于检查从服务器发送的连接,浏览器会自动响应它们。
      • 还有 “connection close frame” 以及其他服务 frames。
    2. WebSocket .send() 方法可以发送文本或二进制数据。
    3. socket.send(body) 调用允许 body 是字符串或二进制格式,包括 BlobArrayBuffer 等。不需要额外的设置:直接发送它们就可以了。
    4. 当我们收到数据时,文本总是以字符串形式呈现。而对于二进制数据,我们可以在 Blob 和 ArrayBuffer 格式之间进行选择。
    5. 它是由 socket.binaryType 属性设置的,默认为 "blob",因此二进制数据通常以 Blob 对象呈现。
    6. Blob 是高级的二进制对象,它直接与 <a><img> 及其他标签集成在一起,因此,默认以 Blob 格式是一个明智的选择。但是对于二进制处理,要访问单个数据字节,我们可以将其改为 "arraybuffer"
    JaVAScript
    socket.binaryType = "arraybuffer";
    socket.onmessage = (event) => {
      // event.data 可以是文本(如果是文本),也可以是 arraybuffer(如果是二进制数据)
    };
  5. 限速

    1. 我们的应用程序正在生成大量要发送的数据。但是用户的网速却很慢,可能是在乡下的移动设备上。我们可以反复地调用 socket.send(data)。但是数据将会缓冲(储存)在内存中,并且只能在网速允许的情况下尽快将数据发送出去。socket.bufferedAmount 属性储存了目前已缓冲的字节数,等待通过网络发送。
    JaVAScript
    // 每 100ms 检查一次 socket
    // 仅当所有现有的数据都已被发送出去时,再发送更多数据
    setInterval(() => {
      if (socket.bufferedAmount == 0) {
        socket.send(moreData());
      }
    }, 100);
  6. 连接关闭

    1. 通常,当一方想要关闭连接时(浏览器和服务器都具有相同的权限),它们会发送一个带有数字码(numeric code)和文本形式的原因的 “connection close frame”。
    2. 方法:
    JaVAScript
    socket.close([code], [reason]);
    • code 是一个特殊的 WebSocket 关闭码(可选)
    • reason 是一个描述关闭原因的字符串(可选) 另外一方通过 close 事件处理器获取了关闭码和关闭原因,例如:
JaVAScript
// 关闭方:
socket.close(1000, "Work complete");

// 另一方
socket.onclose = event => {
  // event.code === 1000
  // event.reason === "Work complete"
  // event.wasClean === true (clean close)
};

最常见的数字码:

  • 1000 —— 默认,正常关闭(如果没有指明 code 时使用它),
  • 1006 —— 没有办法手动设定这个数字码,表示连接丢失(没有 close frame)。 还有其他数字码,例如:
  • 1001 —— 一方正在离开,例如服务器正在关闭,或者浏览器离开了该页面,
  • 1009 —— 消息太大,无法处理,
  • 1011 —— 服务器上发生意外错误,
  • ……等。
  1. 连接状态
    1. 要获取连接状态,可以通过带有值的 socket.readyState 属性:
      • 0 —— “CONNECTING”:连接还未建立,
      • 1 —— “OPEN”:通信中,
      • 2 —— “CLOSING”:连接关闭中,
      • 3 —— “CLOSED”:连接已关闭。
  2. 聊天示例
    1. 让我们来看一个使用浏览器 WebSocket API 和 Node.js 的 WebSocket 模块 https://github.com/websockets/ws 的聊天示例。我们将主要精力放在客户端上,但是服务端也很简单。
    2. 我们需要一个 <form> 来发送消息,并且需要一个 <div> 来接收消息:
html
<!-- 消息表单 -->
<form name="publish">
  <input type="text" name="message">
  <input type="submit" value="Send">
</form>

<!-- 带有消息的 div -->
<div id="messages"></div>

在 JavaScript 中,我们想要做三件事: 1. 打开连接。 2. 在表单提交中 —— socket.send(message) 用于消息。 3. 对于传入的消息 —— 将其附加(append)到 div#messages 上。

JaVAScript
let socket = new WebSocket("wss://javascript.info/article/websocket/chat/ws");

// 从表单发送消息
document.forms.publish.onsubmit = function() {
  let outgoingMessage = this.message.value;

  socket.send(outgoingMessage);
  return false;
};

// 收到消息 —— 在 div#messages 中显示消息
socket.onmessage = function(event) {
  let message = event.data;

  let messageElem = document.createElement('div');
  messageElem.textContent = message;
  document.getElementById('messages').prepend(messageElem);
}

服务器端的算法为:

  1. 创建 clients = new Set() —— 一系列 socket。
  2. 对于每个被接受的 WebSocket,将其添加到 clients.add(socket),并为其设置 message 事件侦听器以获取其消息。
  3. 当接收到消息:遍历客户端,并将消息发送给所有人。
  4. 当连接被关闭:clients.delete(socket)
JaVAScript
const ws = new require('ws');
const wss = new ws.Server({noServer: true});

const clients = new Set();

http.createServer((req, res) => {
  // 在这里,我们仅处理 WebSocket 连接
  // 在实际项目中,我们在这里还会有其他代码,来处理非 WebSocket 请求
  wss.handleUpgrade(req, req.socket, Buffer.alloc(0), onSocketConnect);
});

function onSocketConnect(ws) {
  clients.add(ws);

  ws.on('message', function(message) {
    message = message.slice(0, 50); // message 的最大长度为 50

    for(let client of clients) {
      client.send(message);
    }
  });

  ws.on('close', function() {
    clients.delete(ws);
  });
}

Server Sent Events

Server-Sent Events 规范描述了一个内建的类 EventSource,它能保持与服务器的连接,并允许从中接收事件。 与 WebSocket 类似,其连接是持久的。 两者的区别: WebSocket:双向:客户端和服务端都能交换消息。二进制和文本数据。WebSocket 协议。 EventSource:单向:仅服务端能发送消息。 仅文本数据。常规 HTTP 协议。

使用EventSource的原因:在很多应用中,WebSocket 有点大材小用。

我们需要从服务器接收一个数据流:可能是聊天消息或者市场价格等。这正是 EventSource 所擅长的。它还支持自动重新连接,而在 WebSocket 中这个功能需要我们手动实现。此外,它是一个普通的旧的 HTTP,不是一个新协议。

  1. 获取消息
    1. 要开始接收消息,我们只需要创建 new EventSource(url) 即可。
    2. 浏览器将会连接到 url 并保持连接打开,等待事件。
    3. 服务器响应状态码应该为 200,header 为 Content-Type: text/event-stream,然后保持此连接并以一种特殊的格式写入消息
JavaScript
data: Message 1

data: Message 2

data: Message 3
data: of two lines
  • data: 后为消息文本,冒号后面的空格是可选的。
  • 消息以双换行符 \n\n 分隔。
  • 要发送一个换行 \n,我们可以在要换行的位置立即再发送一个 data:(上面的第三条消息)。 对于每个这样的消息,都会生成 message 事件:
JavaScript
let eventSource = new EventSource("/events/subscribe");

eventSource.onmessage = function(event) {
  console.log("New message", event.data);
  // 对于上面的数据流将打印三次
};

// 或 eventSource.addEventListener('message', ...)
  1. 跨源请求
    1. EventSource 支持跨源请求,就像 fetch 和任何其他网络方法。我们可以使用任何 URL:
JavaScript
let source = new EventSource("https://another-site.com/events");

远程服务器将会获取到 Origin header,并且必须以 Access-Control-Allow-Origin 响应来处理。 要传递凭证(credentials),我们应该设置附加选项 withCredentials

JavaScript
let source = new EventSource("https://another-site.com/events", {
  withCredentials: true
});
  1. 重新连接
    1. 创建之后,new EventSource 连接到服务器,如果连接断开 —— 则重新连接。
    2. 每次重新连接之间有一点小的延迟,默认为几秒钟。
    3. 服务器可以使用 retry: 来设置需要的延迟响应时间(以毫秒为单位)。
    JavaScript
    retry: 15000
    data: Hello, I set the reconnection delay to 15 seconds

retry: 既可以与某些数据一起出现,也可以作为独立的消息出现。 在重新连接之前,浏览器需要等待那么多毫秒。甚至更长,例如,如果浏览器知道(从操作系统)此时没有网络连接,它会等到连接出现,然后重试。

  • 如果服务器想要浏览器停止重新连接,那么它应该使用 HTTP 状态码 204 进行响应。
  • 如果浏览器想要关闭连接,则应该调用 eventSource.close()
JavaScript
let eventSource = new EventSource(...);

eventSource.close();

并且,如果响应具有不正确的 Content-Type 或者其 HTTP 状态码不是 301,307,200 和 204,则不会进行重新连接。在这种情况下,将会发出 "error" 事件,并且浏览器不会重新连接。 当连接最终被关闭时,就无法“重新打开”它。如果我们想要再次连接,只需要创建一个新的 EventSource

  1. 消息 id
    1. 当一个连接由于网络问题而中断时,客户端和服务器都无法确定哪些消息已经收到哪些没有收到。
    2. 为了正确地恢复连接,每条消息都应该有一个 id 字段,
JavaScript
data: Message 1
id: 1

data: Message 2
id: 2

data: Message 3
data: of two lines
id: 3

当收到具有 id 的消息时,浏览器会:

  • 将属性 eventSource.lastEventId 设置为其值。
  • 重新连接后,发送带有 id 的 header Last-Event-ID,以便服务器可以重新发送后面的消息。 把 id: 放在 data: 后:请注意:id 被服务器附加到 data 消息后,以确保在收到消息后 lastEventId 会被更新。
  1. 连接状态:readyState
    1. EventSource 对象有 readyState 属性,该属性具有下列值之一:
JavaScript
EventSource.CONNECTING = 0; // 连接中或者重连中
EventSource.OPEN = 1;       // 已连接
EventSource.CLOSED = 2;     // 连接已关闭
  1. Event 类型
    1. 默认情况下 EventSource 对象生成三个事件:
      • message —— 收到消息,可以用 event.data 访问。
      • open —— 连接已打开。
      • error —— 无法建立连接,例如,服务器返回 HTTP 500 状态码。
JavaScript
eventSource.addEventListener('join', event => {
  alert(`Joined ${event.data}`);
});

eventSource.addEventListener('message', event => {
  alert(`Said: ${event.data}`);
});

eventSource.addEventListener('leave', event => {
  alert(`Left ${event.data}`);
});
  1. 完整示例
    1. 服务器依次发送 123,最后发送 bye 并断开连接。然后浏览器会自动重新连接。
    2. server.js
    javascript
    let http = require('http');
    let url = require('url');
    let querystring = require('querystring');
    let static = require('node-static');
    let fileServer = new static.Server('.');
    
    function onDigits(req, res) {
      res.writeHead(200, {
        'Content-Type': 'text/event-stream; charset=utf-8',
        'Cache-Control': 'no-cache'
      });
    
      let i = 0;
    
      let timer = setInterval(write, 1000);
      write();
    
      function write() {
        i++;
    
        if (i == 4) {
          res.write('event: bye\ndata: bye-bye\n\n');
          clearInterval(timer);
          res.end();
          return;
        }
    
        res.write('data: ' + i + '\n\n');
    
      }
    }
    
    function accept(req, res) {
    
      if (req.url == '/digits') {
        onDigits(req, res);
        return;
      }
    
      fileServer.serve(req, res);
    }
    
    
    if (!module.parent) {
      http.createServer(accept).listen(8080);
    } else {
      exports.accept = accept;
    }
    1. index.html
html
<!DOCTYPE html>
<script>
let eventSource;

function start() { // when "Start" button pressed
  if (!window.EventSource) {
    // IE or an old browser
    alert("The browser doesn't support EventSource.");
    return;
  }

  eventSource = new EventSource('digits');

  eventSource.onopen = function(e) {
    log("Event: open");
  };

  eventSource.onerror = function(e) {
    log("Event: error");
    if (this.readyState == EventSource.CONNECTING) {
      log(`Reconnecting (readyState=${this.readyState})...`);
    } else {
      log("Error has occured.");
    }
  };

  eventSource.addEventListener('bye', function(e) {
    log("Event: bye, data: " + e.data);
  });

  eventSource.onmessage = function(e) {
    log("Event: message, data: " + e.data);
  };
}

function stop() { // when "Stop" button pressed
  eventSource.close();
  log("eventSource.close()");
}

function log(msg) {
  logElem.innerHTML += msg + "<br>";
  document.documentElement.scrollTop = 99999999;
}
</script>

<button onclick="start()">Start</button> Press the "Start" to begin.
<div id="logElem" style="margin: 6px 0"></div>

<button onclick="stop()">Stop</button> "Stop" to finish.