前端请求发生了重大变更,从XMLHTTPRequest,到jquery,再到axios,现在是fetch,伴随着每个时代的经历和泪,终于可以以一种优雅的方式登场。

【请求】从XMLHTTPRequest到fetch:前端请求详解

XMLHTTPRequest

使用示例

// 创建XMLHTTPRequest对象
var xhr = new XMLHttpRequest();
// 设置超时处理
xhr.timeout = 3000;
xhr.ontimeout = function (event) {
    alert("请求超时!");
}
// 创建请求数据
var formData = new FormData();
formData.append('name', 'quanzaiyu');
formData.append('password', '111111');
// 打开链接
xhr.open('POST', 'http://www.test.com:8000/login', true);
// 发送数据
xhr.send(formData);
// 注冊回调
xhr.onreadystatechange = function () {
    if (xhr.readyState == 4 && xhr.status == 200) {
        alert(xhr.responseText);
    }
    else {
        alert(xhr.statusText);
    }
}

属性说明:

  • xhr.readyState:XMLHttpRequest对象的状态,等于4表示数据已经接收完毕。
  • xhr.status:服务器返回的状态码,等于200表示一切正常。
  • xhr.responseText:服务器返回的文本数据
  • xhr.responseXML:服务器返回的XML格式的数据
  • xhr.statusText:服务器返回的状态文本。

手动封装

通过 Promise 的方式封装原生的 XMLHTTPRequest

const ajax = (url, options) => new Promise((resolve, reject) => {
  const xhr = new XMLHttpRequest()
  xhr.open(options.method || 'GET', url, true)
  xhr.setRequestHeader('Content-Type', options.headers['Content-Type'])
  if (options.headers.Authorization) {
    xhr.setRequestHeader('Authorization', options.headers.Authorization)
  }
  xhr.onload = () => {
    if (xhr.status >= 200 && xhr.status < 500) {
      if (xhr.status === 404) {
        reject({ message: '链接地址错误' })
        return
      }
      let res
      try {
        res = JSON.parse(xhr.responseText)
      } catch (e) {
        reject({ message: xhr.responseText, statusCode: xhr.status })
        return
      }
      resolve(res)
    } else if (xhr.status >= 500) {
      reject({ message: '服务器错误' })
    }
  }
  xhr.onerror = () => reject({ message: '与服务器失去联系', statusCode: xhr.status })
  if (options.body) {
    xhr.send(options.body)
  } else {
    xhr.send()
  }
})

jQuery ajax

在 jQuery 时代, 其 ajax 模块对 XMLHTTPRequest 对象的封装还是蛮不错的, 简单回顾下, 重温经典

使用示例

$.ajax({
  type: 'POST',
  url: './index.php',
  data: {
    name: 'xiaoyu'
  },
  async: true,
  dataType: 'json',
  contentType: 'application/json; charset=utf-8',
  success: function () {},
  error: function () {},
  complete: function (data) {}
});

缩写示例

$.get("user/1",function(data,status){});
$.post("user/add", {name: 'xiaoyu'}, function(result){});

Axios

Axios 的亮相可以说是伴随着 Vue 的兴起, 自从 Vue 抛弃了自家的 vue-resource, 官方就大力推广 Axios

使用示例

axios({
  method: 'post',
  url: 'http://test.com/user/add',
  data: {
    firstName: 'Fred',
    lastName: 'Flintstone'
  }
}).then(function (response) {
  console.log(response);
}).catch(function (error) {
  console.log(error);
});

提交 application/x-www-form-urlencoded 时参数格式的问题

axios 在使用时还是有一些问题, 比如在提交 application/x-www-form-urlencoded 时, 如果传递的参数写为对象的形似, 则会被转换为json字符串的形似进行传递, 这时需要借助于 node 的 Qs 模块:

import axios from 'axios'
const Qs = require('qs')
async function getToken (userPhone, userPassword) {
  try {
    let tokenRes = await axios({
      url: `${axiosApiDomain}/userCenter/oauth/token`,
      method: 'post',
      data: Qs.stringify({
        grant_type: 'password',
        username: userPhone,
        password: userPassword
      }),
      headers: {
        'Content-Type': "application/x-www-form-urlencoded",
        'Authorization': 'Basic YW5kcm9pZDphbmRyb2lk'
      }
    })
    if (tokenRes.code && [0, -1, '0', '-1'].includes(tokenRes.code)) {
      throw '获取token失败:' + tokenRes.message
    } else {
      // 存储token
      ls('token', tokenRes.data)
      return tokenRes
    }
  } catch (e) {
    throw e
  }
}

