一、同源策略
什么是浏览器的同源策略?浏览器出于安全考虑,只允许相同域下的资源进行交互,不同源下的脚本在没有明确授权的情况下,不能读写对方的资源,这就是同源策略。
而所谓的同源,指的是协议、域名、端口号
相同。举个例子:http://github.com:80
,其中http://
就是协议,而github.com
就是域名,80
是端口(默认端口可以省略)。只有这三个完全相同才算同源,任何一个不同都不算。假设当前的网址是http://www.a.com:80/a/index.js
,同源情况如下:
-
https://www.a.com:80
不同源(协议不同) -
http://www.a.com:90
不同源(端口号不同) -
http://a.com:80
不同源(域名不同) -
http://abc.a.com:80
不同源(域名不同) -
http://www.b.com:80
不同源(域名不同) -
http://192.110.110.110:80
不同源(192.110.110.110
是www.a.com
对应的ip也认为域名不同) -
http://www.a.com:80/b/index.js
同源(只要前部分相同,后面不同文件夹也可以)
如果两个网站不同源,则交互会受到限制,具体如下:
- 不能共享cookie、storage和IndexedDB。
- 不能互相操作dom。
- 不能向非同源地址发送 AJAX 请求(可发但浏览器会拒绝接受响应)。
而为啥要有这个策略限制呢?试想一下,在一个窗口刚登录过银行网站http://www.bank.com,然后又切换到另一个页面http://hacker.com,如果这个与银行非同源页面能够访问到银行页面的cookie,而cookie里却保存着银行的账号和密码,这是一件非常危险的事情。
二、跨域方案
虽然这个同源策略是为了安全而诞生的。但有时候开发我们却需要有一些跨域请求或操作。比如使用第三方服务而需要请求第三方服务器,这就是所谓的跨域。
那如何避开浏览器的同源策略实现跨域呢?此处主要整理了八种方案,主要分为三类:
- AJAX 请求的跨域
- iframe 的跨域(又可分为主域相同和主域不同)
- 通过服务器代理实现跨域
(一)AJAX 请求的跨域
1. JSONP
JSONP是跨域中非常常见的一种形式,他支持所有老版的浏览器。其主要是利用页面上请求<script>
脚本不受同源策略限制的特性,来实现跨域。主要步骤如下:
- 创建一个接收后处理数据的回调函数,并在被请求的url后面增加
callback=funcName
。 - 创建一个
<script>
元素,src指定为上面增加了callback的url。 - 发起脚本请求,服务器返回数据后会自动执行script脚本从而执行了回调函数。
(1) 原生版本
// 前端页面代码
<script>
// 服务器收到请求后会将数据放在回调函数的参数位置返回
function jsonpCallback(data) {
console.log(data.msg); // 作为参数的JSON数据被视为js对象而非字符串,不需要JSON.parse
}
</script>
<script src="http://127.0.0.1:8080?callback=jsonpCallback"></script>
// 后端代码,node版本
const url = require('url');
const http = require('http');
// 启动http服务
http.createServer((req, res) => {
const data = { msg: 'success' };
const callback = url.parse(req.url, true).query.callback; // 解析url取函数名
res.writeHead(200); // 返回成功的状态码200
res.end(`${callback}(${JSON.stringify(data)})`); // 向前端返回数据
}).listen(8080, '127.0.0.1'); // 监听来自127.0.0.1:8080端口的请求
(2)jquery的ajax版本
// 前端代码
$.ajax({
url: 'http://127.0.0.1:8080',
type: 'get',
dataType: 'jsonp', // 请求方式为jsonp
jsonpCallback: "jsonpCallback", // 回调函数名
data: {}
});
(3)vue版本
this.$http.jsonp('http://127.0.0.1:8080', {
params: {},
jsonp: 'jsonpCallback'
}).then((data) => {
console.log(data);
})
JSONP的优点和缺点如下:
优点:
(1)支持所有浏览器即使是旧版浏览器。
(2)可用于k向一些不支持 CORS 的网站请求数据。
(3)不需要 XMLHttpRequest 或 ActiveX 的支持,所以不受 XMLHttpRequest 同源策略限制;请求完毕后自动调用 callback 并回传结果。
缺点:
(1)只支持get请求。
(2)无法捕获请求时的连接异常,只能通过超时进行处理。
(3)无法解决iframe页面之间的数据通信问题。
2. CORS
所谓CORS,全称Cross-Origin Resource Sharing
跨域资源共享,是一个W3C标准,专门用于解决 AJAX 的跨域问题。它允许浏览器向跨源服务器发出 XMLHttpRequest 请求(如果没有增加 CORS 支持则不能向非同源发送 AJAX 请求,会被浏览器拦截)。
CORS 需要浏览器和服务器同时支持才生效,前端不需要做任何操作。当浏览器发现发送的请求是跨域请求时,会自动在请求头加上Origin字段表明当前的域(协议+域名+端口号
),非简单请求还会先发送一次额外请求进行预检,但这都是浏览器自动完成的,用户和前端并不需要增加操作。而请求之后服务器也会返回一个http响应,如果返回的响应中带有Access-Control-Allow-Origin
,并与上面请求头的Origin相匹配的话,那么即允许跨域;否则抛出错误被 XMLHttpRequest 的onerror回调函数捕获。注意,这种错误状态码可能是200,所以不能通过状态码去判断。
所以,可以见得,实现CORS的关键是后端要增加相应的字段。具体见下面的例子(还可以返回更多的头信息如Access-Control-Allow-Credentials
等,此处只写了关键的一步):
// 服务端代码,node版本
const http = require('http');
// 启动http服务
http.createServer((req, res) => {
res.writeHead(200, {
'Access-Control-Allow-Origin': '*', // 也可以直接写允许请求的域如http://www.baidu.com;*表示所有的域都可以请求,适合公开接口
'Content-Type': 'text/html;charset=utf-8',
});
res.end();
}).listen(8080, '127.0.0.1'); // 监听来自127.0.0.1:8080端口的请求
CORS 的优缺点如下:
优点:
(1)比 JSONP 更强大,支持所有类型的 HTTP 请求。
(2)是W3C标准,大部分浏览器自动完成,只需服务器增加些许字段,非常方便。
缺点:仅支持 IE 10 以上等新版浏览器。
3. WebSocket
WebSocket 是HTML5一种新的协议。它实现了浏览器与服务器的双向通信,同时不受同源策略限制,允许跨域通讯,使用ws://(非加密)和wss://(加密)作为协议前缀。
// 前端代码,websocket版本
var ws = new WebSocket('ws://http://127.0.0.1:8080/websocket/chat'); // 创建连接,安全连接用wss
// 建立连接时调用
ws.onopen = function() {
console.log('Connection open ...');
ws.send('Hello WebSocket!'); // 发送消息给服务端
}
// 接收服务器发送过来的消息
ws.onmessage = function() {
var data = msg.data;
if(typeof data === String) {
console.log(data);
}
if(data instanceof ArrayBuffer) {
// 处理ArrayBuffer逻辑...
}
ws.close(); // 关闭连接
}
// 关闭时调用
ws.onclose = function() {}
// 错误处理
ws.onerror = function(err) {}
一般使用WebSocket,我偏向使用socket.io。后者对前者的API进行了封装,使其更易用。前者只支持 IE 10 以上等新版浏览器,而后者兼容了旧版浏览器。所以此处增加个socket.io的例子。
// 前端代码,socket.io版本
<script src="./socket.io.js"></script>
<script>
var socket = io('http://127.0.0.1:8080');
// 连接成功处理
socket.on('connect', function() {
// 监听接收服务端消息
socket.on('message', function(data){});
// 服务端关闭调用
socket.on('disconnect', function(){});
});
socket.send('Hello WebSocket!'); // 发送消息给服务端
</script>
// 后端代码,node + socket.io版本
var http = require('http');
var socket = require('socket.io');
var server = http.createServer(function(req, res) {
res.writeHead(200, {
'Content-type': 'text/html'
});
res.end();
});
server.listen('8080');
// 监听连接
socket.listen(server).on('connection', function(client) {
// 接收信息
client.on('message', function(data) {
client.send(data);
});
// 断开处理
client.on('disconnect', function(){});
});
优点:
(1)不受同源策略的限制,支持与任意服务端通信。
(2)可以进行双向通信,服务端也可以主动给客户端推送消息。(传统的 HTTP 只允许客户端发起请求,只能用轮询来了解服务端是否更新信息,效率低浪费资源。)
(二)iframe实现跨域
iframe的跨域实现,主要有document.domain、location.hash、window.name、postMessage四种方案,下面我们一一解析。
1. document.domain
这种方法适合一级域名相同,二级域名不同的情况下使用。域名相关见如下:
- 顶级域名:
.com
.net
.edu
.gov
等属于通用顶级域名
.com.cn
.net.cn
.edu.cn
等属于带有国家地区代码顶级域名,而不是所谓的一级域名- 一级域名(又叫主域名)就是最靠近顶级域名左侧的字段,下面均属于一级域名:
baidu.com
qq.com
baidu.com.cn
qq.com.cn
- 二级域名,即最靠近二级域名左侧的字段,二级及以上级别域名称为子域名:
www.baidu.com
www.qq.com
www.baidu.com.cn
www.qq.com.cn
- 再接下来从右向左便可依次有三级域名、四级域名、五级域名等,依次类推即可。
// 主页面域名http://parent.main.com
<script>
document.domain = 'main.com'; // 将两个窗口的域名都设置为一级域名
let iframe = document.getElementsByTagName('iframe')[0];
let data = iframe.contentWindow.data; // 获取子窗口里的data数据
</script>
// 子窗口域名http://child.main.com
<script>
document.domain = 'main.com'; // 将子窗口的域名设置为一级域名
let data = window.parent.data; // 获取父窗口里的data数据
</script>
缺点:只支持主域名相同的父子窗口通信。
2. location.hash
location.hash
获取的是当前地址栏url的片段识别符,即http://example.com/x.html#fragment
里#
及后面部分#fragment
。由于单纯改变片段识别符并不会导致页面刷新,所以我们可以利用这个特性来让父子窗口互相传值。
// 父窗口修改子窗口的hash
var src = childURL + '#' + data;
document.getElementById('childIFrame').src = src;
// 父窗口监听hash改变
window.onhashchange = function() {
var data = window.location.hash; // 获取子窗口传过来的数据
}
如果两个窗口不在同一个域下, IE、Chrome 不允许子窗口修改 parent.location.hash 的值,所以要借助于一个和父窗口同域的页面来实现修改 hash 值。
// 子窗口监听hash改变
window.onhashchange = function() {
var data = window.location.hash; // 获取父窗口传过来的数据
}
// 子窗口也可以修改父窗口的hash,但需要一个与父窗口同域的代理窗口来修改hash值
let iframe = document.createElement('iframe');
iframe.src = parentURL;
iframe.style.display = 'none';
document.body.appendChild(iframe);
iframe.onload = function() {
parent.parent.location.href = parentURL + '#' + data;
}
// 同域可以直接用下面的写法
// parent.location.href = parentURL + '#' + data;
缺点:会改变url上面的#后的值,数据直接暴露在url上。
3. window.name
window.name
是窗口的名字,每个子窗口也有自己的window
和window.name
。只能保存字符串,如果写入的值不是字符串,会自动转成字符串。其特殊之处在于只要窗口不关闭,这个属性便不会消失,且储存容量可高达几MB。如果加载了a.com之后写入window.name
,窗口不关闭重新加载b.com,此时window.name
还是a.com写入的值;窗口关闭window.name
清除。
利用这个特性,我们可以在当前域下创建一个目标域的子窗口,目标域的数据放在window.name
,加载完成后再让子窗口跳到一个父域相同的空白代理页(window.name
还是不变),获取这个代理页的window.name
赋值给父域即可。注意,一定要有与父域同域的代理页,否则父域是无法直接获取子窗口的window.name
的;另外重新跳转页面会触发onload,要注意避免死循环,可以加个loaded标记。
// 当前页面,即http://parent.com
<script>
let isIFrameLoaded = false,
data = '',
iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = 'http://target.com';
document.body.appendChild(iframe);
iframe.onload = function() {
if(!isIFrameLoaded) { // 首次进入读取到window.name,然后刷新到代理页面window.name不变
isIFrameLoaded = true; // 下面刷新会再次进入onload,此处标记为已完成避免死循环
iframe.contentWindow.location = 'http://parent.com/proxy.html';
} else {
data = iframe.contentWindow.name; // 获取到目标域下的数据
// 清除iframe
iframe.contentWindow.document.write('');
iframe.contentWindow.close();
document.body.removeChild(iframe);
}
};
</script>
// 目标数据页面,即http://target.com,将想要提供的数据保存在window.name即可
<script>
// 注意只能是字符串,若内容有引号根据需要可能要进行转义处理
window.name = JSON.stringify({code: 0, result: {name: 'Peter', age: 18}});
</script>
缺点:
(1)比较繁琐,需要增加代理页面,还要避免重复循环等。
(2)目标数据要放在window.name,格式只能是字符串。
(3)缺少请求源控制,任何页面都可以按同样的方式访问到目标页面的数据。
4. window.postMessage
可以无论hash
还是window.name
都是利用一些特性来绕个弯达到目的,均属破解。为了解决该问题,HTML5 引入了一个新API跨文档通信 API(Cross-document messaging),无论两个窗口页面是否同源,都可以通过调用window.postMessage(content, target)
来进行通信。其中target为协议域名端口号,可以设置“ * ”代表向全部窗口发送,也可以指定“ / ”代表当前域。父子窗口通过监听message事件可以获得来源方的这些信息event.origin(源网址)
、event.data(携带的数据)
、event.source(发送消息方的窗口)
。
// 父窗口,http://origin.com
let iframe = document.createElement('iframe');
let targetURL = 'http://target.com';
iframe.src = targetURL;
iframe.style.display = 'none';
document.body.appendChild(iframe);
iframe.onload = function() {
// 引用子窗口触发其message事件
iframe.contentWindow.postMessage('Hello target', targetURL);
}
// 监听当前窗口的message改变来获取数据
window.addEventListener('message', function(event) {
console.log(event.data); // 获取目标页面的数据 ‘Hello origin’
});
// 子窗口或目标页面,http://target.com
window.addEventListener('message', receiveMessage);
function receiveMessage(event) {
// 检验数据请求方是否为自己的网址
if (event.origin !== 'http://origin.com') return;
if (event.data === 'Hello World') {
// 引用父窗口触发父窗口的message事件
event.source.postMessage('Hello origin', event.origin);
} else {
console.log(event.data);
}
}
优点:
(1)多个窗口(嵌套与否均可)之间可以进行跨域通信和操作window属性等,非常强大,也让跨域存储localStorage成为了可能。
(2)可以对来源进行校验,控制是否有权访问。
(三)服务器代理
最后一类实现跨域的方法是通过架设代理服务器来实现跨域。即先请求同源服务器,再由同源服务器请求外部服务器。由于请求的是同源服务器,所以不受浏览器同源策略限制,而服务器之间的请求也没有同源策略这一说,所以以此达到跨域目的。由于对服务器方面了解并不是很深,此处就不做展开,有兴趣可以自行了解下。
至此,八种跨域的方式终于讲完了(写了好久...吃掉每个小点再讲明白真不容易,可能还有图片ping啥的,先躺倒休息会...),以上全部都是简单案例可根据需求进行优化扩充,如有错漏,欢迎指出!