Fetch API 的使用和采坑记录

1,437 阅读6分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第1天,点击查看活动详情

比 XHR 好用的 Fetch

在说 Fetch 和 XHR 之前,不得不先提一提 Axios,前端同学天天用它发送请求。所谓 Axios 其实是基于 Promise 封装的 HTTP 库。本质也是对原生 XMLHttpRequest 的封装。而原生的 XHR 由于编写过于麻烦,已被 axios 取代了,所以作为 XHR 的升级版,Fetch 应运而生,而它也确实提供了更强大和灵活的功能集。

Fetch 的使用

Fetch 天生就支持 Promise,这让它写起来非常方便,不用像 XHR 一样去判断 readyState 的各种状态:

fetch('http://www.xxx.com/')
  .then(...)
  .then(...)
  .catch(...);

当然,使用 asynctry...catch 也是很香的。

返回的结果

注意,Fetch 会返回一个 Response 对象,以此呈现对一次请求的响应数据。以下罗列出一些常用的:

属性类型说明
okbooleantrue 表示成功(HTTP 状态码的范围200-299)
bodyReadableStream可读取的二进制内容,里面放着后端返回的数据
statusnumber状态码
statusTextstring与该 Response 状态码一致的状态信息
urlstring请求地址
headersHeaders请求头信息(看不到,通过 get 读取)

其中,需要注意的是:只有当网络故障时或请求被阻止时,才会标记为 rejected。也就是说,当接收到 404 或 500 这种表示错误的 HTTP 状态码时,返回的 Promise 也是正常的 fulfilled 状态 ,只不过此时 ok 属性为 false。

读取 Response

Response 提供了很多读取 ReadableStream 的方法:arraybuffer()blob()formData()json() text()等,具体请自行参考文档。这里我们使用真实的例子,来介绍下 json() 的使用:

fetch('https://api.github.com/repos/javascript-tutorial/en.javascript.info/commits')
  .then(resp => {
    console.log('response: ', resp)
    const data = resp.json()
    console.log('data: ', data)
    return data
  })
  .then(data => alert(data[0].sha));

fetch_response.png 可以看到,json()最终读取并返回一个 JSON 格式的 Promise 对象。

请求参数的设置

fetch() 接受第二个可选参数,一个可以控制不同配置的 init 对象,可参见 fetch() 查看所有可选的配置和更多描述。 常用属性如下:

属性类型说明
methodstring请求方法,如 GET、POST
headersHeaders请求头,Headers 对象
bodyBlob/FormData/BufferSource 等请求的 body 信息
modestring请求模式 cors、no-cors、same-origin
cachestring请求的缓存模式,default、no-store、reload、no-cache、force-cache、only-if-cached
credentialsstring请求的凭证,如 omit、same-origin、include

其中,需要注意的是 credentials 属性,它的默认值是 same-origin,即只有同源才发送 cookie。也就是说,fetch 不会自动发送跨域 cookie,所以为了在当前域名内自动发送跨域 cookie,必须配置 credentials: 'include'

Post 请求

Content-Type 为例,这里我们介绍 application/jsonmutipart-formdata 两种形式的 post 请求。

上传 json 数据

const data = { username: 'example' };

fetch('https://example.com/profile', {
  method: 'POST', // or 'PUT'
  headers: { 'Content-Type': 'application/json' },
  credentials: 'include',
  body: JSON.stringify(data)
})
.then(response => response.json())
.then(data => {
  console.log('Success:', data);
})
.catch((error) => {
  console.error('Error:', error);
});

上传文件

const fileField = document.querySelector('input[type="file"]');
const formData = new FormData();
formData.append('username', 'abc123');
formData.append('avatar', fileField.files[0]);

fetch('https://example.com/profile/avatar', {
  method: 'POST',
  body: formData,
  credentials: 'include'
  // 注意:这里并没有额外设置 Content-Type: 'multipart/form-data'
})
.then(response => response.json())
.then(result => {
  console.log('Success:', result);
})
.catch(error => {
  console.error('Error:', error);
});

上传文件时遇到的坑

一、multipart/form-data 必须的 boundary 属性

在实际开发中,我发现:如果上传文件时,设置了 multipart/form-data,请求会失败,让后端同学帮忙定位了一下问题,说是请求首部字段 Content-Type 中缺少了boundary

于是翻阅了 MDN 文档,找到了对于 boundary 的如下说明:

对于多部分实体,boundary 是必需的,其包括来自一组字符的 1 到 70 个字符,已知通过电子邮件网关是非常健壮的,而不是以空白结尾。它用于封装消息的多个部分的边界。

