JavaScript 基础 客户端请求 跨域解决方案

前端 HTTP 请求方式

  • 第一代原生方式 xhr
  • ES6 新增第二代原生方式 fetch
  • 第三方
    • axios.js 对第一代原生方式的封装
    • vue-resource vue 插件
    • RxJS 另一种响应式的处理分发和流程操作类库

前端 HTTP 请求方式–axios

Axios 是一个基于 promise 的 HTTP 库,可以用在浏览器和 node.js 中, axios 的特点:

  • 从浏览器中创建 XMLHttpRequests
  • 从 node.js 创建 http 请求
  • 支持 Promise API
  • 拦截请求和响应
  • 转换请求数据和响应数据
  • 能够作到 abort, 并且能够自定义处理请求
  • 自动转换 JSON 数据
  • 客户端支持防御 XSRF

从 axios 源码入手

{ [Function: wrap]
  request: [Function: wrap],
  getUri: [Function: wrap], // 别名函数, 发送 get 请求
  delete: [Function: wrap], // 别名函数, 发送 delete 请求
  get: [Function: wrap], // 别名函数, 发送 get 请求
  head: [Function: wrap], // 别名函数, 发送 head 请求
  options: [Function: wrap], // 别名函数, 发送 options 请求
  post: [Function: wrap], // 别名函数, 发送 post 请求
  put: [Function: wrap], // 别名函数, 发送 put 请求
  patch: [Function: wrap],
  defaults:
   { adapter: [Function: httpAdapter],
     transformRequest: [ [Function: transformRequest] ],
     transformResponse: [ [Function: transformResponse] ],
     timeout: 0,
     xsrfCookieName: 'XSRF-TOKEN',
     xsrfHeaderName: 'X-XSRF-TOKEN',
     maxContentLength: -1,
     validateStatus: [Function: validateStatus],
     headers:
      { common: [Object],
        delete: {},
        get: {},
        head: {},
        post: [Object],
        put: [Object],
        patch: [Object] } },
  interceptors:
   { request: InterceptorManager { handlers: [] },
     response: InterceptorManager { handlers: [] } },
  Axios: [Function: Axios],
  create: [Function: create],
  Cancel: [Function: Cancel],
  CancelToken: { [Function: CancelToken] source: [Function: source] },
  isCancel: [Function: isCancel],
  all: [Function: all],
  spread: [Function: spread],
  default: [Circular] }

在 node 环境中打印出来的结果中可以看出,axios 暴露出来的一些属性和方法

request

抛开 webpack 打包的代码,这里就直接从 494 行开始,__webpack_require__(11) 方法(846行),该方法内部声明配置了 axios 的一些默认配置

以下是 axios 主要依赖模块

var utils = __webpack_require__(2); // 工具方法
var buildURL = __webpack_require__(6); // 地址构建器
var InterceptorManager = __webpack_require__(7); // 拦截器
var dispatchRequest = __webpack_require__(8); // 请求调度器
var mergeConfig = __webpack_require__(22); // 合并配置

InterceptorManager 拦截器

每实例化一次 axios 时默认总会给当前实例增加一个 defaults 属性 和 interceptors对象,前者表示该实例的默认配置,后者则表示一个拦截器, InterceptorManager 内部用一个 handlers 栈维护,每个拦截器都会有 fulfilled 和 rejected 两个方法,

连接拦截器

if (typeof config === 'string') {
  config = arguments[1] || {};
  config.url = arguments[0];
} else {
  config = config || {};
}

该方法是整个 axios 请求入口,方法的开始和 jQuery.prototype.ajax 一样都是对参数作处理,也就是说可以直接传入 url 地址, 或者传入 url,再传入一个配置项对象

源码 537 行

var chain = [dispatchRequest, undefined]; // Line:537
var promise = Promise.resolve(config); // Line:538
this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
  chain.unshift(interceptor.fulfilled, interceptor.rejected);
});

this.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
  chain.push(interceptor.fulfilled, interceptor.rejected);
});

while (chain.length) {
  promise = promise.then(chain.shift(), chain.shift()); // Line:549 
}

return promise;

538行 由 ES6 的 Promise 实现可得出, Promise.resolve(config) 是为了将 config 创建成一个 promise 对象, 而 537 行创建了一个数组包裹的请求调度器,将注册在拦截器中的所有 fulfilled 和 rejected 方法依次放入请求调度器和 undefined 占位前后

