目前为止,我们已经对 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 一章中详细介绍了 method
,headers
和 body
。
在 Fetch:中止(Abort) 一章中介绍了 signal
选项。
现在让我们学习其余的本领。
referrer,referrerPolicy
这些选项决定了 fetch
如何设置 HTTP 的 Referer
header。
通常来说,这个 header 是被自动设置的,并包含了发出请求的页面的 url。在大多数情况下,它一点也不重要,但有时出于安全考虑,删除或缩短它是有意义的。
referer
选项允许设置在当前域的任何 Referer
,或者移除它。
要不发送 referer,可以将 referer
设置为空字符串:
fetch('/page', {
referrer: "" // 没有 Referer header
});
设置在当前域内的另一个 url:
fetch('/page', {
// 假设我们在 https://javascript.info
// 我们可以设置任何 Referer header,但必须是在当前域内的
referrer: "https://javascript.info/anotherpage"
});
referrerPolicy
选项为 Referer
设置一般的规则。
请求分为 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 路径,我们可以这样设置选项:
fetch('https://another.com/page', {
// ...
referrerPolicy: "origin-when-cross-origin" // Referer: https://javascript.info
});
我们可以将其置于所有 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 缓存。就是说,它遵从 Expires
,Cache-Control
header,发送 If-Modified-Since
,等。就像常规的 HTTP 请求那样。
使用 cache
选项可以忽略 HTTP 缓存或者对其用法进行微调:
"default"
——fetch
使用标准的 HTTP 缓存规则和 header,"no-store"
—— 完全忽略 HTTP 缓存,如果我们设置 headerIf-Modified-Since
,If-None-Match
,If-Unmodified-Since
,If-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
选项中,就像这样:
fetch('http://site.com/file', {
integrity: 'sha256-abcdef'
});
然后 fetch
将自行计算 SHA-256 并将其与我们的字符串进行比较。如果不匹配,则会触发错误。
keepalive
keepalive
选项表示该请求可能会使发起它的网页“失活(outlive)”。
例如,我们收集有关当前访问者是如何使用我们的页面(鼠标点击,他查看的页面片段)的统计信息,以分析和改善用户体验。
当访问者离开我们的网页时 —— 我们希望能够将数据保存到我们的服务器上。
我们可以使用 window.onunload
事件来实现:
window.onunload = function() {
fetch('/analytics', {
method: 'POST',
body: "statistics",
keepalive: true
});
};
通常,当一个文档被卸载时(unloaded),所有相关的网络请求都会被中止。但是,keepalive
选项告诉浏览器,即使在离开页面后,也要在后台执行请求。所以,此选项对于我们的请求成功至关重要。
它有一些限制:
FormData 对象可以提供帮助。你可能已经猜到了,它是表示 HTML 表单数据的对象。
构造函数是:
let formData = new FormData([form]);
如果提供了 HTML form
元素,它会自动捕获 form
元素字段。
FormData
的特殊之处在于网络方法(network methods),例如 fetch
可以接受一个 FormData
对象作为 body。它会被编码并发送出去,带有 Content-Type: multipart/form-data
。
从服务器角度来看,它就像是一个普通的表单提交。
发送一个简单的表单
我们先来发送一个简单的表单。
正如你所看到的,它几乎就是一行代码:
<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>
在这个示例中,没有将服务器代码展示出来,因为它超出了我们当前的学习范围。服务器接受 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 字段:
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
}
发送带有文件的表单
表单始终以 Content-Type: multipart/form-data
来发送数据,这个编码允许发送文件。因此 <input type="file">
字段也能被发送,类似于普通的表单提交。
这是具有这种形式的示例:
<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>
发送具有 Blob 数据的表单
正如我们在 Fetch 一章中所看到的,以 Blob
发送一个动态生成的二进制数据,例如图片,是很简单的。我们可以直接将其作为 fetch
参数的 body
。
但在实际中,通常更方便的发送图片的方式不是单独发送,而是将其作为表单的一部分,并带有附加字段(例如 “name” 和其他 metadata)一起发送。
并且,服务器通常更适合接收多部分编码的表单(multipart-encoded form),而不是原始的二进制数据。
下面这个例子使用 FormData
将一个来自 <canvas>
的图片和一些其他字段一起作为一个表单提交:
<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
是如何添加的:
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)
让我们在这里注意两个特点:
set
方法会移除具有相同名称(name)的字段,而append
不会。- 要发送文件,需要使用三个参数的语法,最后一个参数是文件名,一般是通过
<input type="file">
从用户文件系统中获取的。
其他方法是:
formData.delete(name)
formData.get(name)
JavaScript 可以将网络请求发送到服务器,并在需要时加载新信息。
例如,我们可以使用网络请求来:
- 提交订单,
- 加载用户信息,
- 从服务器接收最新的更新,
- ……等。
……所有这些都没有重新加载页面!
对于来自 JavaScript 的网络请求,有一个总称术语 “AJAX”(Asynchronous JavaScript And XML 的简称)。但是,我们不必使用 XML:这个术语诞生于很久以前,所以这个词一直在那儿。
有很多方式可以向服务器发送网络请求,并从服务器获取信息。
fetch()
方法是一种现代通用的方法,那么我们就从它开始吧。旧版本的浏览器不支持它(可以 polyfill),但是它在现代浏览器中的支持情况很好。
基本语法:
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
。
例如:
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 对象:
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
:
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()
:
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):
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 内容已经被处理过了。
let text = await response.text(); // response body 被处理了
let parsed = await response.json(); // 失败(已经被处理过了)
Response header
Response header 位于 response.headers
中的一个类似于 Map 的 header 对象。
它不是真正的 Map,但是它具有类似的方法,我们可以按名称(name)获取各个 header,或迭代它们:
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}`);
}
Request header
要在 fetch
中设置 request header,我们可以使用 headers
选项。它有一个带有输出 header 的对象,如下所示:
let response = fetch(protectedUrl, {
headers: {
Authentication: 'secret'
}
});
……但是有一些我们无法设置的 header(详见 forbidden HTTP headers):
Accept-Charset
,Accept-Encoding
Access-Control-Request-Headers
Access-Control-Request-Method
Connection
Content-Length
Cookie
,Cookie2
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
对象:
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);
请注意,如果请求的 body
是字符串,则 Content-Type
会默认设置为 text/plain;charset=UTF-8
。
但是,当我们要发送 JSON 时,我们会使用 headers
选项来发送 application/json
,这是 JSON 编码的数据的正确的 Content-Type
。
发送图片
我们同样可以使用 Blob
或 BufferSource
对象通过 fetch
提交二进制数据。
例如,这里有一个 <canvas>
,我们可以通过在其上移动鼠标来进行绘制。点击 “submit” 按钮将图片发送到服务器:
<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()
函数,像这样:
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');
}
总结
典型的 fetch 请求由两个 await
调用组成:
let response = await fetch(url, options); // 解析 response header
let result = await response.json(); // 将 body 读取为 json
或者以 promise 形式:
fetch(url, options)
.then(response => response.json())
.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
—— 要以string
,FormData
,BufferSource
,Blob
或UrlSearchParams
对象的形式发送的数据(request body)。
在下一章,我们将会看到更多 fetch
的选项和用例。
任务
从 GitHub fetch 用户信息
创建一个异步函数 getUsers(names)
,该函数接受 GitHub 登录名数组作为输入,查询 GitHub 以获取有关这些用户的信息,并返回 GitHub 用户数组。
带有给定 USERNAME
的用户信息的 GitHub 网址是:https://api.github.com/users/USERNAME
。
沙箱中有一个测试用例。
重要的细节:
- 对每一个用户都应该有一个
fetch
请求。 - 请求不应该相互等待。以便能够尽快获取到数据。
- 如果任何一个请求失败了,或者没有这个用户,则函数应该返回
null
到结果数组中。
解决方案
要获取一个用户,我们需要:fetch('https://api.github.com/users/USERNAME')
.
如果响应的状态码是 200
,则调用 .json()
来读取 JS 对象。
否则,如果 fetch
失败,或者响应的状态码不是 200
,我们只需要向结果数组返回 null
即可。
代码如下:
async function getUsers(names) {
let jobs = [];
for(let name of names) {
let job = fetch(`https://api.github.com/users/${name}`).then(
successResponse => {
if (successResponse.status != 200) {
return null;
} else {
return successResponse.json();
}
},
failResponse => {
return null;
}
);
jobs.push(job);
}
let results = await Promise.all(jobs);
return results;
}
请注意:.then
调用紧跟在 fetch
后面,这样,当我们收到响应时,它不会等待其他的 fetch,而是立即开始读取 .json()
。
如果我们使用 await Promise.all(names.map(name => fetch(...)))
,并在 results
上调用 .json()
方法,那么它将会等到所有 fetch 都获取到响应数据才开始解析。通过将 .json()
直接添加到每个 fetch
中,我们就能确保每个 fetch 在收到响应时都会立即开始以 JSON 格式读取数据,而不会彼此等待。
如果我们向另一个网站发送 fetch
请求,则该请求可能会失败。
例如,让我们尝试向 http://example.com
发送 fetch
请求:
try {
await fetch('http://example.com');
} catch(err) {
alert(err); // fetch 失败
}
正如所料,获取失败。
这里的核心概念是 源(origin)—— 域(domain)/端口(port)/协议(protocol)的组合。
跨源请求 —— 那些发送到其他域(即使是子域)、协议或端口的请求 —— 需要来自远程端的特殊 header。
这个策略被称为 “CORS”:跨源资源共享(Cross-Origin Resource Sharing)。
为什么需要 CORS?跨源请求简史
CORS 的存在是为了保护互联网免受黑客攻击。
说真的,在这说点儿题外话,讲讲它的历史。
多年来,来自一个网站的脚本无法访问另一个网站的内容。
这个简单有力的规则是互联网安全的基础。例如,来自 hacker.com
的脚本无法访问 gmail.com
上的用户邮箱。基于这样的规则,人们感到很安全。
在那时候,JavaScript 并没有任何特殊的执行网络请求的方法。它只是一种用来装饰网页的玩具语言而已。
但是 Web 开发人员需要更多功能。人们发明了各种各样的技巧去突破该限制,并向其他网站发出请求。
使用表单
其中一种和其他服务器通信的方法是在那里提交一个 <form>
。人们将它提交到 <iframe>
,只是为了停留在当前页面,像这样:
<!-- 表单目标 -->
<iframe name="iframe"></iframe>
<!-- 表单可以由 JavaScript 动态生成并提交 -->
<form target="iframe" method="POST" action="http://another.com/…">
...
</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
网站获取数据,例如天气:
-
首先,我们先声明一个全局函数来接收数据,例如
gotWeather
。// 1. 声明处理天气数据的函数
function gotWeather({ temperature, humidity }) {
alert(`temperature: ${temperature}, humidity: ${humidity}`);
}
-
然后我们创建一个特性(attribute)为
src="http://another.com/weather.json?callback=gotWeather"
的<script>
标签,使用我们的函数名作为它的callback
URL-参数。let script = document.createElement('script');
script.src = `http://another.com/weather.json?callback=gotWeather`;
document.body.append(script);
-
远程服务器
another.com
动态生成一个脚本,该脚本调用gotWeather(...)
,发送它想让我们接收的数据。// 我们期望来自服务器的回答看起来像这样:
gotWeather({
temperature: 25,
humidity: 78
});
-
当远程脚本加载并执行时,
gotWeather
函数将运行,并且因为它是我们的函数,我们就有了需要的数据。
这是可行的,并且不违反安全规定,因为双方都同意以这种方式传递数据。而且,既然双方都同意这种行为,那这肯定不是黑客攻击了。现在仍然有提供这种访问的服务,因为即使是非常旧的浏览器它依然适用。
不久之后,网络方法出现在了浏览器 JavaScript 中。
起初,跨源请求是被禁止的。但是,经过长时间的讨论,跨源请求被允许了,但是任何新功能都需要服务器明确允许,以特殊的 header 表述。
简单的请求
有两种类型的跨源请求:
- 简单的请求。
- 所有其他请求。
顾名思义,简单的请求很简单,所以我们先从它开始。
一个 简单的请求 是指满足以下两个条件的请求:
- 简单的方法:GET,POST 或 HEAD
- 简单的 header —— 仅允许自定义下列 header:
Accept
,Accept-Language
,Content-Language
,Content-Type
的值为application/x-www-form-urlencoded
,multipart/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 将会如下:
GET /request
Host: anywhere.com
Origin: https://javascript.info
...
正如你所见,Origin
包含了确切的源(domain/protocol/port),没有路径。
服务器可以检查 Origin
,如果同意接受这样的请求,就会在响应中添加一个特殊的 header Access-Control-Allow-Origin
。该 header 包含了允许的源(在我们的示例中是 https://javascript.info
),或者一个星号 *
。然后响应成功,否则报错。
浏览器在这里扮演受被信任的中间人的角色:
- 它确保发送的跨源请求带有正确的
Origin
。 -
它检查响应中的许可
Access-Control-Allow-Origin
,如果存在,则允许 JavaScript 访问响应,否则将失败并报错。
这是一个带有服务器许可的响应示例:
200 OK
Content-Type:text/html; charset=UTF-8
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 名称列表。
例如:
200 OK
Content-Type:text/html; charset=UTF-8
Content-Length: 12345
API-Key: 2c9de507f2c54aa1
Access-Control-Allow-Origin: https://javascript.info
Access-Control-Expose-Headers: Content-Length,API-Key
有了这种 Access-Control-Expose-Headers
header,此脚本就被允许读取响应的 Content-Length
和 API-Key
header。
“非简单”请求
我们可以使用任何 HTTP 方法:不仅仅是 GET/POST
,也可以是 PATCH
,DELETE
及其他。
之前,没有人能够设想网页能发出这样的请求。因此,可能仍然存在有些 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
请求(此方法经常被用于更新数据):
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-urlencoded
,multipart/form-data
,text/plain
。- “非简单”
API-Key
header。
Step 1 预检请求(preflight request)
在发送我们的请求前,浏览器会自己发送如下所示的预检请求:
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 列表。
Step 2 预检响应(preflight response)
服务应响应状态 200 和 header:
Access-Control-Allow-Methods: PATCH
Access-Control-Allow-Headers: Content-Type,API-Key
。
这将允许后续通信,否则会触发错误。
如果服务器将来期望其他方法和 header,则可以通过添加到列表中来预先允许它们:
200 OK
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
现在,浏览器可以看到 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(因为它是跨源的):
PATCH /service.json
Host: site.com
Content-Type: application/json
API-Key: secret
Origin: https://javascript.info
Step 4 实际响应(actual response)
服务器不应该忘记在主响应中添加 Access-Control-Allow-Origin
。成功的预检并不能免除此要求:
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"
选项,像这样:
fetch('http://another.com', {
credentials: "include"
});
现在,fetch
将把源自 another.com
的 cookie 和我们的请求发送到该网站。
如果服务器同意接受 带有凭据 的请求,则除了 Access-Control-Allow-Origin
外,服务器还应该在响应中添加 header Access-Control-Allow-Credentials: true
。
例如:
200 OK
Access-Control-Allow-Origin: https://javascript.info
Access-Control-Allow-Credentials: true
请注意:对于具有凭据的请求,禁止 Access-Control-Allow-Origin
使用星号 *
。如上所示,它必须有一个确切的源。这是另一项安全措施,以确保服务器真的知道它信任的发出此请求的是谁。
总结
从浏览器角度来看,有两种跨源请求:“简单”请求和其他请求。
简单请求 必须满足下列条件:
- 方法:GET,POST 或 HEAD。
- header —— 我们仅能设置:
Accept
Accept-Language
Content-Language
Content-Type
的值为application/x-www-form-urlencoded
,multipart/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-Control
,Content-Language
,Content-Type
,Expires
,Last-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 看起来如下:
Accept: */*
Accept-Charset: utf-8
Accept-Encoding: gzip,deflate,sdch
Connection: keep-alive
Host: google.com
Origin: http://javascript.info
Referer: http://javascript.info/some/url
正如你所看到的,存在 Referer
和 Origin
。
问题是:
- 为什么需要
Origin
,如果Referer
甚至具有更多信息? - 如果这里没有
Referer
或Origin
可行吗,还是说会出问题?
解决方案
我们需要 Origin
,是因为有时会没有 Referer
。例如,当我们从 HTTPS(从高安全性访问低安全性)fetch
HTTP 页面时,便没有 Referer
。
内容安全策略 可能会禁止发送 Referer
。
正如我们将看到的,fetch
也具有阻止发送 Referer
的选项,甚至允许修改它(在同一网站内)。
根据规范,Referer
是一个可选的 HTTP-header。
正是因为 Referer
不可靠,才发明了 Origin
。浏览器保证跨源请求的正确 Origin
。