或者传递的时候写为查询字符串的形似:

axios({
  url: `./index.php`,
  method: 'post',
  data: 'name=quanzaiyu&password=123',
  headers: {
    'Content-Type': "application/x-www-form-urlencoded"
  }
})

get 与 post 参数格式不一致性

再比如, axios 在使用简便方法时, post 与 get 的传参方式不一致:

// get
axios.get('/user', {
  params: {
    id: 1
  }
})
// or
axios.get('/user?ID=12345')
// post
axios.post('/user', {
  firstName: 'Quan',
  lastName: 'ZaiYu'
})

可以看到, 使用get传参, 需要额外指定 params 字段进行查询字符串参数的传递, 而 post 的参数传递并不需要

二次封装

不过 axios 的拦截器倒是蛮好用的, 以下是结合其拦截器的二次封装

import axios from 'axios'
const axiosBaseConfig = {
  axiosApiUrl: 'https://test.com/'
}
class AxiosAPI {
  constructor (baseUrl) {
    this.baseUrl = baseUrl
  }
  create () {
    return axios.create({
      baseURL: this.baseUrl,
      timeout: 10000,
      headers: {
        'Accept': 'application/json, text/plain, */*'
      }
    })
  }
}
const axiosBaseApi = new AxiosAPI(axiosBaseConfig.axiosApiUrl).create()
function getUrlParam (name) {
  let reg = new RegExp(`(^|&)${name}=([^&]*)(&|$)`)
  let r = window.location.search.substr(1).match(reg)
  if (r != null) {
    return decodeURIComponent(r[2])
  }
  return null
}
// 重新授权
const reaouth = () => {
  // 重新授权的逻辑
  ...
}
// 请求拦截器 -> 成功
const requestInterceptorSuccess = config => {
  let data = localStorage.getItem('authtoken')
  if (!data) {
    return config
  }
  let authtoken = window.JSON.parse(data)
  if (new Date().getTime() - authtoken.time > (authtoken.exp * 1000)) {
    return config
  } else {
    authtoken = authtoken.data
  }
  // 设置头部信息,传递 token
  authtoken && (config.headers.Authorization = 'DYT ' + authtoken)
  const uuid = sessionStorage.getItem('uuid')
  uuid && (config.headers['x-uuid'] = uuid)
  return config
}
// 请求拦截器 -> 失败
const requestInterceptorError = error => {
  return Promise.reject(error)
}
// 响应拦截器 -> 成功
const responseInterceptorSuccess = res => {
  if (res.data.code === 99) {
    reaouth()
  } else {
    return res
  }
}
// 响应拦截器 -> 失败
const responseInterceptorError = error => {
  if (error.response.status === 401) {
    reaouth()
    return Promise.reject(new Error('授权已过期,重新获取中,请稍后...'))
  }
  return Promise.reject(error)
}
const axiosArr = [
  ['axiosBaseApi', axiosBaseApi]
]
const axiosMap = new Map(axiosArr)
axiosMap.forEach(item => {
  item.interceptors.request.use(
    config => requestInterceptorSuccess(config),
    error => requestInterceptorError(error)
  )
  item.interceptors.response.use(
    res => responseInterceptorSuccess(res),
    error => responseInterceptorError(error)
  )
})
export default {
  axiosBaseApi
}

Fetch

使用示例

try {
  let response = await fetch(url);
  let data = response.json();
  console.log(data);
} catch(e) {
  console.log("Oops, error", e);
}

由于fetch天然使用Promise进行异步操作, 使用时可以使用 async..await (需要配合try..catch) 或 then..catch

Fetch在使用时需注意:

  • fetch只对网络请求报错,对400,500都当做成功的请求,服务器返回 400,500 错误码时并不会 reject,只有网络错误这些导致请求不能完成时,fetch 才会被 reject。
  • fetch默认不会带cookie,需要添加配置项: fetch(url, {credentials: 'include'})
  • fetch不支持abort,不支持超时控制,使用setTimeout及Promise.reject的实现的超时控制并不能阻止请求过程继续在后台运行,造成了流量的浪费
  • fetch没有办法原生监测请求的进度,而XHR可以
  • Content-Type 为 multipart/form-data 时, 不要显示指明其Content-Type, 否则不会带 boundary=----WebKitFormBoundaryYF6ROxRWcKq0oXET, 通过 formData.append 的数据会自动解析为 multipart/form-data

手动封装

在使用时, Fetch 并不是那么友好, 因此还是需要进行一层封装:

const ApiUrl = 'http://localhost:8088'
class API {
  constructor (baseUrl) {
    this.baseUrl = baseUrl
  }
  async base (options) {
    try {
      let extraHeader = {}
      let res = await fetch(`${this.baseUrl}/${options.url}`, {
        ...options,
        headers: Object.assign({
          'Content-Type': "application/x-www-form-urlencoded",
          'Accept': 'application/json, text/plain, */*',
          ...options.headers
        }, extraHeader)
      })
      if (res.ok) { // 200
        let retData = await res.json()
        if (retData.code == -1) {
        } else {
          return retData
        }
      } else {
        if (res.status == 401) {
        } else {
          throw '请求失败'
        }
      }
    } catch (e) {
      throw e
    }
  }
  async getBase (url, params, opts) {
    try {
      // 查询字符串
      if (params) {
        let paramsArray = [];
        // 拼接参数
        Object.keys(params).forEach(key => paramsArray.push(key + '=' + params[key]))
        if (url.search(/\?/) === -1) {
          url += '?' + paramsArray.join('&')
        } else {
          url += '&' + paramsArray.join('&')
        }
      }
      let options = {
        url, ...opts
      }
      return await this.base({...options, method: 'get' })
    } catch (e) {
      throw e
    }
  }
  async postBase (url, params, opts) {
    try {
      // 请求参数
      let body = ''
      if (params) {
        let paramsArray = [];
        // 拼接参数
        Object.keys(params).forEach(key => paramsArray.push(key + '=' + params[key]))
        body = paramsArray.join('&')
      }
      let options = { url, body, ...opts }
      return await this.base({...options, method: 'post' })
    } catch (e) {
      throw e
    }
  }
  async putBase (url, params, opts) {
    try {
      // 请求参数
      let body = ''
      if (params) {
        let paramsArray = [];
        // 拼接参数
        Object.keys(params).forEach(key => paramsArray.push(key + '=' + params[key]))
        body = paramsArray.join('&')
      }
      let options = { url, body, ...opts }
      return await this.base({...options, method: 'put' })
    } catch (e) {
      throw e
    }
  }
  async deleteBase (url, params, opts) {
    try {
      // 请求参数
      let body = ''
      if (params) {
        let paramsArray = [];
        // 拼接参数
        Object.keys(params).forEach(key => paramsArray.push(key + '=' + params[key]))
        body = paramsArray.join('&')
      }
      let options = { url, body, ...opts }
      return await this.base({...options, method: 'delete' })
    } catch (e) {
      throw e
    }
  }
  async get (url, params, opts) {
    return this.getBase(url, params, { ...opts, withAuth: true })
  }
  async post (url, params, opts) {
    return this.postBase(url, params, { ...opts, withAuth: true })
  }
  async put (url, params, opts) {
    return this.putBase(url, params, { ...opts, withAuth: true })
  }
  async delete (url, params, opts) {
    return this.deleteBase(url, params, { ...opts, withAuth: true })
  }
}
const api = new API(ApiUrl)
export default api

使用:

import api from 'API'
api.get('user/1').then().catch(e => {})
api.post('user/add', {name: 'xiaoyu'})
// 当然, 也可以使用 async..await
async getData () {
  try {
    await api.get('user/1')
  } catch (e) {
    console.log(e)
  }
}

HTTP 响应码

1xx (临时响应)

  • 100: 请求者应当继续提出请求。
  • 101: (切换协议) 请求者已要求服务器切换协议,服务器已确认并准备进行切换。

2xx (成功)

  • 200: (成功) 正确的请求返回正确的结果,如果不想细分正确的请求结果都可以直接返回200。
  • 201: 表示资源被正确的创建。比如说,我们 POST 用户名、密码正确创建了一个用户就可以返回 201。
  • 202: 请求是正确的,但是结果正在处理中,这时候客户端可以通过轮询等机制继续请求。
  • 203: 请求的代理服务器修改了源服务器返回的 200 中的内容,我们通过代理服务器向服务器 A 请求用户信息,服务器 A 正常响应,但代理服务器命中了缓存并返回了自己的缓存内容,这时候它返回 203 告诉我们这部分信息不一定是最新的,我们可以自行判断并处理。

3xx (已重定向)

  • 300: 请求成功,但结果有多种选择。
  • 301: 请求成功,但是资源被永久转移。比如说,我们下载的东西不在这个地址需要去到新的地址。
  • 303: 使用 GET 来访问新的地址来获取资源。
  • 304: (重定向) 请求的资源并没有被修改过。
  • 308: 使用原有的地址请求方式来通过新地址获取资源。