特别注意第 537 行, 因为 promise 在 538 行时,状态已经为 resolved 了,也就是说已经完成了,也就会从头触发到尾巴,但是中途会发现 shift() 方法会经过 dispatchRequest, undefined 这两个占位符,当然此时的 then 永远会 reslove,永远不会 reject,因为 rejected 是一个 undefined,这也是为什么会出现 undefined 占位符原因

此处后面的 response 响应的拦截器也会永远的被执行,但再那之前,会经过请求调度器,所以目转调度器

补充一段实例,该实例是在浏览器环境中,当前保证能够成功响应时

const url = 'axios-test-json.json'
axios.interceptors.request.use( function rs1(config){
  console.log('request 1 success')
  return config
}, function re1 (error){
  console.log('response 1 error', error)
  return Promise.reject(error)
})
axios.interceptors.response.use(  function rs2(config){
  console.log('request 2 success')
  return config
}, function re2 (error){
  console.log('response 2 error', error)
  return Promise.reject(error)
})
axios.request(url).then( r => console.log('response'))
// request 1 success
// request 2 success
// response

特别注意 Promise.resolve(config) 是将 config 包装成一个 promise 对象, ES6 的该方法实现有介绍, config 在默认情况下是不存在 then 方法的,也就是说 config 是一个不带 then 方法的对象,所以返回的 Promise 对象状态为 fulfilled,并且将该value传递给对应的then方法,这里在的 then 方法就是 549 行的 then,因为当前的状态直接为 fulfilled, 所以不仅将请求之前需要执行的拦截器的成功回调全部依次执行

这也是为什么 rs1 和 rs2 两个方法都被直接 fulfilled 执行了

如果当响应不成功时, 在响应的拦截器会将会依次执行,特别注意!!!

请求处理器 adapter

接下来就是源码第 779 行,利用 adapter 请求处理器,对 config 内容进行 promise 操作,取消操作的地方都用了 throwIfCancellationRequested 阻止当次操作

__webpack_require__(13) 创建默认 config 的方法,来到源码 970 行

module.exports = function xhrAdapter(config) {
  return new Promise(function dispatchXhrRequest(resolve, reject) {
    request.onreadystatechange = function handleLoad() {
      settle(resolve, reject, response)
      request = null
    }
    request.onabort = function handleAbort() {}
    request.onerror = function handleError() {}
    request.ontimeout = function handleTimeout() {}
  })
}

该方法是 defaluts.adapter 的源头。 axios 是可以支持 node 和浏览器环境的,虽然是对底层做了封装,但是对于过老 IE 这样的浏览器是不支持 XMLHttpRequest 对象的,也就不支持过老的版本

  1. 浏览器环境中使用浏览器设定默认的 Content-Type 头部字段
  2. 默认可携带 HTTP 验证并 bota 转码
  3. 可配置超时时间,默认不超时
  4. 取消,错误和超时分别用原生 onabort,onerror和ontimeout 事件监听
  5. 响应状态 [200, 300) 区间视为成功

此处的响应完全是原生的响应内容,需要返回给请求调度器对响应进行转换才是最后请求成功后的样子

请求调度器 dispatchRequest

直接来到源码的 __webpack_require__(8) 方法(717行),该方法中引入的更多的模块

var utils = __webpack_require__(2);
var transformData = __webpack_require__(9); // 请求响应转换工具
var isCancel = __webpack_require__(10);  // 取消操作时付加对象,有个内部属性 __CANCEL__
var defaults = __webpack_require__(11); // 默认配置
var isAbsoluteURL = __webpack_require__(20); // URL 判断工具
var combineURLs = __webpack_require__(21); // 组全 URL

isAbsoluteURL 判断 URL 是否是一个绝对路径,遵守 RFC 3986 编码规范方案

function throwIfCancellationRequested(config) {
  if (config.cancelToken) {
    config.cancelToken.throwIfRequested();
  }
}

该方法简短,也没什么其它操作,但该方法在源码其它位置往往都在主方法体内的第一行,如注释其就是为了判断该实例是否取消了请求,而这个标识就是config.cancelToken

源码 777 行:

