javascript

FormData

目前为止,我们已经对 fetch 相当了解了。

现在让我们来看看 fetch 的剩余 API,来了解它的全部本领吧。

请注意:

请注意:这些选项 (option) 大多都很少使用。即使跳过本章,你也可以很好地使用 fetch

但是,知道 fetch 可以做什么还是很好的,所以如果需要,你可以来看看这些细节内容。

这是所有可能的 fetch 选项及其默认值(注释中标注了可选值)的完整列表:

 
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
});

一个令人印象深刻的列表,对吧?

我们已经在 Fetch 一章中详细介绍了 methodheaders 和 body

在 Fetch:中止(Abort) 一章中介绍了 signal 选项。

现在让我们学习其余的本领。

referrer,referrerPolicy

这些选项决定了 fetch 如何设置 HTTP 的 Referer header。

通常来说,这个 header 是被自动设置的,并包含了发出请求的页面的 url。在大多数情况下,它一点也不重要,但有时出于安全考虑,删除或缩短它是有意义的。

referer 选项允许设置在当前域的任何 Referer,或者移除它。

要不发送 referer,可以将 referer 设置为空字符串:

 
  1. fetch('/page', {
  2. referrer: "" // 没有 Referer header
  3. });

设置在当前域内的另一个 url:

 
  1. fetch('/page', {
  2. // 假设我们在 https://javascript.info
  3. // 我们可以设置任何 Referer header,但必须是在当前域内的
  4. referrer: "https://javascript.info/anotherpage"
  5. });

referrerPolicy 选项为 Referer 设置一般的规则。

请求分为 3 种类型:

  1. 同源请求。
  2. 跨域请求。
  3. 从 HTTPS 到 HTTP 的请求 (从安全协议到不安全协议)。

与 referrer 选项允许设置确切的 Referer 值不同,referrerPolicy 告诉浏览器针对各个请求类型的一般的规则。

可能的值在 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 请求。

这是一个包含所有组合的表格:

同源跨源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

假如我们有一个带有 URL 结构的管理区域(admin zone),它不应该被从网站外看到。

如果我们发送了一个 fetch,则默认情况下,它总是发送带有页面完整 url 的 Referer header(我们从 HTTPS 向 HTTP 发送请求的情况除外,这种情况下没有 Referer)。

例如 Referer: https://javascript.info/admin/secret/paths

如果我们想让其他网站只知道域的部分,而不是 URL 路径,我们可以这样设置选项:

 
  1. fetch('https://another.com/page', {
  2. // ...
  3. referrerPolicy: "origin-when-cross-origin" // Referer: https://javascript.info
  4. });

我们可以将其置于所有 fetch 调用中,也可以将其集成到我们项目的执行所有请求并在内部使用 fetch 的 JavaScript 库中。