4xx (客户端请求错误)

  • 400: 请求出现错误,比如请求头不对等。
  • 401: (授权失败) 没有提供认证信息, 比如请求的时候没有带上Token。
  • 402: 为以后需要所保留的状态码。
  • 403: (请求方式错误) 请求的资源不允许访问, 比如请求方式不允许。
  • 404: (找不到资源) 请求的内容不存在。
  • 406: 请求的资源并不符合要求。
  • 408: 客户端请求超时。
  • 413: 请求体过大。
  • 415: 类型不正确。
  • 416: 请求的区间无效。

5xx (服务器错误)

  • 500: 服务器错误。
  • 501: 请求还没有被实现。
  • 502: 网关错误。
  • 503: 服务暂时不可用。服务器正好在更新代码重启。
  • 504: 网关错误。
  • 505: 请求的 HTTP 版本不支持。

ContentType

Content-Type(MediaType),即是Internet Media Type,互联网媒体类型,也叫做MIME类型。在互联网中有成百上千中不同的数据类型,HTTP在传输数据对象时会为他们打上称为MIME的数据格式标签,用于区分数据类型。最初MIME是用于电子邮件系统的,后来HTTP也采用了这一方案。

application/x-www-urlencoded

HTTP会将请求参数用 key1=val1&key2=val2 查询字符串的方式进行组织,并放到请求实体里面,注意如果是中文或特殊字符如 "/"、","、":" 等会自动进行URL转码。不支持文件,一般用于表单提交。

可以看到, 请求域仍然为 Form Data

multipart/form-data

与application/x-www-form-urlencoded不同,这是一个多部分多媒体类型。首先生成了一个 boundary 用于分割不同的字段,在请求实体里每个参数以------boundary开始,然后是附加信息和参数名,然后是空行,最后是参数内容。多个参数将会有多个boundary块。如果参数是文件会有特别的文件域。最后以------boundary为结束标识。multipart/form-data支持文件上传的格式,一般需要上传文件的表单则用该类型。

可以看到, 请求域为 Form Data

application/json

JSON 是一种轻量级的数据格式,以“键-值”对的方式组织的数据。这个使用这个类型,需要参数本身就是json格式的数据,参数会被直接放到请求实体里,不进行任何处理。服务端/客户端会按json格式解析数据(约定好的情况下)。

可以看到, 参数域变为 Request Payload

text/plain

这是纯文本的数据格式, 将会把数据转化为一个字符串

xml

application/xml 和 text/xml与application/json类似,这里用的是xml格式的数据,text/xml的话,将忽略xml数据里的编码格式

跨域解决方案

跨域资源共享(CORS)

XMLHttpRequest可以向不同域名的服务器发出HTTP请求,叫做CORS

可以进行CORS有两个条件:

  • 浏览器要支持CORS
  • 服务器允许跨域:响应头需要添加以下选项
    • self.set_header('Access-Control-Allow-Origin', '*')
    • self.set_header('Access-Control-Allow-Methods', 'POST, GET, OPTIONS, DELETE, PUT, HEAD')
    • self.set_header('Access-Control-Allow-Headers', '*')
    • self.set_header('Access-Control-Max-Age', 1000)
    • self.set_header('Content-type', 'application/json')

jsonp

jsonp 的实现原理其实是依据 script 标签的 src 属性不受跨域限制, 基于这个特性, 我们可以动态创建 script 并请求接口, 获取到的数据通过回调执行:

// 得到航班信息查询结果后的回调函数
var callbackfun = function(data){
  console.log(data)
};
// 提供jsonp服务的url地址(不管是什么类型的地址,最终生成的返回值都是一段javascript代码)
// 提供接收回调的方法, 使用callback指定
var url = "http://test.com/jsonp/data.aspx?id=123&callback=callbackfun";
// 动态创建script标签,设置其属性
var script = document.createElement('script');
script.setAttribute('src', url);
// 把script标签加入head,此时调用开始
document.getElementsByTagName('head')[0].appendChild(script);

通过 jQuery 使用 jsonp

后端有那么一串代码:

<?php
header('Content-type: application/json');
//获取回调函数名
$jsoncallback = htmlspecialchars($_REQUEST['jsoncallback']);
//json数据
$json_data = '["customername1","customername2"]';
//输出jsonp格式的数据
echo $jsoncallback . "(" . $json_data . ")";
?>

通过 $.getJSON 取得数据:

$.getJSON("http:/test.com/try/ajax/jsonp.php?jsoncallback=?", function(data) {
  var html = '<ul>';
  for(var i = 0; i < data.length; i++) {
    html += '<li>' + data[i] + '</li>';
  }
  html += '</ul>';
  $('#divCustomers').html(html);
});