var adapter = config.adapter || defaults.adapter;
return adapter(config).then(function onAdapterResolution(response) {
  // 成功时对此次请求进行取消检查
  throwIfCancellationRequested(config);
  // Transform response data
  response.data = transformData(
    response.data,
    response.headers,
    config.transformResponse
  );
  return response;
}, function onAdapterRejection(reason) {
  if (!isCancel(reason)) {
    // 失败时对此次请求进行取消检查
    throwIfCancellationRequested(config);
    // Transform response data
    if (reason && reason.response) {
      reason.response.data = transformData(
        reason.response.data,
        reason.response.headers,
        config.transformResponse
      );
    }
  }
  return Promise.reject(reason);
});

config.adapter 就是用户自定义请求的来源,在 axios 内部,默认每个实例都会有一个请求处理方法 defaults.adapter, 但值得注意的是该方法需要返回一个 Promise 来处理后续操作并且还得提供一个有效的响应, axios 官方上解释,在当前的请求前后会分别执行转换和拦截,转换则是转换请求或响应,拦截则是拦截器,详情可见例子

axios 封装第一代 xhr 使用 withCredentials共享

最后无论成功失败,将响应转换成 axios 独特格式 ☺

其实这个转换默认只是判断是否为字符串,如果是字符串则直接 JSON.parse() 否则啥也做,当然,这个转换规则可以是多个,默认是只有一个的,最后回到原点, 源码 549 行,将最后的 promise 返回给用户

响应以用请求的转换可以具体参考 config.transformResponse 配置

个人认为在此源码 549 行是整个 axios 的核心,因为它的奇妙设计,利用栈队列这个样的数据结构,完美的实现了请求拦截器,请求处理和响应拦截器之间的次序,很直观的对机器表达出了自己想要做的事,个人很佩服这一点。

跨域解决方案

当请求的目标地址和当前网站地址的 URL 端口不一样,或域名,或协议一样,满足其中任何一个的请求就会触发浏览器的同源策略限制,也就是不让你访问,完美的跨域解决是前后两端共同商讨决策

以下是可通过跨域访问的几种方案:

  1. 通过 jsonp 跨域
  2. document.domain + iframe跨域
  3. location.hash + iframe
  4. window.name + iframe跨域
  5. postMessage 跨域
  6. HTTP 访问控制(CORS)
  7. nginx 代理跨域
  8. nodejs 中间件代理跨域
  9. WebSocket 协议跨域

HTTP 访问控制

req 发送 origin 字段, res 响应Access-Control-Allow-Origin,如果 origin 来源在 Access-Control-Allow-Origin 中则是达成 CORS,这也是简单请求完成的最简单的访问控制,使用该方案应注意下几点:

  1. 如果是简单请求,在之前的 HTTP 访问控制有说明,什么是简单请求和预检请求,如果该次跨域只个简单请求则会直接发送跨域请求,后端的Access-Control-Allow-Origin字段可以是*
  2. 如果不是简单请求,那么每一次非简单请求都增加一个 option 方式的预检请求消耗,特别注意
  3. 如果需要共享资源(因为简单的跨域请求是不会共享资源的,也就是 cookie,session 等会话或存储) 那一定注意,后端的 HTTP 控制头部字段必须支持Access-Control-Allow-Origin不能在是*应该具体到某一个访问域下面
  4. 如果要携带 cookie, 后端同样要允许Access-Control-Allow-Credentials字段,虽然可以资源共享了,但那也是后端可以支持,并不代表前后两端都能接收和发送,所以前端还必须将 Credentials 请求字段设置为 true

注意 application/json 或者是 application/xml 已经不满足简单请求,所以该 content-type 就应该需要预检

下面来构建一次简单请求和需要预检请求的环境

首先由于 Chrome 的 Provisional headers are shown 临时响应头问题所以客户端运行在FF http://local.notetest.com:81 浏览器环境

服务器用 node http 模块http://localhost:8080起一个服务器,关于 node 可详情可见 nodejs http

简单请求

客户端请求:

// http://local.notetest.com:81/index.html
const url = 'http://localhost:8080'
// 浏览器使用原生二代 fetch 请求
fetch(new Request(url)).then( r => console.log(r))

node 服务器端:

const http = require('http')
http.createServer( (request, response) => {
  const host = request.headers.origin // 请求地址
  // 打印源地址和请求方法
  console.log(`Host: ${host} Method: ${request.method}`) 
  response.writeHead(200, {
    'Content-Type': 'text/plain', // 简单请求普通文本
    'Access-Control-Allow-Origin' : '*', // 允许所有源地址跨域访问
    'Set-Cookie': 'money=1000' // 默认始终设置一个 cookie 
  })
  response.end('{ "name" : "qlover", "age" : 21 }')
}).listen(8080)