但是,文档并没有说明如何设置 boundary 属性。随后,我又找到一篇关于 如何在 multipart/form-data 中设置 boundary 的文章:

At this moment there is no way to set up boundary for FormData.

Just remove: 'Content-Type': 'multipart/form-data; boundary=------some-random-characters' - it will cause the Content-Type will be set according to body type.

意思目前还没有办法给 FormData 设置 boundary,唯一的方法就是不要加!...... o(︶︿︶)o

二、总结fetch 请求中的 Content-Type 配置:

  1. 如果请求的 body 是字符串,则 Content-Type 会默认设置为 'text/plain;charset=UTF-8'
  2. 当发送 JSON 时,我们会配置 Content-Type: 'application/json',这是 JSON 编码的数据所需的正确的 Content-Type ;
  3. 但是,在上传文件时(比如这里是 FormData),我们没有手动设置 Content-Type: 'multipart/form-data',因为上传二进制文件时,浏览器会自动设置;
  4. 当然,也可以直接上传二进制数据,同样无需设置 Content-Type,将 BlobarrayBuffer 数据放在 body 属性里,因为 Blob 对象具有内建的类型。对于 Blob 对象,这个类型就变成了 Content-Type 的值。

封装 Fetch

贴一下封装的 Fetch 请求,供大家参考和修改:

import { message } from 'antd'

// 公司基准地址
const baseUrl = 'http://127.0.0.1:8989'

// 初始options
const initalOptions = {
  method: 'get',
  body: null,
  headers: {
    Accept: 'application/json',
    'Content-Type': 'application/json;charset=UTF-8'
  },
  credentials: 'include'
}

// 处理 url 的 query 参数
export const encodeURIparam = (obj) => {
  let pairs = []
  for (let prop in obj) {
    if (obj.hasOwnProperty(prop)) {
      let k = encodeURIComponent(prop)
      let v = encodeURIComponent(
        typeof obj[prop] !== 'undefined' ? obj[prop] : ''
      )
      pairs.push(k + '=' + v)
    }
  }
  return pairs.join('&')
}

// 拼接 url
export const handleParams = (url, params) => {
  url = baseUrl + url
  if (params && Object.keys(params).length) {
    url += '?' + encodeURIparam(params)
  }
  return url
}

// 处理 fetch 返回的 Response
const handleFetchResponse = (response, responseType) => {
  const { ok, status, statusText } = response
  let data // 是 promise
  switch (responseType) {
    case 'TEXT':
      data = response.text()
      break
    case 'JSON':
      data = response.json()
      break
    case 'BLOB':
      data = response.blob()
      break
    case 'ARRAYBUFFER':
      data = response.arrayBuffer()
      break
  }

  // 成功直接返回 data (ok: 状态码200-299)
  if (ok) return data
  // 失败返回错误信息以及处理后的 data
  if (data) {
    data.then((errorInfo) => {
      return Promise.reject({
        message: statusText,
        code: status,
        data: errorInfo
      })
    })
  }
}

const http = async (
  url,
  { params, noMessage = false, responseType = 'JSON', ...config } = {}
) => {
  const options = {
    ...initalOptions,
    ...config
  }
  // step1. 拼接 url
  url = handleParams(url, params)
  
  // step2. post/put 移除 Content-Type
  responseType = responseType.toUpperCase()
  const method = config.method.toUpperCase()
  if (['POST', 'PUT'].includes(method)) {
    delete options.headers['Content-Type']
  }
  
  // step3. 发送请求
  try {
    const response = await fetch(url, options)
    // step4. 处理 Response
    return await handleFetchResponse(response, responseType)
  } catch (error) {
    // step5. 处理错误
    // 这里有两种错误:1. 因网络故障或被阻止的请求 2. 错误状态码
    const msg = error.message || 'Internal Server Error.'
    const status = error.code || 500
    // 报错(可选)并 reject 错误信息
    if (!noMessage) message.error(`${status} ${msg}`, 5)
    return Promise.reject(error)
  }
}

export default http

总结

Fetch 是比 XHR 更好用的请求方式,天生自带 Promise,在上传文件时也无需手动配置 Content-Type 头部。当然,在实际项目开发中,还是要基于现有要求做一些封装。所以除了 axios,原生Fetch 现在也是一个不错的选择。

参考资料

  1. Fetch - 现代JS教程
  2. 使用 Fetch - Web API 接口参考 | MDN
  3. Response - Web API 接口参考 | MDN
  4. fetch() - Web API 接口参考 | MDN
  5. Content-Type 的 boundary
  6. How to get or set boundary in multipart/form-data from FormData?