与默认行为相比,它的唯一区别在于,对于跨源请求,fetch 只发送 URL 域的部分(例如 https://javascript.info,没有路径)。对于同源请求,我们仍然可以获得完整的 Referer(可能对于调试目的是有用的)。

Referrer policy 不仅适用于 fetch

在 规范 中描述的 referrer policy,不仅适用于 fetch,它还具有全局性。

特别是,可以使用 Referrer-Policy HTTP header,或者为每个链接设置 <a rel="noreferrer">,来为整个页面设置默认策略(policy)。

mode

mode 选项是一种安全措施,可以防止偶发的跨源请求:

  • "cors" —— 默认值,允许跨源请求,如 Fetch:跨源请求 一章所述,
  • "same-origin" —— 禁止跨源请求,
  • "no-cors" —— 只允许简单的跨源请求。

当 fetch 的 URL 来自于第三方,并且我们想要一个“断电开关”来限制跨源能力时,此选项可能很有用。

credentials

credentials 选项指定 fetch 是否应该随请求发送 cookie 和 HTTP-Authorization header。

  • "same-origin" —— 默认值,对于跨源请求不发送,
  • "include" —— 总是发送,需要来自跨源服务器的 Accept-Control-Allow-Credentials,才能使 JavaScript 能够访问响应,详细内容在 Fetch:跨源请求 一章有详细介绍,
  • "omit" —— 不发送,即使对于同源请求。

cache

默认情况下,fetch 请求使用标准的 HTTP 缓存。就是说,它遵从 ExpiresCache-Control header,发送 If-Modified-Since,等。就像常规的 HTTP 请求那样。

使用 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 时生效。

redirect

通常来说,fetch 透明地遵循 HTTP 重定向,例如 301,302 等。

redirect 选项允许对此进行更改:

  • "follow" —— 默认值,遵循 HTTP 重定向,
  • "error" —— HTTP 重定向时报错,
  • "manual" —— 不遵循 HTTP 重定向,但 response.url 将是一个新的 URL,并且 response redirectd 将为 true,以便我们能够手动执行重定向到新的 URL(如果需要的话)。

integrity

integrity 选项允许检查响应是否与已知的预先校验和相匹配。

正如 规范 所描述的,支持的哈希函数有 SHA-256,SHA-384,和 SHA-512,可能还有其他的,这取决于浏览器。

例如,我们下载一个文件,并且我们知道它的 SHA-256 校验和为 “abcdef”(当然,实际校验和会更长)。

我们可以将其放在 integrity 选项中,就像这样:

 
  1. fetch('http://site.com/file', {
  2. integrity: 'sha256-abcdef'
  3. });

然后 fetch 将自行计算 SHA-256 并将其与我们的字符串进行比较。如果不匹配,则会触发错误。

keepalive

keepalive 选项表示该请求可能会使发起它的网页“失活(outlive)”。

例如,我们收集有关当前访问者是如何使用我们的页面(鼠标点击,他查看的页面片段)的统计信息,以分析和改善用户体验。

当访问者离开我们的网页时 —— 我们希望能够将数据保存到我们的服务器上。

我们可以使用 window.onunload 事件来实现:

 
  1. window.onunload = function() {
  2. fetch('/analytics', {
  3. method: 'POST',
  4. body: "statistics",
  5. keepalive: true
  6. });
  7. };

通常,当一个文档被卸载时(unloaded),所有相关的网络请求都会被中止。但是,keepalive 选项告诉浏览器,即使在离开页面后,也要在后台执行请求。所以,此选项对于我们的请求成功至关重要。

它有一些限制:

FormData 对象可以提供帮助。你可能已经猜到了,它是表示 HTML 表单数据的对象。

构造函数是:

 
  1. let formData = new FormData([form]);

如果提供了 HTML form 元素,它会自动捕获 form 元素字段。

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

从服务器角度来看,它就像是一个普通的表单提交。

发送一个简单的表单

我们先来发送一个简单的表单。

正如你所看到的,它几乎就是一行代码:

 
  1. <form id="formElem">
  2. <input type="text" name="name" value="John">
  3. <input type="text" name="surname" value="Smith">
  4. <input type="submit">
  5. </form>
  6. <script>
  7. formElem.onsubmit = async (e) => {
  8. e.preventDefault();
  9. let response = await fetch('/article/formdata/post/user', {
  10. method: 'POST',
  11. body: new FormData(formElem)
  12. });
  13. let result = await response.json();
  14. alert(result.message);
  15. };
  16. </script>

在这个示例中,没有将服务器代码展示出来,因为它超出了我们当前的学习范围。服务器接受 POST 请求并回应 “User saved”。

FormData 方法

我们可以使用以下方法修改 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

从技术上来讲,一个表单可以包含多个具有相同 name 的字段,因此,多次调用 append 将会添加多个具有相同名称的字段。

还有一个 set 方法,语法与 append 相同。不同之处在于 .set 移除所有具有给定 name 的字段,然后附加一个新字段。因此,它确保了只有一个具有这种 name 的字段,其他的和 append 一样:

  • formData.set(name, value)
  • formData.set(name, blob, fileName)

我们也可以使用 for..of 循环迭代 formData 字段:

 
  1. let formData = new FormData();
  2. formData.append('key1', 'value1');
  3. formData.append('key2', 'value2');
  4. // 列出 key/value 对
  5. for(let [name, value] of formData) {
  6. alert(`${name} = ${value}`); // key1=value1,然后是 key2=value2
  7. }

发送带有文件的表单

表单始终以 Content-Type: multipart/form-data 来发送数据,这个编码允许发送文件。因此 <input type="file"> 字段也能被发送,类似于普通的表单提交。

这是具有这种形式的示例:

 
  1. <form id="formElem">
  2. <input type="text" name="firstName" value="John">
  3. Picture: <input type="file" name="picture" accept="image/*">
  4. <input type="submit">
  5. </form>
  6. <script>
  7. formElem.onsubmit = async (e) => {
  8. e.preventDefault();
  9. let response = await fetch('/article/formdata/post/user-avatar', {
  10. method: 'POST',
  11. body: new FormData(formElem)
  12. });
  13. let result = await response.json();
  14. alert(result.message);
  15. };
  16. </script>

发送具有 Blob 数据的表单

正如我们在 Fetch 一章中所看到的,以 Blob 发送一个动态生成的二进制数据,例如图片,是很简单的。我们可以直接将其作为 fetch 参数的 body

但在实际中,通常更方便的发送图片的方式不是单独发送,而是将其作为表单的一部分,并带有附加字段(例如 “name” 和其他 metadata)一起发送。

并且,服务器通常更适合接收多部分编码的表单(multipart-encoded form),而不是原始的二进制数据。

下面这个例子使用 FormData 将一个来自 <canvas> 的图片和一些其他字段一起作为一个表单提交:

 
  1. <body style="margin:0">
  2. <canvas id="canvasElem" width="100" height="80" style="border:1px solid"></canvas>
  3. <input type="button" value="Submit" onclick="submit()">
  4. <script>
  5. canvasElem.onmousemove = function(e) {
  6. let ctx = canvasElem.getContext('2d');
  7. ctx.lineTo(e.clientX, e.clientY);
  8. ctx.stroke();
  9. };
  10. async function submit() {
  11. let imageBlob = await new Promise(resolve => canvasElem.toBlob(resolve, 'image/png'));
  12. let formData = new FormData();
  13. formData.append("firstName", "John");
  14. formData.append("image", imageBlob, "image.png");
  15. let response = await fetch('/article/formdata/post/image-form', {
  16. method: 'POST',
  17. body: formData
  18. });
  19. let result = await response.json();
  20. alert(result.message);
  21. }
  22. </script>
  23. </body>

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

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

就像表单中有 <input type="file" name="image"> 一样,用户从他们的文件系统中使用数据 imageBlob(第二个参数)提交了一个名为 image.png(第三个参数)的文件。

服务器读取表单数据和文件,就好像它是常规的表单提交一样。

总结

FormData 对象用于捕获 HTML 表单,并使用 fetch 或其他网络方法提交。

我们可以从 HTML 表单创建 new FormData(form),也可以创建一个完全没有表单的对象,然后使用以下方法附加字段:

  • formData.append(name, value)
  • formData.append(name, blob, fileName)
  • formData.set(name, value)
  • formData.set(name, blob, fileName)

让我们在这里注意两个特点:

  1. set 方法会移除具有相同名称(name)的字段,而 append 不会。
  2. 要发送文件,需要使用三个参数的语法,最后一个参数是文件名,一般是通过 <input type="file"> 从用户文件系统中获取的。

其他方法是:

  • formData.delete(name)
  • formData.get(name)

JavaScript 可以将网络请求发送到服务器,并在需要时加载新信息。

例如,我们可以使用网络请求来:

  • 提交订单,
  • 加载用户信息,
  • 从服务器接收最新的更新,
  • ……等。

……所有这些都没有重新加载页面!

对于来自 JavaScript 的网络请求,有一个总称术语 “AJAX”(Asynchronous JavaScript And XML 的简称)。但是,我们不必使用 XML:这个术语诞生于很久以前,所以这个词一直在那儿。

有很多方式可以向服务器发送网络请求,并从服务器获取信息。

fetch() 方法是一种现代通用的方法,那么我们就从它开始吧。旧版本的浏览器不支持它(可以 polyfill),但是它在现代浏览器中的支持情况很好。

基本语法:

 
  1. let promise = fetch(url, [options])
  • url —— 要访问的 URL。
  • options —— 可选参数:method,header 等。

没有 options,那就是一个简单的 GET 请求,下载 url 的内容。

浏览器立即启动请求,并返回一个该调用代码应该用来获取结果的 promise

获取响应通常需要经过两个阶段。

第一阶段,当服务器发送了响应头(response header),fetch 返回的 promise 就使用内建的 Response class 对象来对响应头进行解析。

在这个阶段,我们可以通过检查响应头,来检查 HTTP 状态以确定请求是否成功,当前还没有响应体(response body)。

如果 fetch 无法建立一个 HTTP 请求,例如网络问题,亦或是请求的网址不存在,那么 promise 就会 reject。异常的 HTTP 状态,例如 404 或 500,不会导致出现 error。

我们可以在 response 的属性中看到 HTTP 状态:

  • status —— HTTP 状态码,例如 200。
  • ok —— 布尔值,如果 HTTP 状态码为 200-299,则为 true

例如:

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

第二阶段,为了获取 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 对象:

 
  1. let url = 'https://api.github.com/repos/javascript-tutorial/en.javascript.info/commits';
  2. let response = await fetch(url);
  3. let commits = await response.json(); // 读取 response body,并将其解析为 JSON
  4. alert(commits[0].author.login);

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

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

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

 
  1. let response = await fetch('https://api.github.com/repos/javascript-tutorial/en.javascript.info/commits');
  2. let text = await response.text(); // 将 response body 读取为文本
  3. alert(text.slice(0, 80) + '...');

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

 
  1. let response = await fetch('/article/fetch/logo-fetch.svg');
  2. let blob = await response.blob(); // 下载为 Blob 对象
  3. // 为其创建一个 <img>
  4. let img = document.createElement('img');
  5. img.style = 'position:fixed;top:10px;left:10px;width:100px';
  6. document.body.append(img);
  7. // 显示它
  8. img.src = URL.createObjectURL(blob);
  9. setTimeout(() => { // 3 秒后将其隐藏
  10. img.remove();
  11. URL.revokeObjectURL(img.src);
  12. }, 3000);

重要:

我们只能选择一种读取 body 的方法。

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

 
  1. let text = await response.text(); // response body 被处理了
  2. let parsed = await response.json(); // 失败(已经被处理过了)

Response header

Response header 位于 response.headers 中的一个类似于 Map 的 header 对象。

它不是真正的 Map,但是它具有类似的方法,我们可以按名称(name)获取各个 header,或迭代它们:

 
  1. let response = await fetch('https://api.github.com/repos/javascript-tutorial/en.javascript.info/commits');
  2. // 获取一个 header
  3. alert(response.headers.get('Content-Type')); // application/json; charset=utf-8
  4. // 迭代所有 header
  5. for (let [key, value] of response.headers) {
  6. alert(`${key} = ${value}`);
  7. }

Request header

要在 fetch 中设置 request header,我们可以使用 headers 选项。它有一个带有输出 header 的对象,如下所示:

 
  1. let response = fetch(protectedUrl, {
  2. headers: {
  3. Authentication: 'secret'
  4. }
  5. });

……但是有一些我们无法设置的 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-*

这些 header 保证了 HTTP 的正确性和安全性,所以它们仅由浏览器控制。

POST 请求

要创建一个 POST 请求,或者其他方法的请求,我们需要使用 fetch 选项:

  • method —— HTTP 方法,例如 POST
  • body —— request body,其中之一:
    • 字符串(例如 JSON 编码的),
    • FormData 对象,以 form/multipart 形式发送数据,
    • Blob/BufferSource 发送二进制数据,
    • URLSearchParams,以 x-www-form-urlencoded 编码形式发送数据,很少使用。

JSON 形式是最常用的。

例如,下面这段代码以 JSON 形式发送 user 对象:

 
  1. let user = {
  2. name: 'John',
  3. surname: 'Smith'
  4. };
  5. let response = await fetch('/article/fetch/post/user', {
  6. method: 'POST',
  7. headers: {
  8. 'Content-Type': 'application/json;charset=utf-8'
  9. },
  10. body: JSON.stringify(user)
  11. });
  12. let result = await response.json();
  13. alert(result.message);

请注意,如果请求的 body 是字符串,则 Content-Type 会默认设置为 text/plain;charset=UTF-8

但是,当我们要发送 JSON 时,我们会使用 headers 选项来发送 application/json,这是 JSON 编码的数据的正确的 Content-Type

发送图片

我们同样可以使用 Blob 或 BufferSource 对象通过 fetch 提交二进制数据。

例如,这里有一个 <canvas>,我们可以通过在其上移动鼠标来进行绘制。点击 “submit” 按钮将图片发送到服务器:

 
  1. <body style="margin:0">
  2. <canvas id="canvasElem" width="100" height="80" style="border:1px solid"></canvas>
  3. <input type="button" value="Submit" onclick="submit()">
  4. <script>
  5. canvasElem.onmousemove = function(e) {
  6. let ctx = canvasElem.getContext('2d');
  7. ctx.lineTo(e.clientX, e.clientY);
  8. ctx.stroke();
  9. };
  10. async function submit() {
  11. let blob = await new Promise(resolve => canvasElem.toBlob(resolve, 'image/png'));
  12. let response = await fetch('/article/fetch/post/image', {
  13. method: 'POST',
  14. body: blob
  15. });
  16. // 服务器给出确认信息和图片大小作为响应
  17. let result = await response.json();
  18. alert(result.message);
  19. }
  20. </script>
  21. </body>

请注意,这里我们没有手动设置 Content-Type header,因为 Blob 对象具有内建的类型(这里是 image/png,通过 toBlob 生成的)。对于 Blob 对象,这个类型就变成了 Content-Type 的值。

可以在不使用 async/await 的情况下重写 submit() 函数,像这样:

 
  1. function submit() {
  2. canvasElem.toBlob(function(blob) {
  3. fetch('/article/fetch/post/image', {
  4. method: 'POST',
  5. body: blob
  6. })
  7. .then(response => response.json())
  8. .then(result => alert(JSON.stringify(result, null, 2)))
  9. }, 'image/png');
  10. }

总结

典型的 fetch 请求由两个 await 调用组成:

 
  1. let response = await fetch(url, options); // 解析 response header
  2. let result = await response.json(); // 将 body 读取为 json

或者以 promise 形式:

 
  1. fetch(url, options)
  2. .then(response => response.json())
  3. .then(result => /* process result */)

响应的属性:

  • response.status —— response 的 HTTP 状态码,
  • response.ok —— HTTP 状态码为 200-299,则为 true
  • response.headers —— 类似于 Map 的带有 HTTP header 的对象。

获取 response body 的方法:

  • response.text() —— 读取 response,并以文本形式返回 response,
  • response.json() —— 将 response 解析为 JSON 对象形式,
  • response.formData() —— 以 FormData 对象(form/multipart 编码,参见下一章)的形式返回 response,
  • response.blob() —— 以 Blob(具有类型的二进制数据)形式返回 response,
  • response.arrayBuffer() —— 以 ArrayBuffer(低级别的二进制数据)形式返回 response。

到目前为止我们了解到的 fetch 选项:

  • method —— HTTP 方法,
  • headers —— 具有 request header 的对象(不是所有 header 都是被允许的)
  • body —— 要以 stringFormDataBufferSourceBlob 或 UrlSearchParams 对象的形式发送的数据(request body)。

在下一章,我们将会看到更多 fetch 的选项和用例。

任务

从 GitHub fetch 用户信息

创建一个异步函数 getUsers(names),该函数接受 GitHub 登录名数组作为输入,查询 GitHub 以获取有关这些用户的信息,并返回 GitHub 用户数组。

带有给定 USERNAME 的用户信息的 GitHub 网址是:https://api.github.com/users/USERNAME

沙箱中有一个测试用例。

重要的细节:

  1. 对每一个用户都应该有一个 fetch 请求。
  2. 请求不应该相互等待。以便能够尽快获取到数据。
  3. 如果任何一个请求失败了,或者没有这个用户,则函数应该返回 null 到结果数组中。

打开带有测试的沙箱。

解决方案

要获取一个用户,我们需要:fetch('https://api.github.com/users/USERNAME').

如果响应的状态码是 200,则调用 .json() 来读取 JS 对象。

否则,如果 fetch 失败,或者响应的状态码不是 200,我们只需要向结果数组返回 null 即可。

代码如下:

 
  1. async function getUsers(names) {
  2. let jobs = [];
  3. for(let name of names) {
  4. let job = fetch(`https://api.github.com/users/${name}`).then(
  5. successResponse => {
  6. if (successResponse.status != 200) {
  7. return null;
  8. } else {
  9. return successResponse.json();
  10. }
  11. },
  12. failResponse => {
  13. return null;
  14. }
  15. );
  16. jobs.push(job);
  17. }
  18. let results = await Promise.all(jobs);
  19. return results;
  20. }

请注意:.then 调用紧跟在 fetch 后面,这样,当我们收到响应时,它不会等待其他的 fetch,而是立即开始读取 .json()

如果我们使用 await Promise.all(names.map(name => fetch(...))),并在 results 上调用 .json() 方法,那么它将会等到所有 fetch 都获取到响应数据才开始解析。通过将 .json() 直接添加到每个 fetch 中,我们就能确保每个 fetch 在收到响应时都会立即开始以 JSON 格式读取数据,而不会彼此等待。

如果我们向另一个网站发送 fetch 请求,则该请求可能会失败。

例如,让我们尝试向 http://example.com 发送 fetch 请求:

 
  1. try {
  2. await fetch('http://example.com');
  3. } catch(err) {
  4. alert(err); // fetch 失败
  5. }

正如所料,获取失败。

这里的核心概念是 源(origin)—— 域(domain)/端口(port)/协议(protocol)的组合。

跨源请求 —— 那些发送到其他域(即使是子域)、协议或端口的请求 —— 需要来自远程端的特殊 header。

这个策略被称为 “CORS”:跨源资源共享(Cross-Origin Resource Sharing)。

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

CORS 的存在是为了保护互联网免受黑客攻击。

说真的,在这说点儿题外话,讲讲它的历史。

多年来,来自一个网站的脚本无法访问另一个网站的内容。

这个简单有力的规则是互联网安全的基础。例如,来自 hacker.com 的脚本无法访问 gmail.com 上的用户邮箱。基于这样的规则,人们感到很安全。

在那时候,JavaScript 并没有任何特殊的执行网络请求的方法。它只是一种用来装饰网页的玩具语言而已。

但是 Web 开发人员需要更多功能。人们发明了各种各样的技巧去突破该限制,并向其他网站发出请求。

使用表单

其中一种和其他服务器通信的方法是在那里提交一个 <form>。人们将它提交到 <iframe>,只是为了停留在当前页面,像这样:

 
  1. <!-- 表单目标 -->
  2. <iframe name="iframe"></iframe>
  3. <!-- 表单可以由 JavaScript 动态生成并提交 -->
  4. <form target="iframe" method="POST" action="http://another.com/…">
  5. ...
  6. </form>

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

确切地说,实际上有一些技巧能够解决这个问题,这在 iframe 和页面中都需要添加特殊脚本。因此,与 iframe 的通信在技术上是可能的。现在我们没必要讲其细节内容,我们还是让这些古董代码不要再出现了吧。

使用 script

另一个技巧是使用 script 标签。script 可以具有任何域的 src,例如 <script src="http://another.com/…">。也可以执行来自任何网站的 script

如果一个网站,例如 another.com 试图公开这种访问方式的数据,则会使用所谓的 “JSONP (JSON with padding)” 协议。

这是它的工作方式。

假设在我们的网站,需要以这种方式从 http://another.com 网站获取数据,例如天气:

  1. 首先,我们先声明一个全局函数来接收数据,例如 gotWeather

     
      
    1. // 1. 声明处理天气数据的函数
    2. function gotWeather({ temperature, humidity }) {
    3. alert(`temperature: ${temperature}, humidity: ${humidity}`);
    4. }
  2. 然后我们创建一个特性(attribute)为 src="http://another.com/weather.json?callback=gotWeather" 的 <script> 标签,使用我们的函数名作为它的 callback URL-参数。

     
      
    1. let script = document.createElement('script');
    2. script.src = `http://another.com/weather.json?callback=gotWeather`;
    3. document.body.append(script);
  3. 远程服务器 another.com 动态生成一个脚本,该脚本调用 gotWeather(...),发送它想让我们接收的数据。

     
      
    1. // 我们期望来自服务器的回答看起来像这样:
    2. gotWeather({
    3. temperature: 25,
    4. humidity: 78
    5. });
  4. 当远程脚本加载并执行时,gotWeather 函数将运行,并且因为它是我们的函数,我们就有了需要的数据。

这是可行的,并且不违反安全规定,因为双方都同意以这种方式传递数据。而且,既然双方都同意这种行为,那这肯定不是黑客攻击了。现在仍然有提供这种访问的服务,因为即使是非常旧的浏览器它依然适用。

不久之后,网络方法出现在了浏览器 JavaScript 中。

起初,跨源请求是被禁止的。但是,经过长时间的讨论,跨源请求被允许了,但是任何新功能都需要服务器明确允许,以特殊的 header 表述。

简单的请求

有两种类型的跨源请求:

  1. 简单的请求。
  2. 所有其他请求。

顾名思义,简单的请求很简单,所以我们先从它开始。

一个 简单的请求 是指满足以下两个条件的请求:

  1. 简单的方法:GET,POST 或 HEAD
  2. 简单的 header —— 仅允许自定义下列 header:
    • Accept
    • Accept-Language
    • Content-Language
    • Content-Type 的值为 application/x-www-form-urlencodedmultipart/form-data 或 text/plain

任何其他请求都被认为是“非简单请求”。例如,具有 PUT 方法或 API-Key HTTP-header 的请求就不是简单请求。

本质区别在于,可以使用 <form> 或 <script> 进行“简单请求”,而无需任何其他特殊方法。

因此,即使是非常旧的服务器也能很好地接收简单请求。

与此相反,带有非标准 header 或者例如 DELETE 方法的请求,无法通过这种方式创建。在很长一段时间里,JavaScript 都不能进行这样的请求。所以,旧的服务器可能会认为此类请求来自具有特权的来源(privileged source),“因为网页无法发送它们”。

当我们尝试发送一个非简单请求时,浏览器会发送一个特殊的“预检(preflight)”请求到服务器 —— 询问服务器,你接受此类跨源请求吗?

并且,除非服务器明确通过 header 进行确认,否则非简单请求不会被发送。

现在,我们来详细介绍它们。

用于简单请求的 CORS

如果一个请求是跨源的,浏览器始终会向其添加 Origin header。

例如,如果我们从 https://javascript.info/page 请求 https://anywhere.com/request,请求的 header 将会如下:

 
  1. GET /request
  2. Host: anywhere.com
  3. Origin: https://javascript.info
  4. ...

正如你所见,Origin 包含了确切的源(domain/protocol/port),没有路径。

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

浏览器在这里扮演受被信任的中间人的角色:

  1. 它确保发送的跨源请求带有正确的 Origin
  2. 它检查响应中的许可 Access-Control-Allow-Origin,如果存在,则允许 JavaScript 访问响应,否则将失败并报错。

    Fetch:跨源请求 - 图1

这是一个带有服务器许可的响应示例:

 
  1. 200 OK
  2. Content-Type:text/html; charset=UTF-8
  3. Access-Control-Allow-Origin: https://javascript.info

Response header

对于跨源请求,默认情况下,JavaScript 只能访问“简单” response header:

  • Cache-Control
  • Content-Language
  • Content-Type
  • Expires
  • Last-Modified
  • Pragma

访问任何其他 response header 都将导致 error。

请注意:

请注意:列表中没有 Content-Length header!

该 header 包含完整的响应长度。因此,如果我们正在下载某些内容,并希望跟踪进度百分比,则需要额外的权限才能访问该 header(请见下文)。

要授予 JavaScript 对任何其他 response header 的访问权限,服务器必须发送 Access-Control-Expose-Headers header。它包含一个以逗号分隔的应该被设置为可访问的非简单 header 名称列表。

例如:

 
  1. 200 OK
  2. Content-Type:text/html; charset=UTF-8
  3. Content-Length: 12345
  4. API-Key: 2c9de507f2c54aa1
  5. Access-Control-Allow-Origin: https://javascript.info
  6. Access-Control-Expose-Headers: Content-Length,API-Key

有了这种 Access-Control-Expose-Headers header,此脚本就被允许读取响应的 Content-Length 和 API-Key header。

“非简单”请求

我们可以使用任何 HTTP 方法:不仅仅是 GET/POST,也可以是 PATCHDELETE 及其他。

之前,没有人能够设想网页能发出这样的请求。因此,可能仍然存在有些 Web 服务将非标准方法视为一个信号:“这不是浏览器”。它们可以在检查访问权限时将其考虑在内。

因此,为了避免误解,任何“非标准”请求 —— 浏览器不会立即发出在过去无法完成的这类请求。即在它发送这类请求前,会先发送“预检(preflight)”请求来请求许可。

预检请求使用 OPTIONS 方法,它没有 body,但是有两个 header:

  • Access-Control-Request-Method header 带有非简单请求的方法。
  • Access-Control-Request-Headers header 提供一个以逗号分隔的非简单 HTTP-header 列表。

如果服务器同意处理请求,那么它会进行响应,此响应的状态码应该为 200,没有 body,具有 header:

  • Access-Control-Allow-Methods 必须具有允许的方法。
  • Access-Control-Allow-Headers 必须具有一个允许的 header 列表。
  • 另外,header Access-Control-Max-Age 可以指定缓存此权限的秒数。因此,浏览器不是必须为满足给定权限的后续请求发送预检。

让我们用一个例子来一步步看一下它是怎么工作的,对于一个跨源的 PATCH 请求(此方法经常被用于更新数据):

 
  1. let response = await fetch('https://site.com/service.json', {
  2. method: 'PATCH',
  3. headers: {
  4. 'Content-Type': 'application/json',
  5. 'API-Key': 'secret'
  6. }
  7. });

这里有三个理由解释为什么它不是一个简单请求(其实一个就够了):

  • 方法 PATCH
  • Content-Type 不是这三个中之一:application/x-www-form-urlencodedmultipart/form-datatext/plain
  • “非简单” API-Key header。

Step 1 预检请求(preflight request)

在发送我们的请求前,浏览器会自己发送如下所示的预检请求:

 
  1. OPTIONS /service.json
  2. Host: site.com
  3. Origin: https://javascript.info
  4. Access-Control-Request-Method: PATCH
  5. Access-Control-Request-Headers: Content-Type,API-Key
  • 方法:OPTIONS
  • 路径 —— 与主请求完全相同:/service.json
  • 特殊跨源头:
    • Origin —— 来源。
    • Access-Control-Request-Method —— 请求方法。
    • Access-Control-Request-Headers —— 以逗号分隔的“非简单” header 列表。

Step 2 预检响应(preflight response)

服务应响应状态 200 和 header:

  • Access-Control-Allow-Methods: PATCH
  • Access-Control-Allow-Headers: Content-Type,API-Key

这将允许后续通信,否则会触发错误。

如果服务器将来期望其他方法和 header,则可以通过添加到列表中来预先允许它们:

 
  1. 200 OK
  2. Access-Control-Allow-Methods: PUT,PATCH,DELETE
  3. Access-Control-Allow-Headers: API-Key,Content-Type,If-Modified-Since,Cache-Control
  4. Access-Control-Max-Age: 86400

现在,浏览器可以看到 PATCH 在 Access-Control-Allow-Methods 中,Content-Type,API-Key 在列表 Access-Control-Allow-Headers 中,因此它将发送主请求。

此外,预检响应会缓存一段时间,该时间由 Access-Control-Max-Age header 指定(86400 秒,一天),因此,后续请求将不会导致预检。假设它们符合缓存的配额,则将直接发送它们。

Step 3 实际请求(actual request)

预检成功后,浏览器现在发出主请求。这里的算法与简单请求的算法相同。

主请求具有 Origin header(因为它是跨源的):

 
  1. PATCH /service.json
  2. Host: site.com
  3. Content-Type: application/json
  4. API-Key: secret
  5. Origin: https://javascript.info

Step 4 实际响应(actual response)

服务器不应该忘记在主响应中添加 Access-Control-Allow-Origin。成功的预检并不能免除此要求:

 
  1. Access-Control-Allow-Origin: https://javascript.info

然后,JavaScript 可以读取主服务器响应了。

请注意:

预检请求发生在“幕后”,它对 JavaScript 不可见。

JavaScript 仅获取对主请求的响应,如果没有服务器许可,则获得一个 error。

凭据(Credentials)

默认情况下,跨源请求不会带来任何凭据(cookies 或者 HTTP 认证(HTTP authentication))。

这对于 HTTP 请求来说并不常见。通常,对 http://site.com 的请求附带有该域的所有 cookie。但是由 JavaScript 方法发出的跨源请求是个例外。

例如,fetch('http://another.com') 不会发送任何 cookie,即使那些 (!) 属于 another.com 域的 cookie。

为什么?

这是因为具有凭据的请求比没有凭据的请求要强大得多。如果被允许,它会使用它们的凭据授予 JavaScript 代表用户行为和访问敏感信息的全部权力。

服务器真的这么信任这种脚本吗?是的,它必须显式地带有允许请求的凭据和附加 header。

要在 fetch 中发送凭据,我们需要添加 credentials: "include" 选项,像这样:

 
  1. fetch('http://another.com', {
  2. credentials: "include"
  3. });

现在,fetch 将把源自 another.com 的 cookie 和我们的请求发送到该网站。

如果服务器同意接受 带有凭据 的请求,则除了 Access-Control-Allow-Origin 外,服务器还应该在响应中添加 header Access-Control-Allow-Credentials: true

例如:

 
  1. 200 OK
  2. Access-Control-Allow-Origin: https://javascript.info
  3. Access-Control-Allow-Credentials: true

请注意:对于具有凭据的请求,禁止 Access-Control-Allow-Origin 使用星号 *。如上所示,它必须有一个确切的源。这是另一项安全措施,以确保服务器真的知道它信任的发出此请求的是谁。

总结

从浏览器角度来看,有两种跨源请求:“简单”请求和其他请求。

简单请求 必须满足下列条件:

  • 方法:GET,POST 或 HEAD。
  • header —— 我们仅能设置:
    • Accept
    • Accept-Language
    • Content-Language
    • Content-Type 的值为 application/x-www-form-urlencodedmultipart/form-data 或 text/plain

简单请求和其他请求的本质区别在于,自古以来使用 <form> 或 <script> 标签进行简单请求就是可行的,而长期以来浏览器都不能进行非简单请求。

所以,实际区别在于,简单请求会使用 Origin header 并立即发送,而对于其他请求,浏览器会发出初步的“预检”请求,以请求许可。

对于简单请求:

  • → 浏览器发送带有源的 Origin header。
  • ← 对于没有凭据的请求(默认不发送),服务器应该设置:
    • Access-Control-Allow-Origin 为 * 或与 Origin 的值相同
  • ← 对于具有凭据的请求,服务器应该设置:
    • Access-Control-Allow-Origin 值与 Origin 的相同
    • Access-Control-Allow-Credentials 为 true

此外,要授予 JavaScript 访问除 Cache-ControlContent-LanguageContent-TypeExpiresLast-Modified 或 Pragma 外的任何 response header 的权限,服务器应该在 header Access-Control-Expose-Headers 中列出允许的那些 header。

**对于非简单请求,会在请求之前发出初步“预检”请求:

  • → 浏览器将具有以下 header 的 OPTIONS 请求发送到相同的 URL:
    • Access-Control-Request-Method 有请求方法。
    • Access-Control-Request-Headers 以逗号分隔的“非简单” header 列表。
  • ← 服务器应该响应状态码为 200 和 header:
    • Access-Control-Allow-Methods 带有允许的方法的列表,
    • Access-Control-Allow-Headers 带有允许的 header 的列表,
    • Access-Control-Max-Age 带有指定缓存权限的秒数。
  • 然后,发出实际请求,应用先前的“简单”方案。

任务

我们为什么需要源(Origin)?

重要程度: 5

你可能知道有一个 HTTP-header Referer,它通常包含发起网络请求的页面的 url。

例如,当从 http://javascript.info/some/url fetch http://google.com 时,header 看起来如下:

 
  1. Accept: */*
  2. Accept-Charset: utf-8
  3. Accept-Encoding: gzip,deflate,sdch
  4. Connection: keep-alive
  5. Host: google.com
  6. Origin: http://javascript.info
  7. Referer: http://javascript.info/some/url

正如你所看到的,存在 Referer 和 Origin

问题是:

  1. 为什么需要 Origin,如果 Referer 甚至具有更多信息?
  2. 如果这里没有 Referer 或 Origin 可行吗,还是说会出问题?

解决方案

我们需要 Origin,是因为有时会没有 Referer。例如,当我们从 HTTPS(从高安全性访问低安全性)fetch HTTP 页面时,便没有 Referer

内容安全策略 可能会禁止发送 Referer

正如我们将看到的,fetch 也具有阻止发送 Referer 的选项,甚至允许修改它(在同一网站内)。

根据规范,Referer 是一个可选的 HTTP-header。

正是因为 Referer 不可靠,才发明了 Origin。浏览器保证跨源请求的正确 Origin

  • 30
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值