浏览器中直接访问, 服务器会在命令行中打印出 Host: http://local.notetest.com:81 Method: GET, 该次请求属于简单请求,请求和响应的头部信息如下

# 请求
请求网址:http://localhost:8080/
请求方法:GET
远程地址:127.0.0.1:8080
版本:HTTP/1.1
Referrer 政策:no-referrer-when-downgrade

Host: localhost:8080
User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:70.0) Gecko/20100101 Firefox/70.0
Accept: */*
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Referer: http://local.notetest.com:81/
Origin: http://local.notetest.com:81
Connection: keep-alive
Cache-Control: max-age=0

# 响应
HTTP/1.1 200 OK
Content-Type: text/plain
Access-Control-Allow-Origin: *
Set-Cookie: money=1000
Date: Sun, 22 Dec 2019 09:15:08 GMT
Connection: keep-alive
Transfer-Encoding: chunked

# 服务器打印信息
Host: http://local.notetest.com:81 Method: GET

预请求 非简单请求

上述的简单请求使用 fetch 直接成功,后台设置Access-Control-Allow-Origin字段为*,简单请求直接通过,请求 content-type 指定为 json 格式返回,这样就不满足简单请求

fetch(new Request(url, {
  mode: 'cors',
  headers: {
    'Content-Type': 'application/json'
  }
})).then( r => console.log(r))

node 服务器打印结果为 Host: http://local.notetest.com:81 Method: OPTIONS, 说明这个时间已经不再是简单,进行了预检请求,这个时的请求会在前台控制台打印抛出错误

Access to XMLHttpRequest at 'http://localhost:8080/' from origin 'http://local.notetest.com:81' has been blocked by CORS policy: Request header field content-type is not allowed by Access-Control-Allow-Headers in preflight response.

这个时候的服务器端并未允许 content-type 字段,并且响应数据还只是 text/plain 所以会报错,接下来 node 允许Content-Type字段并改变服务器的响应内容为 json

http.createServer( (request, response) => {
  const host = request.headers.origin // 请求地址
  // 打印源地址和请求方法
  console.log(`Host: ${host} Method: ${request.method}`) 
  response.writeHead(200, {
    'Content-Type': 'application/json', // 返回 json
    'Access-Control-Allow-Origin' : '*', // 允许所有源地址跨域访问
    'Access-Control-Allow-Headers' : 'Content-Type', // 允许请求通过 Content-Type 字段
    'Set-Cookie': 'money=1000' // 默认始终设置一个 cookie 
  })
  response.end('{ "name" : "qlover", "age" : 21 }')
}).listen(8080)

此时会先以 OPTION 方式进行预检请求,并且请求响应头部如下:

# 请求
请求网址:http://localhost:8080/
请求方法:OPTIONS  # 请求方法为 options
远程地址:127.0.0.1:8080
版本:HTTP/1.1
Referrer 政策:no-referrer-when-downgrade

Host: localhost:8080
User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:70.0) Gecko/20100101 Firefox/70.0
Accept: */*
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Access-Control-Request-Method: GET
Access-Control-Request-Headers: content-type
Referer: http://local.notetest.com:81/
Origin: http://local.notetest.com:81
Connection: keep-alive
Cache-Control: max-age=0


# 响应
HTTP/1.1 200 OK
Content-Type: application/json
Access-Control-Allow-Origin: *
Access-Control-Allow-Headers: Content-Type
Set-Cookie: money=1000
Date: Sun, 22 Dec 2019 09:12:16 GMT
Connection: keep-alive
Transfer-Encoding: chunked

# 服务器打印结果
Host: http://local.notetest.com:81 Method: OPTIONS

当预检请求通过,之后的 post 请求就会当作实际的请求发送出去,这个时候的请求和响应头信息如下

#请求
请求网址:http://localhost:8080/
请求方法:GET
远程地址:127.0.0.1:8080
版本:HTTP/1.1
Referrer 政策:no-referrer-when-downgrade

