为什么 XMLHTTPRequest 不能跨域请求资源

351 阅读6分钟

本文已参与掘金创作者训练营第三期「话题写作」赛道,详情查看:掘力计划|创作者训练营第三期正在进行,「写」出个人影响力

如果你在开发网站时曾经尝试通过框架或是浏览器的 fetchXHR 请求过外部 API 的话,那么一定遇到过跨域请求,还有那个触目惊心的 CORS 错误信息;今天咱们来讨论跨域问题的原因以及解决方法。

什么是跨域

浏览器有一个同源策略,它用于限制一个源的文档或者它加载的脚本如何能与另一个源的资源进行交互。它能帮助阻隔恶意文档,减少可能被攻击的媒介。当我们向不同源的 url 发送请求的时候,就会产生跨域

浏览器对非同源站点有这样一些限制:

  • 不能读取和修改对方的 DOM
  • 不读访问对方的 CookieIndexDBLocalStorage
  • 限制 XMLHttpRequest 请求

那什么是同源呢?我们拿个例子来看看

假设当前用户在:https://example.com :
[✅] https://example.com/test -> 同源,只有路径不同
[❌] https://m.example.com -> 主机不同
[❌] https://example.com:3000 -> 端口不同( https:// 默认端口是443)
[❌] http://example.com -> 协议不同

跨域的解决方案

接下来我们来说一说解决跨域问题的几种方案

三种思路

  1. 绕过浏览器的同源策略

    • 使用 Nginx 反向代理

    • Node 中间件代理

  2. 告诉浏览器这个跨域允许

    • CORS
  3. 使用没有跨域限制的方式

    • JSONP

    • postMessage

    • WebSocket

CORS

简单请求

CORS 其实是 W3C 的一个标准,全称是跨域资源共享。它需要浏览器和服务器的共同支持,具体来说,非 IE 和 IE10 以上支持 CORS,服务器需要附加特定的响应头,后面具体拆解。不过在弄清楚 CORS 的原理之前,我们需要清楚两个概念: 简单请求非简单请求

浏览器根据请求方法和请求头的特定字段,将请求做了一下分类,具体来说规则是这样,凡是满足下面条件的属于简单请求:

  • 请求方法为 GET、POST 或者 HEAD
  • 请求头的取值范围: Accept、Accept-Language、Content-Language、Content-Type(只限于三个值application/x-www-form-urlencodedmultipart/form-datatext/plain)

浏览器画了这样一个圈,在这个圈里面的就是简单请求, 圈外面的就是非简单请求,然后针对这两种不同的请求进行不同的处理。

在请求发出之前,浏览器会自动在请求头当中,添加一个Origin字段,用来说明请求来自哪个。服务器拿到请求之后,在回应时对应地添加Access-Control-Allow-Origin字段,如果Origin不在这个字段的范围中,那么浏览器就会将响应拦截。

因此,Access-Control-Allow-Origin字段是服务器用来决定浏览器是否拦截这个响应,这是必需的字段。与此同时,其它一些可选的功能性的字段,用来描述如果不会拦截,这些字段将会发挥各自的作用。

Access-Control-Allow-Credentials。这个字段是一个布尔值,表示是否允许发送 Cookie,对于跨域请求,浏览器对这个字段默认值设为 false,而如果需要拿到浏览器的 Cookie,需要添加这个响应头并设为true, 并且在前端也需要设置withCredentials属性:

let xhr = new XMLHttpRequest()
xhr.withCredentials = true

Access-Control-Expose-Headers。这个字段是给 XMLHttpRequest 对象赋能,让它不仅可以拿到基本的 6 个响应头字段(包括Cache-ControlContent-LanguageContent-TypeExpiresLast-ModifiedPragma), 还能拿到这个字段声明的响应头字段。比如这样设置:

Access-Control-Expose-Headers: aaa

那么在前端可以通过 XMLHttpRequest.getResponseHeader('aaa') 拿到 aaa 这个字段的值。

非简单请求

非简单请求相对而言会有些不同,体现在两个方面: 预检请求响应字段

我们以 PUT 方法为例。

var url = 'http://xxx.com'
var xhr = new XMLHttpRequest()
xhr.open('PUT', url, true)
xhr.setRequestHeader('X-Custom-Header', 'xxx')
xhr.send()

当这段代码执行后,首先会发送预检请求。这个预检请求的请求行和请求体是下面这个格式:

OPTIONS / HTTP/1.1
Origin: 当前地址
Host: xxx.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom-Header

预检请求的方法是OPTIONS,同时会加上Origin源地址和Host目标地址,这很简单。同时也会加上两个关键的字段:

  • Access-Control-Request-Method, 列出 CORS 请求用到哪个 HTTP 方法
  • Access-Control-Request-Headers,指定 CORS 请求将要加上什么请求头