其他

简单请求与复杂请求

CORS可以分成两种:

1、简单请求 2、复杂请求

简单请求

一个简单的请求大致如下:

HTTP方法是下列之一

  • HEAD
  • GET
  • POST

HTTP头信息不超出以下几种字段

  • Accept
  • Accept-Language
  • Content-Language
  • Last-Event-ID
  • Content-Type 只能是下列之一
    • application/x-www-form-urlencoded
    • multipart/form-data
    • text/plain

任何一个不满足上述要求的请求,即被认为是复杂请求。一个复杂请求不仅有包含通信内容的请求,同时也包含预请求(preflight request)。

简单请求的发送从代码上来看和普通的XHR没太大区别,但是HTTP头当中要求总是包含一个域(Origin)的信息。该域包含协议名、地址以及一个可选的端口。不过这一项实际上由浏览器代为发送,并不是开发者代码可以触及到的。

简单请求的部分响应头及解释如下:

  • Access-Control-Allow-Origin(必含)- 不可省略,否则请求按失败处理。该项控制数据的可见范围,如果希望数据对任何人都可见,可以填写"*"。
  • Access-Control-Allow-Credentials(可选) – 该项标志着请求当中是否包含cookies信息,只有一个可选值:true(必为小写)。如果不包含cookies,请略去该项,而不是填写false。这一项与XmlHttpRequest2对象当中的withCredentials属性应保持一致,即withCredentials为true时该项也为true;withCredentials为false时,省略该项不写。反之则导致请求失败。
  • Access-Control-Expose-Headers(可选) – 该项确定XmlHttpRequest2对象当中getResponseHeader()方法所能获得的额外信息。通常情况下,getResponseHeader()方法只能获得如下的信息:
  • Cache-Control
  • Content-Language
  • Content-Type
  • Expires
  • Last-Modified
  • Pragma 当你需要访问额外的信息时,就需要在这一项当中填写并以逗号进行分隔

如果仅仅是简单请求,那么即便不用CORS也没有什么大不了,但CORS的复杂请求就令CORS显得更加有用了。简单来说,任何不满足上述简单请求要求的请求,都属于复杂请求。比如说你需要发送PUT、DELETE等HTTP动作,或者发送Content-Type: application/json的内容。

复杂请求

复杂请求表面上看起来和简单请求使用上差不多,但实际上浏览器发送了不止一个请求。其中最先发送的是一种"预请求",此时作为服务端,也需要返回"预回应"作为响应。预请求实际上是对服务端的一种权限请求,只有当预请求成功返回,实际请求才开始执行。

预请求以OPTIONS形式发送,当中同样包含域,并且还包含了两项CORS特有的内容:

  • Access-Control-Request-Method – 该项内容是实际请求的种类,可以是GET、POST之类的简单请求,也可以是PUT、DELETE等等。
  • Access-Control-Request-Headers – 该项是一个以逗号分隔的列表,当中是复杂请求所使用的头部。

显而易见,这个预请求实际上就是在为之后的实际请求发送一个权限请求,在预回应返回的内容当中,服务端应当对这两项进行回复,以让浏览器确定请求是否能够成功完成。

复杂请求的部分响应头及解释如下:

  • Access-Control-Allow-Origin(必含) – 和简单请求一样的,必须包含一个域。
  • Access-Control-Allow-Methods(必含) – 这是对预请求当中Access-Control-Request-Method的回复,这一回复将是一个以逗号分隔的列表。尽管客户端或许只请求某一方法,但服务端仍然可以返回所有允许的方法,以便客户端将其缓存。
  • Access-Control-Allow-Headers(当预请求中包含Access-Control-Request-Headers时必须包含) – 这是对预请求当中Access-Control-Request-Headers的回复,和上面一样是以逗号分隔的列表,可以返回所有支持的头部。这里在实际使用中有遇到,所有支持的头部一时可能不能完全写出来,而又不想在这一层做过多的判断,没关系,事实上通过request的header可以直接取到Access-Control-Request-Headers,直接把对应的value设置到Access-Control-Allow-Headers即可。
  • Access-Control-Allow-Credentials(可选) – 和简单请求当中作用相同。
  • Access-Control-Max-Age(可选) – 以秒为单位的缓存时间。预请求的的发送并非免费午餐,允许时应当尽可能缓存。

一旦预回应如期而至,所请求的权限也都已满足,则实际请求开始发送。

参考资料

HTTP响应码

请求工具

跨域

Content-Type

MIT Licensed | Copyright © 2018-present 滇ICP备16006294号

Design by Quanzaiyu | Power by VuePress