跨域
什么是跨域?浏览器如何拦截响应?如何解决?
在前后端分离的开发模式中,经常会遇到跨域问题,即 Ajax 请求发出去了,服务器也成功响应了,前端就是拿不到这个响应。
一、什么是跨域?
1.什么是同源策略及其限制内容?
回顾一下 URI 的组成:
浏览器遵循同源政策(scheme(协议)
、host(主机)
和port(端口)
都相同则为同源
)。非同源站点有这样一些限制:
不能读取和修改对方的 DOM
不读访问对方的 Cookie、IndexDB 和 LocalStorage
限制 XMLHttpRequest 请求。
但是有三个标签是允许跨域加载资源:
<img src=XXX>
<link href=XXX>
<script src=XXX>
2.常见跨域场景
当协议、子域名、主域名、端口号中任意一个不相同时,都算作不同域。当浏览器向目标 URI 发 Ajax 请求时,只要当前 URL 和目标 URL 不同源,则产生跨域,被称为跨域请求
。
比如:
源 1 | 源 2 | 是否同源 |
---|---|---|
www.baidu.com | www.baidu.com/news | ✅ |
www.baidu.com | www.baidu.com | ❌ |
http://localhost:5000 | http://localhost:7000 | ❌ |
http://localhost:5000 | http://127.0.0.1:5000 | ❌ |
www.baidu.com | baidu.com | ❌ |
同源策略是指,若页面的源和页面运行过程中加载的源不一致时,出于安全考虑,浏览器会对跨域的资源访问进行一些限制
特别说明两点:
第一:如果是协议和端口造成的跨域问题“前台”是无能为力的。
第二:在跨域问题上,仅仅是通过“URL的首部”来识别而不会根据域名对应的IP地址是否相同来判断。“URL的首部”可以理解为“协议, 域名和端口必须匹配”。
跨域请求的响应一般会被浏览器所拦截,注意,是被浏览器拦截,响应其实是成功到达客户端了。
你可能会疑问明明通过表单的方式可以发起跨域请求,为什么 Ajax 就不会?因为归根结底,跨域是为了阻止用户读取到另一个域名下的内容,Ajax 可以获取响应,浏览器认为这不安全,所以拦截了响应。但是表单并不会获取新的内容,所以可以发起跨域请求。
二、跨域解决方案
有多种方式解决跨域问题,常见的有:
- 代理,常用
- CORS,常用
- JSONP
0.代理
对于前端开发而言,大部分的跨域问题,都是通过代理解决的
代理适用的场景是:生产环境不发生跨域,但开发环境发生跨域
因此,只需要在开发环境使用代理解决跨域即可,这种代理又称之为开发代理
在实际开发中,只需要对开发服务器稍加配置即可完成
// vue 的开发服务器代理配置
// vue.config.js
module.exports = {
devServer: { // 配置开发服务器
proxy: { // 配置代理
"/api": { // 若请求路径以 /api 开头
target: "http://dev.taobao.com", // 将其转发到 http://dev.taobao.com
},
},
},
};
1.CORS
CORS 其实是 W3C 的一个标准,全称是跨域资源共享
。它需要浏览器和服务器的共同支持,
它的总体思路是:如果浏览器要跨域访问服务器的资源,需要获得服务器的允许
要知道,一个请求可以附带很多信息,从而会对服务器造成不同程度的影响
比如有的请求只是获取一些新闻,有的请求会改动服务器的数据
针对不同的请求,CORS 规定了三种不同的交互模式,分别是:
- 简单请求
- 需要预检的请求
- 附带身份凭证的请求
简单请求
浏览器根据请求方法和请求头的特定字段,将请求做了一下分类,具体来说规则是这样,凡是满足下面条件的属于简单请求:
请求方法为 GET、POST 或者 HEAD
application/x-www-form-urlencoded
、multipart/form-data
、text/plain
)下面是一些例子:
// 简单请求
fetch('http://crossdomain.com/api/news');
// 请求方法不满足要求,不是简单请求
fetch('http://crossdomain.com/api/news', {
method: 'PUT',
});
// 加入了额外的请求头,不是简单请求
fetch('http://crossdomain.com/api/news', {
headers: {
a: 1,
},
});
// 简单请求
fetch('http://crossdomain.com/api/news', {
method: 'post',
});
// content-type不满足要求,不是简单请求
fetch('http://crossdomain.com/api/news', {
method: 'post',
headers: {
'content-type': 'application/json',
},
});
简单请求的交互规范
当浏览器判定某个ajax 跨域请求是简单请求时,会发生以下的事情
- 请求头中会自动添加
Origin
字段
比如,在页面http://my.com/index.html
中有以下代码造成了跨域
// 简单请求
fetch('http://crossdomain.com/api/news');
复制代码
请求发出后,请求头会是下面的格式:
GET /api/news/ HTTP/1.1
Host: crossdomain.com
Connection: keep-alive
...
Referer: http://my.com/index.html
Origin: http://my.com
Origin
字段会告诉服务器,是哪个源地址在跨域请求
- 服务器响应头中应包含
Access-Control-Allow-Origin
Origin
字段,用来说明请求来自哪个源
。服务器拿到请求之后,在回应时对应地添加Access-Control-Allow-Origin
字段,如果Origin
不在这个字段的范围中,那么浏览器就会将响应拦截。Access-Control-Allow-Origin: http://my.com或者
*:表示我很开放,什么人我都允许访问
Access-Control-Allow-Credentials
这个字段是一个布尔值,表示是否允许发送 Cookie,对于跨域请求,浏览器对这个字段默认值设为 false,而如果需要拿到浏览器的 Cookie,需要添加这个响应头并设为true
, 并且在前端也需要设置withCredentials
属性:
let xhr = new XMLHttpRequest();
xhr.withCredentials = true;
Access-Control-Expose-Headers
Cache-Control
、Content-Language
、Content-Type
、Expires
、Last-Modified
和Pragma
), 还能拿到这个字段声明的响应头字段。比如这样设置:Access-Control-Expose-Headers: aaa
那么在前端可以通过 XMLHttpRequest.getResponseHeader('aaa')
拿到 aaa
这个字段的值。
非简单请求
预检请求和响应字段。
如果浏览器不认为这是一种简单请求,就会按照下面的流程进行:
- 浏览器发送预检请求,询问服务器是否允许
- 服务器允许
- 浏览器发送真实请求
- 服务器完成真实的响应
比如,在页面http://my.com/index.html
中有以下代码造成了跨域
// 需要预检的请求
fetch('http://crossdomain.com/api/user', {
method: 'POST', // post 请求
headers: {
// 设置请求头
a: 1,
b: 2,
'content-type': 'application/json',
},
body: JSON.stringify({ name: '袁小进', age: 18 }), // 设置请求体
});
复制代码
浏览器发现它不是一个简单请求,则会按照下面的流程与服务器交互
- 浏览器发送预检请求,询问服务器是否允许
OPTIONS /api/user HTTP/1.1
Host: crossdomain.com
...
Origin: http://my.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: a, b, content-type
这是一个预检请求,它的目的是询问服务器,是否允许后续的真实请求。
预检请求没有请求体,它包含了后续真实请求要做的事情
预检请求有以下特征:
- 请求方法为
OPTIONS
- 没有请求体
- 请求头中包含
Origin
:请求的源,和简单请求的含义一致Access-Control-Request-Method
:后续的真实请求将使用的请求方法Access-Control-Request-Headers
:后续的真实请求会改动的请求头
- 服务器允许
服务器收到预检请求后,可以检查预检请求中包含的信息,如果允许这样的请求,需要响应下面的消息格式
HTTP/1.1 200 OK
Date: Tue, 21 Apr 2020 08:03:35 GMT
...
Access-Control-Allow-Origin: http://my.com
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: a, b, content-type
Access-Control-Max-Age: 86400
...
复制代码
对于预检请求,不需要响应任何的消息体,只需要在响应头中添加:
Access-Control-Allow-Origin
:和简单请求一样,表示允许的源Access-Control-Allow-Methods
:表示允许的后续真实的请求方法Access-Control-Allow-Headers
:表示允许改动的请求头Access-Control-Max-Age
:告诉浏览器,多少秒内,对于同样的请求源、方法、头,都不需要再发送预检请求了- Access-Control-Allow-Credentials: 允许携带Cookie。
在预检请求的响应返回后,如果请求不满足响应头的条件,则触发XMLHttpRequest
的onerror
方法,当然后面真正的CORS请求也不会发出去了。
- 浏览器发送真实请求
现在它和简单请求的情况是一样的。浏览器自动加上Origin
字段,服务端响应头返回Access-Control-Allow-Origin
POST /api/user HTTP/1.1
Host: crossdomain.com
Connection: keep-alive
...
Referer: http://my.com/index.html
Origin: http://my.com
{"name": "xiaoming", "age": 18 }
- 服务器响应真实请求
HTTP/1.1 200 OK
Date: Tue, 21 Apr 2020 08:03:35 GMT
...
Access-Control-Allow-Origin: http://my.com
...
添加用户成功
整个过程:
2.JSONP
虽然XMLHttpRequest
对象遵循同源政策,但是script
标签不一样,它可以通过 src 填上目标地址从而发出 GET 请求,实现跨域请求并拿到响应。这也就是 JSONP 的原理,JSONP只支持GET请求
JSONP封装实现流程:
// index.html
function jsonp({ url, params, callback }) {
return new Promise((resolve, reject) => {
let script = document.createElement('script') //动态生成script标签
window[callback] = function(data) { //声名回调函数
resolve(data)
document.body.removeChild(script)
}
params = { ...params, callback } // {wd:b,callback:show} 拷贝params对象并与callback合并
let arrs = []
for (let key in params) {
arrs.push(`${key}=${params[key]}`) //{wd=b}{callback=show}
}
script.src = `${url}?${arrs.join('&')}` //url+?+wd=b&callback=show
document.body.appendChild(script) // 删标签
})
}
jsonp({
url: 'http://localhost:3000/say',
params: { wd: 'Iloveyou' },
callback: 'show'
}).then(data => {
console.log(data)
})
// server.js
let express = require('express')
let app = express()
app.get('/say', function(req, res) {
let { wd, callback } = req.query
console.log(wd) // Iloveyou
console.log(callback) // show
res.end(`${callback}('我不爱你')`)
})
app.listen(3000)
1.声名一个回调函数,函数名当作参数值传递给跨域请求的服务器,函数形参接收服务器返回的data
2.创建一个<script>标签,设置其url为跨域接口地址+前端参数params+回调函数名,如上url+?+wd=b&callback=show
3.服务器收到请求后,把传递进来的函数名和要返回给客户端的数据拼接成字符串,如show('数据包')
4.客户端收到服务器返回的数据show('数据包')后执行回调函数
Nginx
反向代理拿到客户端的请求,将请求转发给其他的服务器,主要的场景是维持服务器集群的负载均衡,换句话说,反向代理帮其它的服务器拿到请求,然后选择一个合适的服务器,将请求转交给它。
server {
listen 80;
server_name client.com;
location /api {
proxy_pass server.com;
}
}
client.com
,让客户端首先访问 client.com/api
,这当然没有跨域,然后 Nginx 服务器作为反向代理,将请求转发给server.com
,当响应返回时又将响应给到客户端,这就完成整个跨域请求的过程。