Host: localhost:8080
User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:70.0) Gecko/20100101 Firefox/70.0
Accept: application/json, text/plain, */*
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Content-Type: application/json;charset=utf-8
Content-Length: 35
Origin: http://local.notetest.com:81
Connection: keep-alive
Referer: http://local.notetest.com:81/

# 响应
HTTP/1.1 200 OK
Content-Type: application/json
Access-Control-Allow-Origin: *
Access-Control-Allow-Headers: Content-Type
Set-Cookie: money=1000
Date: Sun, 22 Dec 2019 08:37:57 GMT
Connection: keep-alive
Transfer-Encoding: chunked

# 实际请求发送后服务器打印结果
Host: http://local.notetest.com:81 Method: OPTIONS
Host: http://local.notetest.com:81 Method: POST

非简单请求 共享 cookie

上述完成了一次非简单请求,仔细的话会发现,每次 node 都在响应付加了 cookie ,前端的 cookie 却一致获取不到,并且每一次的请求头都没有 cookie 信息,这是因为前后都没有允许跨域通过响应内容,接下来利用Access-Control-Allow-Credentials字段允许前后端通过响应内容,并且要注意 origin 只能是具体的源地址

fetch(new Request(url, {
  mode: 'cors',
  headers: {
    'Content-Type': 'application/json'
  },
  credentials: 'include'
})).then( r => console.log(r))

fetch 属于第二代原生请求方式,使用 credentials = 'include'字段允许传递共享数据,而原生一代请求方式使用 withCredentials = true 字段(axios 属于对原生一代 xhr 的封装,所以也使用 withCredentials 字段)

http.createServer( (request, response) => {
  const host = request.headers.origin // 请求地址
  // 打印源地址和请求方法
  console.log(`Host: ${host} Method: ${request.method}`) 
  response.writeHead(200, {
    'Content-Type': 'application/json', // 简单请求普通文本
    'Access-Control-Allow-Credentials': true, // 允许跨域通过 cookie 
    'Access-Control-Allow-Origin' : host, // 只能是具体源地址
    'Access-Control-Allow-Headers' : 'Content-Type',
    'Set-Cookie': 'money=1000' // 默认始终设置一个 cookie 
  })
  response.end('{ "name" : "qlover", "age" : 21 }')
}).listen(8080)

预检查请求的请求和响应如下:

# 请求
请求网址:http://localhost:8080/
请求方法:OPTIONS
远程地址:127.0.0.1:8080
版本:HTTP/1.1
Referrer 政策:no-referrer-when-downgrade

Host: localhost:8080
User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:70.0) Gecko/20100101 Firefox/70.0
Accept: */*
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Access-Control-Request-Method: GET
Access-Control-Request-Headers: content-type
Referer: http://local.notetest.com:81/
Origin: http://local.notetest.com:81
Connection: keep-alive
Cache-Control: max-age=0

# 响应
HTTP/1.1 200 OK
Content-Type: application/json
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: http://local.notetest.com:81
Access-Control-Allow-Headers: Content-Type
Set-Cookie: money=1000; path=/ # 服务器返回 cookie
Date: Sun, 22 Dec 2019 09:04:18 GMT
Connection: keep-alive
Transfer-Encoding: chunked

# 服务器打印
Host: http://local.notetest.com:81 Method: OPTIONS

实际请求:

# 请求

请求网址:http://localhost:8080/
请求方法:GET
远程地址:127.0.0.1:8080
版本:HTTP/1.1
Referrer 政策:no-referrer-when-downgrade


Host: localhost:8080
User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:70.0) Gecko/20100101 Firefox/70.0
Accept: */*
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Referer: http://local.notetest.com:81/
Content-Type: application/json
Origin: http://local.notetest.com:81
Connection: keep-alive
Cookie: money=1000 # !!! 第二次实际请求已经将上一次的预检请求携带上了
Cache-Control: max-age=0

# 响应
HTTP/1.1 200 OK
Content-Type: application/json
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: http://local.notetest.com:81
Access-Control-Allow-Headers: Content-Type, Set-Cookie, *
Set-Cookie: money=1000; path=/ # 服务器同样返回 cookie
Date: Sun, 22 Dec 2019 09:06:16 GMT
Connection: keep-alive
Transfer-Encoding: chunked

# 服务器打印
Host: http://local.notetest.com:81 Method: OPTIONS
Host: http://local.notetest.com:81 Method: GET

以上就是跨域时会遇见的三种情况

参考链接

  • axios v0.19.0-beta.1 CDN 源码
  • MDN XMLHttpRequest API
  • MDN Fetch API
  • MDN CORS
  • axios adapters
  • nodejs http API

你可能感兴趣的:(js)