这是预检请求。接下来是响应字段,响应字段也分为两部分,一部分是对于预检请求的响应,一部分是对于 CORS 请求的响应。

预检请求的响应。如下面的格式:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 1728000
Content-Type: text/html; charset=utf-8
Content-Encoding: gzip
Content-Length: 0

其中有这样几个关键的响应头字段:

  • Access-Control-Allow-Origin: 表示可以允许请求的源,可以填具体的源名,也可以填*表示允许任意源请求。
  • Access-Control-Allow-Methods: 表示允许的请求方法列表。
  • Access-Control-Allow-Credentials: 简单请求中已经介绍。
  • Access-Control-Allow-Headers: 表示允许发送的请求头字段
  • Access-Control-Max-Age: 预检请求的有效期,在此期间,不用发出另外一条预检请求。

在预检请求的响应返回后,如果请求不满足响应头的条件,则触发XMLHttpRequestonerror方法,当然后面真正的CORS 请求也不会发出去了。

CORS 请求的响应。绕了这么一大转,到了真正的 CORS 请求就容易多了,现在它和简单请求的情况是一样的。浏览器自动加上Origin字段,服务端响应头返回Access-Control-Allow-Origin。可以参考以上简单请求部分的内容。

Node.js 写的后端实现跨域:

前端本地服务 3000 端口跨到后端 8080 端口

const http = require('http')

const cors = res => {
  // 根据需要设置
  res.setHeader('Access-Control-Allow-Origin', 'http://127.0.0.1:8080')
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE')
  res.setHeader('Access-Control-Allow-Headers', 'X-TOKEN')
  res.setHeader('Access-Control-Allow-Credentials', true)
}

const server = http.createServer((request, response) => {
  cors(response) // 从这里也可以看出是对响应的处理,加几个响应头告诉浏览器这个响应不要限制 🚫
  response.writeHead(200, { 'Content-Type': 'text/plain' })
  response.end('ok')
})

server.listen(8081, () => {
  console.log('Server is running at http://localhost:8081')
})

JSONP

script标签的src属性中的链接可以访问跨域的js脚本,利用这个特性,服务端不再返回JSON格式的数据,而是返回一段调用某个函数的js代码,在src中进行了调用,这样实现了跨域。

我们来看 一个天气查询查询的 jsonp 接口 query.asilu.com/weather/bai…

它有一个参数是 city

我们先来看看 请求 https://api.asilu.com/weather/?city=${'重庆'}&callback=weather会返回什么

/** api.asilu.com **/ weather({
  city: '重庆',
  pm25: '62',
  weather: [
    {
      date: '周三 09月16日',
      icon1: 'http://s1.bdstatic.com/r/www/aladdin/img/new_weath/bigicon/3',
      icon2: 'http://s1.bdstatic.com/r/www/aladdin/img/new_weath/bigicon/3',
      weather: '阴转小雨',
      wind: '北风微风',
      temp: '23 ~ 20℃',
    },
    {
      date: '周四',
      icon1: 'http://s1.bdstatic.com/r/www/aladdin/img/new_weath/icon/8',
      icon2: '',
      weather: '小雨转阴',
      wind: '北风微风',
      temp: '22 ~ 19℃',
    },
    {
      date: '周五',
      icon1: 'http://s1.bdstatic.com/r/www/aladdin/img/new_weath/icon/5',
      icon2: '',
      weather: '多云转晴',
      wind: '东北风微风',
      temp: '24 ~ 19℃',
    },
    {
      date: '周六',
      icon1: 'http://s1.bdstatic.com/r/www/aladdin/img/new_weath/icon/1',
      icon2: '',
      weather: '晴转多云',
      wind: '东北风微风',
      temp: '28 ~ 21℃',
    },
  ],
  date: '2020-11-24',
  s: 1606147200,
})

我们看到响应结果是一段 js 代码 调用了 weather 函数 而给 weather 函数传的数据就是我们需要的数据

那 weather 到底是哪来的?

其实就是我们自己写的函数,比如我的 weather 函数是这么写的,在拿到 data 之后更新 dom

function weather(data) {
  const city = document.querySelector('.city')
  const weather = document.querySelector('.weather')
  city.innerText = data.city
  weather.innerText = data.weather[0].weather
}

下一步就是把返回的这段 js 代码插入到我们的页面中了

const script = document.createElement('script')
script.src = `https://api.asilu.com/weather/?city=${'重庆'}&callback=weather`
document.body.appendChild(script)

最终效果

参考文章

什么是跨域?浏览器如何拦截响应?如何解决?

跨源资源共享(CORS)