Fetch
基本用法
新的通用网络请求是fetch()方法。
基本语法:
js1let promise = fetch(url, [options])
- url —访问的 URL
- options —可选参数:method、header 等
如果不传 options,则默认为 get 请求。
fetch方法天然能够获取promise对象。
获取响应时,fetch 需要做以下事情:
-
服务器发送了响应头,
fetch使用Response class对象对响应头进行解析,并且会返回一个被Promise包裹着的response实例。在这个阶段,我们可以检查响应头来确定请求是否成功。
如果
fetch没办法建立 HTTP 请求,比如网络问题,那么会返回一个 reject 的 promise。异常的 HTTP 状态,比如 404 或者 500 等实际上都跟服务端做了交互,那么不会导致 error。
我们可以通过 response 属性来查看 HTTP 状态:
status——HTTP 状态码ok—— 布尔值,如果 HTTP 状态码是 200-299,则为true
-
上面的方法并没有获取到
response body,我们还需要调用方法来获取Response提供多种方法,用不同的格式来拿到 bodyresponse.text()—— 读取 response,并以文本形式返回 responseresponse.json()—— 将 response 解析为 JSONresponse.formData()—— 以FormData对象的形式返回 responseresponse.blob()—— 以 Blob形式返回 responseresponse.arrayBuffer()—— 以 ArrayBuffer形式返回 response
response.body是可读流(ReadableStream)对象,我们可以逐块读取 body。
一个完整的请求示例:
js1let response = await fetch(url) 2 3if (response.ok) { 4 // 如果 HTTP 状态码为 200-299 5 // 获取 response body,将 body 转换成 json 6 let json = await response.json() 7} else { 8 alert('HTTP-Error: ' + response.status) 9}
也可以只用 promise 语法,不使用 await
js1fetch(url).then(response=>response.json()).then(...)
使用 fetch 来获取 blob 不需要像 XHR 一样指定 responseType,直接调用response.blob()即可:
js1let response = await fetch('/article/fetch/logo-fetch.svg') 2 3let blob = await response.blob() // 下载为 Blob 对象 4 5// 为其创建一个 <img> 6let img = document.createElement('img') 7img.style = 'position:fixed;top:10px;left:10px;width:100px' 8document.body.append(img) 9 10// 显示它 11img.src = URL.createObjectURL(blob) 12// 允许浏览器删除内存映射 13URL.revokeObjectURL(img.src)
我们只能选择一种读取 body 的方法。
如果我们已经使用了
response.text()方法来获取 response,那么如果再用response.json(),则不会生效,因为 body 内容已经被处理过了。
Response header
response header类似 Map 对象,我们可以使用get()来获取到它的每一项header属性,也可以迭代它们:
js1let response = await fetch('https://api.github.com/repos/javascript-tutorial/en.javascript.info/commits') 2 3// 获取一个 header 4alert(response.headers.get('Content-Type')) // application/json; charset=utf-8 5 6// 迭代所有 header 7for (let [key, value] of response.headers) { 8 alert(`${key} = ${value}`) 9}
Request header
在fetch的第二个参数中,我们可以设置请求头。
js1let response = fetch(protectedUrl, { 2 headers: { 3 Authentication: 'secret', 4 }, 5})
为了保证 HTTP 的正确性和安全性,以下属性只能由浏览器控制,我们不能手动发送:
POST 请求
要创建 POST 请求,我们需要设置两个 option:
- method ——HTTP 方法
- body —— 请求体,可以是:
- 字符串(例如 JSON 编码的)
- FormData 对象
Blob/BufferSource发送二进制数据- URLSearchParams,以
x-www-form-urlencoded编码形式发送数据,很少使用。
JSON 形式目前是最为广泛使用的:
js1let user = { 2 name: 'John', 3 surname: 'Smith', 4} 5 6let response = await fetch('/article/fetch/post/user', { 7 method: 'POST', 8 headers: { 9 'Content-Type': 'application/json;charset=utf-8', 10 }, 11 body: JSON.stringify(user), 12}) 13 14let result = await response.json()
这里需要注意的点是,当我们发送的是字符串时,fetch 会默认替我们设置成text/plain;charset=UTF-8,所以如果我们想要发送 JSON,则必须指定对应的 Content-type
发送图片
下面的例子是通过fetch提交二进制数据。
我们先将canvas进行描绘,然后通过 canvasElement.toBlob将其转化成图片格式,最后发送给服务器。
html1<body style="margin:0"> 2 <canvas id="canvasElem" width="100" height="80" style="border:1px solid"></canvas> 3 4 <input type="button" value="Submit" onclick="submit()" /> 5 6 <script> 7 canvasElem.onmousemove = function (e) { 8 let ctx = canvasElem.getContext('2d') 9 ctx.lineTo(e.clientX, e.clientY) 10 ctx.stroke() 11 } 12 13 async function submit() { 14 let blob = await new Promise((resolve) => canvasElem.toBlob(resolve, 'image/png')) 15 let response = await fetch('/article/fetch/post/image', { 16 method: 'POST', 17 body: blob, 18 }) 19 20 // 服务器给出确认信息和图片大小作为响应 21 let result = await response.json() 22 alert(result.message) 23 } 24 </script> 25</body>
这里不需要设置Content-Type header,因为 Blob 对象具有内建的类型(这里是 image/png,通过 toBlob 生成的)。对于 Blob 对象,这个类型就变成了 Content-Type 的值。
小结
典型的fetch请求需要做两个步骤:
js1let response = await fetch(url, options) // 解析 response header 2let result = await response.json() // 将 body 读取为 json
换成promise形式则是这样的:
js1fetch(url, options) 2 .then(response => response.json()) 3 .then(result => /* process result */)
当解析 response header 时,我们可以获取到响应状态码response.status、response.ok、response.headers等属性。
需要解析 response body 时,需要调用对应的方法:
response.text()—— 读取 response,并以文本形式返回 responseresponse.json()—— 将 response 解析为 JSONresponse.formData()—— 以FormData对象的形式返回 responseresponse.blob()—— 以 Blob形式返回 responseresponse.arrayBuffer()—— 以 ArrayBuffer形式返回 response
当发送请求时,我们可以设置:
method——请求方法headers——请求头body——以string,FormData,BufferSource,Blob或UrlSearchParams对象的形式发送的数据(request body)
FormData
FormData 对象是 HTML 表单数据的对象。
通过构造函数可以创建一个 formData 实例:
javascript1let formData = new FormData([form])
HTML 的 form 元素会自动捕获 form 元素字段
fetch 可以接受一个 formData 作为 body。它被编码出去后,会带上Content-Type: multipart/form-data。
下面例子中,采用FormData 构造器,接受 HTML 的表单元素作为参数,发送一个 formData 给服务端
js1<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 7<script> 8 formElem.onsubmit = async (e) => { 9 e.preventDefault(); 10 11 let response = await fetch('/article/formdata/post/user', { 12 method: 'POST', 13 body: new FormData(formElem) 14 }); 15 16 let result = await response.json(); 17 18 alert(result.message); 19 }; 20</script>
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。formData.set(name,value)——同append方法formData.set(name,blob,fileName)——同append方法
append和set方法的区别是append方法可以添加多个具有相同名称的字段。
而set方法会移除所有name字段,并附加一个新字段。它能够确保只有一个name字段。
我们也可以使用for..of来循环迭代 formData 字段
javascript1let formData = new FormData() 2formData.append('key1', 'value1') 3formData.append('key2', 'value2') 4 5// 列出 key/value 对 6for (let [name, value] of formData) { 7 alert(`${name} = ${value}`) // key1 = value1,然后是 key2 = value2 8}
发送带有文件的表单
表单始终以 Content-Type: multipart/form-data 来发送数据,这个编码允许发送文件。因此 <input type="file"> 字段也能被发送,类似于普通的表单提交。
html1<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 7<script> 8 formElem.onsubmit = async (e) => { 9 // 防止表单提交后页面刷新 10 e.preventDefault() 11 12 let response = await fetch('/article/formdata/post/user-avatar', { 13 method: 'POST', 14 body: new FormData(formElem), 15 }) 16 17 let result = await response.json() 18 19 alert(result.message) 20 } 21</script>
发送具有 Blob 数据的表单
通常情况下,发送文件的方式不是单独发送,而是作为表单的一部分发送,并带有附加字段,例如:name。
服务器通常更适合接收多部分编码的表单(multipart-encoded form),而不是原始的二进制数据。
下面改写一下在fetch中发送 canvas 生成的图片的示例代码:
javascript1<body style="margin:0"> 2 <canvas id="canvasElem" width="100" height="80" style="border:1px solid"></canvas> 3 4 <input type="button" value="Submit" onclick="submit()"> 5 6 <script> 7 canvasElem.onmousemove = function(e) { 8 let ctx = canvasElem.getContext('2d'); 9 ctx.lineTo(e.clientX, e.clientY); 10 ctx.stroke(); 11 }; 12 13 async function submit() { 14 let imageBlob = await new Promise(resolve => canvasElem.toBlob(resolve, 'image/png')); 15 16 let formData = new FormData(); 17 formData.append("firstName", "John"); 18 formData.append("image", imageBlob, "image.png"); 19 20 let response = await fetch('/article/formdata/post/image-form', { 21 method: 'POST', 22 body: formData 23 }); 24 let result = await response.json(); 25 alert(result.message); 26 } 27 28 </script> 29</body>
在这里主要使用formData.append("image", imageBlob, "image.png");来添加 Blob。
就像表单中有 <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)
请注意 append 和 set 的区别。
如果我们需要发送文件,那么就需要使用三个参数的语法,最后一个参数是文件名,通常是从文件系统获取的。
其他方法是:
formData.delete(name)formData.get(name)formData.has(name)
用 Fetch 实现下载进度
fetch方法没办法跟踪上传进度,但是可以跟踪下载进度。
我们可以使用response.body属性,这是一个可读流——ReadableStream,它可以逐块(chunk)提供 body。
我们在下载时可以通过response.body.getReader()方法来获取流读取器,然后通过这个流读取器来计算下载了多少。
大概过程是这样的:
js1// 代替 response.json() 以及其他方法 2const reader = response.body.getReader() 3 4// 在 body 下载时,一直为无限循环 5while (true) { 6 // 当最后一块下载完成时,done 值为 true 7 // value 是块字节的 Uint8Array 8 const { done, value } = await reader.read() 9 10 if (done) { 11 break 12 } 13 14 console.log(`Received ${value.length} bytes`) 15}
调用await reader.read()方法会得到两个属性的对象:
done——读取完成则为true,否则为falsevalue——字节的类型化数组:Uint8Array
由于浏览器问题,上面循环异步迭代
ReadableStream的方式不使用for await..of,而是while循环
简单来说,我们需要在循环中接受响应块(response chunk),直到加载完成,也就是 done 为 true。
要将进度打印出来,我们只需要将每个接收到的片段 value 的长度(length)加到 counter 即可。
步骤是这样的:
js1// Step 1:启动 fetch,并获得一个 reader 2let response = await fetch('https://api.github.com/repos/javascript-tutorial/en.javascript.info/commits?per_page=100') 3 4const reader = response.body.getReader() 5 6// Step 2:获得总长度(length) 7const contentLength = +response.headers.get('Content-Length') 8 9// Step 3:读取数据 10let receivedLength = 0 // 当前接收到了这么多字节 11let chunks = [] // 接收到的二进制块的数组(包括 body) 12while (true) { 13 const { done, value } = await reader.read() 14 15 if (done) { 16 break 17 } 18 19 chunks.push(value) 20 receivedLength += value.length 21 22 console.log(`Received ${receivedLength} of ${contentLength}`) 23} 24 25// Step 4:将块连接到单个 Uint8Array 26let chunksAll = new Uint8Array(receivedLength) // 创建一个具有所有数据块合并后的长度的同类型数组。 27let position = 0 28for (let chunk of chunks) { 29 chunksAll.set(chunk, position) // 使用 .set(chunk, position) 方法,从数组中一个个地复制这些 chunk 30 position += chunk.length 31} 32 33// Step 5:解码成字符串 34let result = new TextDecoder('utf-8').decode(chunksAll) 35 36// 我们完成啦! 37let commits = JSON.parse(result) 38alert(commits[0].author.login)
步骤解读:
-
使用 fetch 获取数据,但不调用
response.json(),而是获取一个流读取器(stream reader)response.body.getReader()要么使用流读取器,要么使用
response方法,不能同时用两种方法读取相同响应。 -
在读取数据之前,从
Content-Length中获取完整的响应长度。 -
调用
await reader.read(),直到结果为done:true。这时候需要将响应收集到数组
chunks中。因为我们不能再用response.json来读取响应内容,所以需要有个地方把它存起来。 -
拥有了
chunks结果之后,实际上里面是一段一段的Unit8Array的字节块。-
如果我们想要创建二进制内容(比如图片、文件),就可以使用
Blob类来创建一个Blob对象。Blob类直接可以接收内含Unit8Array字节块的数组,这里可以写为:js1let blob = new Blob(chunks) -
如果我们想要做其他事情,比如将字节块解析成一段字符串。我们首先需要将这些字节块拼起来:
这里使用一些代码来将其串联起来:
- 创建
chunksAll = new Uint8Array(receivedLength)——一个具有所有数据块合并后的长度的同类型数组 - 使用
.set(chunk,position)方法,从这些数组中挨个复制这些chunk - 最后将结果拷贝到
chunksAll中,但它们是字节数组,并不是字符串,我们需要解析这些字节——可以使用内建的TextDecoder对象来完成。
- 创建
-
以上,这就是使用fetch来跟踪下载进度的过程。
用 Fetch 实现请求中止(Abort)
JavaScript 没有中止Promise的概念。但我们可以取消fetch请求。
有一个特殊的内置对象AbortController,它不单可以中止fetch,还可以中止其他异步任务。
AbortController 对象用法
创建一个控制器:
js1let controller = new AbortController()
控制器中有一个属性和一个方法:
abort()方法signal属性,我们可以在这个属性上设置事件监听器
当abort被调用时:
controller.signal会触发abort事件controller.signal.aborted属性变为true。
这个处理方式需要我们分两部分去做:
- 在
controller.signal上设置一个监听器,里面放一个取消操作后的回调函数 - 调用
controller.abort()来取消
下面是一个取消setTimeout的例子:
html1<body> 2 <button id="button">点击我取消异步任务</button> 3 <script> 4 let controller = new AbortController() 5 let signal = controller.signal 6 let timer = setTimeout(() => { 7 alert('这是 setTimeout 异步执行的任务') 8 }, 3000) 9 // 可取消的操作这一部分 10 // 获取 "signal" 对象, 11 // 并将监听器设置为在 controller.abort() 被调用时触发 12 signal.addEventListener('abort', () => clearTimeout(timer)) 13 button.onclick = function () { 14 // 另一部分,取消(在之后的任何时候): 15 controller.abort() // 中止! 16 // 事件触发,signal.aborted 变为 true 17 alert(signal.aborted) // true 18 } 19 </script> 20</body>
上面的代码会在点击 button 后取消setTimeout异步任务。
其实就是在abort()后触发监听器里面的函数,跟正常的发布订阅模式没有区别。
上面的实现完全没必要用到AbortController对象也可以实现。
但这个对象有意思的地方在于与 fetch 的集成。
与 fetch 集成实现取消请求的功能
fetch 的 options 参数可以接受一个signal属性,我们可以将AbortController 的 signal 属性传递进去:
js1let controller = new AbortController() 2fetch(url, { 3 signal: controller.signal, 4})
这时候fetch会监听signal的 abort 事件。当我们想要中止fetch时,这样调用:
javascript1controller.abort()
然后fetch就从 signal 获取了事件并中止了请求。
当fetch被中止时,它的promise就会reject一个name为AbortError的error,我们需要用try..catch进行捕获。
js1// 1 秒后中止 2let controller = new AbortController() 3setTimeout(() => controller.abort(), 1000) 4 5try { 6 let response = await fetch('/article/fetch-abort/demo/hang', { 7 signal: controller.signal, 8 }) 9} catch (err) { 10 if (err.name == 'AbortError') { 11 // handle abort() 12 alert('Aborted!') 13 } else { 14 throw err 15 } 16}
并行取消多个 fetch
AbortController 是可伸缩的。它允许一次取消多个 fetch。
js1let urls = [...]; // 要并行 fetch 的 url 列表 2 3let controller = new AbortController(); 4 5// 一个 fetch promise 的数组 6let fetchJobs = urls.map(url => fetch(url, { 7 signal: controller.signal 8})); 9 10let results = await Promise.all(fetchJobs); 11 12// controller.abort() 被从任何地方调用, 13// 它都将中止所有 fetch
上面的代码能够并行 fetch 很多个 urls,并使用单个控制器使其全部中止。
如果我们有自己的与 fetch 不同的异步任务,我们可以使用单个 AbortController 中止这些任务以及 fetch。
js1let urls = [...]; 2let controller = new AbortController(); 3 4let ourJob = new Promise((resolve, reject) => { // 我们的任务 5 ... 6 controller.signal.addEventListener('abort', reject); 7}); 8 9let fetchJobs = urls.map(url => fetch(url, { // fetches 10 signal: controller.signal 11})); 12 13// 等待完成我们的任务和所有 fetch 14let results = await Promise.all([...fetchJobs, ourJob]); 15 16// controller.abort() 被从任何地方调用, 17// 它都将中止所有 fetch 和 ourJob
小结
-
AbortController是一个简单对象,当abort()方法被调用时,会调用自身signal属性监听的abort事件,并将singnal.aborted设置为true -
signal可以传递给 fetch 的options.signal属性,这样 fetch 就能够监听到他,因此可以中断fetch -
我们也可以在自己的代码中使用
AbortController,先监听abort事件,在调用abort()方法后触发abort事件来中止某些任务