同源策略限制
跨域指的是当两个url的协议,域名,端口三者有一个不同,就会发生跨域。之所以会有跨域,是因为浏览器的同源策略限制。
同源策略限制了一个源的脚本与另一个不同源的脚本之间的交互,这是一种安全保障机制。
是否同源的判断,只要看两个源的url端口和域名是否相同,只要有一个不同,那就是不同源。
同源策略的影响
具体的同源策略的影响,有以下三个
- DOM层面:同源策略限制了JavaScript对当前DOM对象的读和写操作
- 数据层面:同源策略限制了不同源的站点读取当前站点的cookie,indexDB,LocalStorage等数据。
- 网络层面:同源策略限制了通过XMLHttpRequest等方式将站点的数据发送给不同源的站点。这也是我们最常见的限制,但也是我们最常去通过跨域避免的层面
上面说到的,是同源策略带来的,基于安全性考虑的一些影响,但是这些影响,实际上也会给我们带来一些不便,就比如我们发起的请求,其实很多是跨域的,而实际开发中,我们也常常要去处理跨域的问题,这就涉及到同源策略带来的一个便利性和安全性的权衡。让不同源之间绝对隔离,自然是最安全的,但这样会使得开发的时候很不便利,所以在考虑安全性的同时,我们也要让出一些便利性。
跨域的解决方法
jsonp
jsonp简单来说就是通过在html中插入一个script标签,利用其src属性来发起请求,通过在src中为callback添加对应的回调函数来达到对响应数据进行处理的目的
如下例子
发起请求的客户端
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<script>
let sendJSONP = function(url, callbackName) {
// 新建script标签
let script = document.createElement("script");
// 为script标签添加请求送达的服务器url和得到响应的回调函数
script.src = `${url}&callback=${callbackName}`;
// 在head标签中插入script标签
document.head.appendChild(script);
};
let getResponse = function(data) {
// 打印响应信息
console.log(`the response you get is "${data}"`);
};
// 发起请求,这里的url为目标url
sendJSONP('url/?name=Mike &age=1', 'getResponse');
</script>
</body>
</html>
服务端 node
const util = require('util'),
http = require('http'),
url = require('url');
let data = JSON.stringify({
message: "I HAVE ALREADY RECEIVED"
});
http.createServer(function(req, res) {
req = url.parse(req.url, true);
if (!req.query.callback)
res.end();
console.log(`name is ${req.query.name}and his age is ${req.query.age}`);
res.writeHead(200, { 'Content-Type': 'appliction/javascript' })
res.end(req.query.callback + "('" + data + "')")
}).listen(3000)
这里打开服务器,然后打开我们写的页面
可以看到服务端控制台已经打印出对应的内容
客户端也打印出相应的响应信息
通过jsonp我们可以跨域发送请求,而且这种方式兼容老的浏览器,但是要注意的是,jsonp只允许发送get请求,而且因为我们是将script标签插入html中,所以无法做到异步请求
CORS
CORS是一个w3c标准,全称是“跨域资源共享”,通过额外的HTTP头来告诉浏览器,让运行在一个origin上的Web应用被准许访问不同域的服务器上指定的资源。
在我们常见的请求中,有很多都是跨域访问的,如img的src属性经常访问不同域的资源,但是出于安全性的考虑,脚本发出的请求,例如XMLHttpRequest和Fetch API遵循同源策略,不能访问不同域的资源。
如果要在脚本中也能访问不同域的资源,可以在服务器中设置CORS接口,这里主要是要服务器和浏览器(兼容性)的支持,前端的请求不需要做出改变。
CORS分简单请求和非简单请求
简单请求
符合简单请求的条件
请求方式为下面三个其中一个
- get
- head
- post
content-type的值仅限下面三个其中之一
- text/plain
- multipart/form-data
- application/x-www-form-urlencoded
设置了对 CORS 安全的首部字段集合之外的其他首部字段如下
- Accept
- Accept-Language
- Content-Language
- Content-Type (除了上面限制的那三个外)
- DPR
- Downlink
- Save-Data
- Viewport-Width
- Width
对于简单请求,浏览器直接发出CORS请求,也即是说,会自动在请求中添加origin字段。
当服务器接收到请求时,发现发出Origin指定的源不在允许访问的列表内,在响应时就会返回一个没有Access-Control-Allow-Origin字段的正常HTTP响应。而浏览器发现没有Access-Control-Allow-Origin字段后,就会知道请求出错,会在控制台中提示。要注意的是,这种错误的状态码是200,所以无法通过状态码来判断是否正确。
可以看到,即使报错,状态码仍是200。
我们可以通过设置Access-Control-Allow-Origin来让请求得以跨域
举个使用node设置CORS白名单的例子
3000端口的服务端文件
const http = require('http'),
url = require('url');
let data = JSON.stringify({
message: "I HAVE ALREADY RECEIVED"
});
http.createServer(function(req, res) {
// 设置http://localhost:3001可访问当前服务端的资源
res.setHeader('Access-Control-Allow-Origin', 'http://localhost:3001');
req = url.parse(req.url, true);
if (!req.query.callback)
res.end();
console.log(`name is ${req.query.name}and his age is ${req.query.age}`);
res.end(req.query.callback + "('" + data + "')")
}).listen(3000)
3001端口的服务端文件
const http = require('http');
const fs = require('fs');
http.createServer((req, res) => {
const html = fs.readFileSync('test.html', 'utf-8');
res.writeHead(200, {
'Content-Type': 'text/html'
});
res.end(html);
}).listen(3001);
发起请求的页面
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<script>
const xhr = new XMLHttpRequest();
xhr.open('get', 'http://localhost:3000/?name=Mike &age=1')
xhr.send()
</script>
</body>
</html>
可以看到,这里已经有Access-Control-Allow-Origin字段了,而且也不报错了。
如果将
res.setHeader('Access-Control-Allow-Origin', 'http://localhost:3001');
改为
res.setHeader('Access-Control-Allow-Origin', '*');
我们甚至可以在本地(‘file://’)获取服务端的资源
非简单请求
上面已经提及了简单请求是什么了,其他的请求为非简单请求。
对于非简单请求的CORS请求,会在正式请求前发送一个预请求,询问服务器发起请求的页面所在的域名是否在可访问服务器资源的名单中,如果在的话,正式发起请求,否则报错。
预请求的method为options,我们试着将上面的get请求改为put请求
xhr.open('put', 'http://localhost:3000/?name=Mike &age=1')
看看控制台
可以看到,这里的method已经变成了OPTIONS了。
同样地,和上面配置允许访问的域名一样,这里我们可以通过设置put请求可访问资源来实现跨域,在3000端口的服务端文件加上一行代码设置
http.createServer(function(req, res) {
res.setHeader('Access-Control-Allow-Origin', '*');
// ----------下面一行是增加的设置-------------------
res.setHeader('Access-Control-Allow-Methods','PUT')
// ----------上面一行是增加的设置-------------------
req = url.parse(req.url, true);
if (!req.query.callback)
res.end();
console.log(`name is ${req.query.name}and his age is ${req.query.age}`);
res.end(req.query.callback + "('" + data + "')")
}).listen(3000)
重启服务器,再次刷新页面,可以发现不报错了,查看控制台network,发现发起了两个请求
而且我们可以看到,两个请求虽然名字一样,但是Request Method是不一样的,显然一个是预请求,另一个才是真正的请求。
此外,一旦第一次预请求成功确定该请求可访问服务器资源,后面的请求就不必再发起预请求了
修改代码如下
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<!-- 点击发送请求 -->
<button onClick="send()">button</button>
<script>
function send() {
const xhr = new XMLHttpRequest();
xhr.open('put', 'http://localhost:3000/?name=Mike &age=1')
xhr.send()
}
</script>
</body>
</html>
我们会发现,除了第一次点击时会发起两个请求,后面点击都只会发起一个请求。
(这样说其实不太准确,应该是在预请求得到响应后,每次发起请求都不再发起预请求,如果这里快速点击按钮,可能会发起多个预请求)
但实际上,是否会再次发起options预请求会受到Access-Control-Max-Age的控制,Access-Control-Max-Age用来设定options预请求的有效时间,单位为秒,当时间过了那么再次发起非简单请求会再次发起options预请求
而默认情况下,在Firefox中,上限是24小时 (即86400秒),而在Chrome 中则是10分钟(即600秒)
上面是因为使用到了put,所以我们使用了Access-Control-Allow-Methods,而如果是因为发起了对 CORS 安全的首部字段集合之外的其他首部字段的话,我们需要同个设置Access-Control-Allow-Headers去允许部分首部字段的发起,像如果我们要允许发起一个appId,那就要将这个字段写入到Access-Control-Allow-Headers的值中,如果还有其它字段,可以用逗号分隔填入
http.createServer(function(req, res) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods','PUT')
// ----------下面一行是增加的设置-------------------
res.setHeader('Access-Control-Allow-Headers','appId')
// ----------上面一行是增加的设置-------------------
req = url.parse(req.url, true);
if (!req.query.callback)
res.end();
console.log(`name is ${req.query.name}and his age is ${req.query.age}`);
res.end(req.query.callback + "('" + data + "')")
}).listen(3000)
document.domain
document.domain的处理方式,感觉就像是域名的缩减
www.first.hello.com
www.second.hello.com
这里两个网址的域名一个为first.hello.com,一个为second.hello.com
可以通过将first.hello.com和second.hello.com的document.domain设置为hello.com就可以做到发送请求时不跨域
window.postMessage()
通过postMessage,我们可以向目标窗口发送MessageEvent消息
MessageEvent四个主要属性
- message:类型
- data:发送的内容
- origin:调用postMessaged的窗口地址
- source:调用postMessage的窗口信息
postMessage可以在一个窗口的引用对象上使用,比如当我们在窗口A使用window.open打开一个窗口B后,在打开的窗口B就可以使用window.opener来调用postMessage,这样就可以在打开的窗口B中,将信息发送给不同域的窗口A
举个例子
在CSDN首页使用window.open打开百度页面
window.open("https://www.baidu.com")
然后在CSDN页面监听其他页面发送的信息,打印出监听的信息(e.data)
window.addEventListener('message',function(e){
console.log(e.data);
},false)
在百度页面中,向CSDN首页发送信息
window.opener.postMessage({
data:123
},"https://www.csdn.net/");
在CSDN首页的控制台中,可以看到已经有东西打印出来了
除了使用window.open打开的页面来实现之外,也可以通过iframe的contentWindow属性来实现父窗口向不同域的子窗口发送信息的目的。
假设页面中有这个iframe标签,src的指向和当前页面的地址不同域
<body>
<iframe src="https://www.baidu.com" id="iframe" frameborder="0"></iframe>
</body>
在iframe窗口中设置监听
window.addEventListener('message',function(e){
console.log(e.data);
},false)
回到top发送信息
document.getElementById('iframe').contentWindow.postMessage({data:123},'https://www.baidu.com')
这里打印出的{data:123}
是在子页面中打印出来的
可以通过设置控制台选项来让控制台只显示一个窗口的内容
可以看到,的确是在子页面打印出的数据,子页面的确收到了不同域的父窗口的信息
websocket
websocket发送数据时不受同源策略影响,所以我们也可以利用websocket来做到跨域的请求,但使用websocket需要去进行协议变更的握手,且需要一直监听发送来的信息,个人认为一般尽可能采用其他方法来实现跨域
(这里websocket实现跨域只要建立一个websocket连接就可以了,比较简单,就先不在这里写出例子了,后面有空再补上)
代理服务器
同源策略是对于浏览器的限制,而对于服务器之间的请求,是没有限制的,这也就意味着,我们可以将我们的请求发送到一个允许我们跨域的服务器,然后由这个服务器去代理发送我们的请求,得到的响应再返回给我们的客户端,通过这种方式来实现跨域
实际上这种方式相当常见,使用过vue的同学如果遇到跨域问题时,在网上找方法时往往会找到一些通过配置就可以实现跨域的方法,这里的配置实际上就是在本地打开一个服务器,接收特定匹配的请求,将这些请求借由打开的这个服务器来代理发送,通过这种方式来实现跨域