前言
日常处理前后端数据交互的过程中,跨域问题并不陌生,相信大部分人看到跨域问题就能立马想到它的解决办法,但你真的了解它的实现原理吗?尝试回答下面的问题:
- 为什么会存在跨域问题?
- 跨域是谁的机制?
- 同源策略是什么?它限制了什么?
- 为什么浏览器会使用同源策略?
- JSONP可以利用哪些标签绕开限制?
- 跨域请求如何携带cookie?
- options预检请求什么时候触发?它是否会真正执行请求?它的作用是什么?
- 浏览器会在什么时机拦截?请求能发出去吗?
- 你知道哪些常用的解决跨域的方法?
- 你能说出解决跨域的各个方法实现原理吗?
...
跨域4个W1个H
一、what:什么是跨域?
- 广义的跨域:一个域下的文档或脚本试图去请求另一个域下的资源。
- 狭义的跨域:由浏览器同源策略限制的一类请求场景。
二、when:什么时候会发生跨域?
不同域(协议、子域名、主域名、端口号中任意一个不相同)之间相互请求资源的时候就发生了。
三、why:为什么会存在跨域的问题?
1、什么是同源策略?
同源策略是一种约定,它是浏览器最核心也最基本的安全功能,如果缺少了同源策略,浏览器很容易受到 XSS、CSRF 等攻击。
2、为什么浏览器会使用同源策略?
设置同源策略是为了保证用户信息的安全,防止恶意的网站窃取数据。如果没有同源限制,在浏览器中的cookie等其他数据可以任意读取,不同域下的DOM可以任意操作,任意请求其他网站的数据,包括隐私数据。后果将无法想象...
(1995年,同源策略由 Netscape 公司引入浏览器。目前所有浏览器都实行这个政策。浏览器同时还规定,提交表单不受同源同源策略的限制)
3、同源策略限制了什么?
- Cookie、LocalStorage、IndexedDB 等存储性内容无法获取
- DOM 节点无法获取
- AJAX 请求不能发送
四、where: 常见的跨域场景?
协议/域名/端口 只要有其一不同就是跨域;即便是同一个ip地址对应的两个不同域名之间也是跨域(因为它仅通过 URL的首部 来识别,而不是根据域名对应的IP地址是否相同来判断)。
http端口号80,https端口号443
五、how: 怎样解决跨域问题?
1、jsonp
通常为了减轻web服务器的负载,我们把js、css,img等静态资源分离到另一台独立域名的服务器上,在html页面中再通过相应的标签从不同域名下加载静态资源,而被浏览器允许,基于此原理,我们可以通过动态创建script,再请求一个带参网址实现跨域通信。
<img src=XXX>
<link href=XXX>
<script src=XXX>
<iframe src=XXX>
实现原理:利用带有 src 属性的以上标签都不受同源策略的限制的漏洞,因而绕过了限制。
基本思想:网页通过添加一个<script>元素,向服务器请求JSON数据,这种做法不受同源政策限制;服务器收到请求后,将数据放在一个指定名字的回调函数里传回来。
缺点:需要前后端配合,且src中只能是get请求、不安全可能会遭受XSS攻击
那为什么同源策略不限制这些标签呢 因为通过标签引入的代码,不会被js代码更改,因而认定它是安全的,故不会对其限制。
实践:
搭建node server
const http = require('http');
const url = require('url'); //引入url模块解析url字符串
const querystring = require('querystring'); //引入querystring模块处理query字符串
const server = http.createServer(); //创建新的HTTP服务器
const resData = {
name: 'june',
password: '123456'
};
//通过request事件来响应request请求
server.on('request', function (req, res) {
var urlPath = url.parse(req.url).pathname;
var qs = querystring.parse(req.url.split('?')[1]);
if (urlPath === '/jsonp' && qs.callback) {
res.writeHead(200, { 'Content-Type': 'application/json;charset=utf-8' });
var callback = qs.callback + '(' + JSON.stringify(resData) + ');';
res.end(callback);
} else {
res.writeHead(200, { 'Content-Type': 'text/html;charset=utf-8' });
res.end('Hell World\n');
}
});
// 配置监听端口号及成功运行提示
server.listen(3000, () => {
console.log('The server is running at http://localhost:3000');
});
JSONP将访问跨域请求变成了执行远程JS代码,服务端不再返回JSON格式的数据,而是返回了一段将JSON数据作为传入参数的函数执行代码。
我们先来尝试使用ajax请求
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>JSONP</title>
</head>
<body>
<script type="text/javascript" src="http://code.jquery.com/jquery-1.8.3.min.js"></script>
<script type="text/javascript">
$(document).ready(function () {
$.ajax({
type: 'get',
async: false,
url: 'http://localhost:3000/jsonp?callback=jsonpCallback',
type: 'json',
success: function (data) {
jsonhandle(data);
},
});
});
</script>
</body>
</html>
利用jsonp的基本思想,通过添加一个<script>元素,向服务器请求JSON数据
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>JSONP解决跨域问题</title>
</head>
<body>
<script>
function addScriptTag(src) {
var script = document.createElement('script');
script.setAttribute('type', 'text/javascript');
script.src = src;
document.body.appendChild(script);
}
window.onload = function () {
addScriptTag('http://localhost:3000/jsonp?callback=jsonpCallback');
};
// 回调的方法,且必须为全局方法,不然会报错
function jsonpCallback(data) {
console.log(data);
}
</script>
</body>
</html>
2、cros
CORS(Cross-origin resource sharing 跨域资源共享),是一种基于 HTTP 头的机制,该机制通过允许服务器标示除了它自己以外的其它 origin(域,协议和端口),使得浏览器允许这些 origin 访问加载自己的资源。
它允许浏览器向跨源服务器,发出 XMLHttpRequest 请求,从而克服了 AJAX 只能同源使用的限制。
2.1: 那为什么设置cors接口之后就不会激活同源策略呢?因为js并不能更改request header,一旦它可以被更改那么这种方式也将失效。
2.2: 一个请求跨域了,这个请求是否已经发出去了?
1、首先我们需要明确跨域是谁的策略?浏览器;
2、在什么时机会拦截请求?浏览器的策略和服务器并没关系,服务器不会对跨域请求做拦截,即使服务器想要拦截也没办法判断是否跨域,http request的Header是可以被篡改的。那是否浏览器会直接拦截不让请求发出去呢?我们知道浏览器会根据服务器返回的header去判断请求是否允许跨域,如果没有发出去请求,这个header信息从哪里来呢?浏览器又怎会知道 Server 允许请求在哪些 Origin 下跨域发送?所以请求是发出去的,只不过返回数据会被浏览器给拦截掉。
3、请求是否会真正执行?我们先来区分一下简单请求和复杂请求:
简单请求(simple request) 需要满足下面的条件:
-
使用下列方法之一
-
HTTP的头信息不超出以下几种字段:
- Accept
- Accept-Language
- Content-Language
- Last-Event-ID
Content-Type的值仅限于下列三者之一:text/plain、multipart/form-data、application/x-www-form-urlencoded
-
请求中的任意
XMLHttpRequest对象均没有注册任何事件监听器;XMLHttpRequest对象可以使用XMLHttpRequest.upload属性访问。 -
请求中没有使用
ReadableStream对象
这是为了兼容表单(form),因为历史上表单一直可以发出跨域请求。AJAX 的跨域设计就是,只要表单可以发,AJAX 就可以直接发。
复杂请求(not-so-simple request):不满足上述条件的就是复杂请求
对于复杂请求,浏览器必须首先使用 OPTIONS 方法发起一个预检请求(preflight request,目的:避免跨域请求对服务器的用户数据产生未预期的影响),从而获知服务端是否允许该跨源请求、支持哪些 HTTP 方法。服务器确认允许之后,才发起实际的 HTTP 请求(因此在network中会看到两次接口请求)。在预检请求的返回中,服务器端也可以通知客户端,是否需要携带身份凭证。
服务器收到"预检"请求以后,检查 Origin、Access-Control-Request-Method和Access-Control-Request-Headers 字段来确认是否允许跨源请求;若服务器否定了"预检"请求,会返回一个正常的HTTP回应,但是没有任何CORS相关的头信息字段。这时,浏览器就会认定,服务器不同意预检请求,因此触发一个错误。
2.3: 跨域如何携带cookie?
CORS请求默认不发送Cookie和HTTP认证信息。如果要把Cookie发到服务器,一方面要服务器同意,指定 Access-Control-Allow-Credentials:true,另一方面开发者必须在AJAX请求中设置 withCredentials:true。否则,即使服务器同意发送Cookie,浏览器也不会发送。或者,服务器要求设置Cookie,浏览器也不会处理。但,如果省略withCredentials设置,有的浏览器还是会一起发送Cookie。这时,可以显式关闭withCredentials:false
注:
Origin用来说明,本次请求来自哪个源(协议 + 域名 + 端口)。服务器根据这个值,决定是否同意这次请求Access-Control-Allow-Origin: <origin> | *指定了允许访问该资源的外域 URIAccess-Control-Allow-Credentials: true来表明可以携带凭据进行实际的请求- 当响应的是 附带身份凭证的请求 时,服务端 必须 明确
Access-Control-Allow-Origin的值,而不能使用通配符 “*” - 实际的
POST请求不会携带Access-Control-Request-*首部,它们仅用于OPTIONS请求。
实践:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>cros解决跨域问题</title>
</head>
<body>
<script>
let xhr = new XMLHttpRequest();
document.cookie = 'name=june';
xhr.withCredentials = true; // 前端设置是否允许携带cookie
xhr.open('PUT', 'http://localhost:4000/getData', true);
xhr.setRequestHeader('name', 'june');
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
console.log(xhr.getResponseHeader('name'));
}
}
};
xhr.send();
</script>
</body>
</html>
local.js
const express = require('express');
const path = require('path');
const app = express();
app.use(express.static(__dirname)); // 同层级的文件可以直接
app.listen(3000, () => {
console.log('cros server is running at http://localhost:3000');
});
origin.js
let express = require('express');
let app = express();
let whitList = ['http://localhost:3000']; //设置白名单
app.use(function (req, res, next) {
let origin = req.headers.origin;
if (whitList.includes(origin)) {
// 设置哪个源可以访问:内部接口可以直接设置为*,外部接口一般正常返回允许跨域的域名
res.setHeader('Access-Control-Allow-Origin', origin);
// 允许哪个方法访问:内部接口一般也是直接设置为*,外部接口会返回允许的所有方法,逗号分割返回
res.setHeader('Access-Control-Allow-Methods', 'PUT');
// 允许携带哪个头访问
res.setHeader('Access-Control-Allow-Headers', 'name');
// 允许携带cookie
res.setHeader('Access-Control-Allow-Credentials', true);
// 预检的存活时间
res.setHeader('Access-Control-Max-Age', 6);
// 允许返回的头
res.setHeader('Access-Control-Expose-Headers', 'name');
if (req.method === 'OPTIONS') {
res.end(); // OPTIONS请求不做任何处理
}
}
next();
});
app.put('/getData', function (req, res) {
console.log(req.headers);
res.setHeader('name', 'returnHeader'); //返回一个响应头,后台需设置
res.end('put is ok');
});
app.get('/getData', function (req, res) {
console.log(req.headers);
res.end('get is ok');
});
app.use(express.static(__dirname));
app.listen(4000, () => {
console.log('cros server is running at http://localhost:4000');
});
node运行local.js和serve.js,在端口号3000运行html,实现与端口号4000之间进行通信。
3、nginx反向代理
使用nginx反向代理实现跨域,是最简单的跨域方式。
基本原理:我们已经知道同源策略是浏览器的安全策略。服务器端调用HTTP接口只是使用HTTP协议,故不存在跨域的问题
实现思路:通过nginx配置一个代理服务器(域名与domain1相同,端口不同)做跳板机,反向代理访问domain2接口,并且可以顺便修改cookie中domain信息,方便当前域cookie写入,实现跨域登录。
nginx.conf配置如下,通过命令行nginx -s reload启动nginx
server {
listen 81;
server_name www.domain1.com;
location / {
proxy_pass http://www.domain2.com:8080; #反向代理
proxy_cookie_domain www.domain2.com www.domain1.com; #修改cookie里域名
index index.html index.htm;
# 当用webpack-dev-server等中间件代理接口访问nignx时,此时无浏览器参与,故没有同源限制,下面的跨域配置可不启用
add_header Access-Control-Allow-Origin http://www.domain1.com; #当前端只跨域不带cookie时,可为*
add_header Access-Control-Allow-Credentials true;
}
}
前端代码:
var xhr = new XMLHttpRequest();
// 浏览器是否读写cookie
xhr.withCredentials = true;
// 访问http-proxy-middleware代理服务器
xhr.open('get', 'http://www.domain1.com:3000/login?user=admin', true);
xhr.send();
node后台:
var http = require('http');
var server = http.createServer();
var qs = require('querystring');
server.on('request', function(req, res) {
var params = qs.parse(req.url.substring(2));
// 向前台写cookie
res.writeHead(200, {
'Set-Cookie': 'l=a123456;Path=/;Domain=www.domain2.com;HttpOnly' // HttpOnly:脚本无法读取
});
res.write(JSON.stringify(params));
res.end();
});
server.listen('8080');
console.log('Server is running at port 8080...');
4、node中间件代理
实现原理和nginx类似,它是通过设置cookieDomainRewrite参数修改响应头中cookie中的域名,来实现当前域的cookie写入。
利用node + express + http-proxy-middleware搭建一个proxy服务器(前端代码及node后台代码与上面一致)中间件服务器实现:
const express = require('express');
const proxy = require('http-proxy-middleware');
const app = express();
app.use('/', proxy({
// 代理跨域目标
target: 'http://www.domain2.com:8080',
changeOrigin: true,
// 修改响应头信息,实现跨域并允许携带cookie
onProxyRes: function(proxyRes, req, res) {
res.header('Access-Control-Allow-Origin', 'http://www.domain1.com');
res.header('Access-Control-Allow-Credentials', 'true');
},
// 修改响应信息中的cookie域名
cookieDomainRewrite: 'www.domain1.com' // 可以为false,表示不修改
}));
app.listen(3000, () => {
console.log('Proxy server is listen at port 3000...');
});
5、postMessage
postMessage()方法允许来自不同源的脚本采用异步方式进行有限的通信,可以实现跨文本档、多窗口、跨域消息传递
语法:otherWindow.postMessage(message, targetOrigin, [transfer]);
a.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>postMessage-a</title>
</head>
<body>
<iframe src="http://localhost:3000/b.html" frameborder="0" id="frame" onload="load()"></iframe>
<script>
function load() {
const frame = document.getElementById('frame');
frame.contentWindow.postMessage('send message', 'http://localhost:3000'); //发送数据
window.onmessage = function (e) {
// 接收传出的数据
console.log(e.data); // postBack
};
}
</script>
</body>
</html>
b.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>postMessage-b</title>
</head>
<body>
<script>
window.addEventListener('message', receiveMessage, false);
function receiveMessage(event) {
console.log(event.data); // send message
var origin = event.origin || event.originalEvent.origin;
if (origin !== 'http://localhost:4000') return;
event.source.postMessage('postBack', event.origin);
}
</script>
</body>
</html>
将a.html运行在本地端口号4000,b.html运行在本地端口号3000,运行结果如下
6、websocket
背景:一般地,为了实现推送技术,所用的技术都是轮询。轮询是在特定的的时间间隔,由浏览器对服务器发出HTTP请求,然后服务器返回最新的数据给客户端的浏览器。这种传统的模式存在以下缺点:
- 推送延迟
- 服务端压力:每次数据交换除了真正的数据部分外,服务器和客户端还要大量交换HTTP header
- 推送延迟和服务端压力无法中和。减小轮询间隔,延迟降低,压力增加;反之,增加轮询的间隔,压力降低,延迟增高
WebSocket是一种在单个 TCP 连接上进行 全双工 通信的 持久化 协议。
WebSocket在建立握手时,数据是通过HTTP传输的。但建立之后,借助于TCP传输信道进行全双工通信,使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。
创建 长链接 ,实时性优势明显。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webSocket</title>
</head>
<body>
<div>
input:<input type="text" />
</div>
<script src="https://cdn.bootcss.com/socket.io/2.2.0/socket.io.js"></script>
<script>
var socket = io('http://localhost:8080');
// 连接成功处理
socket.on('connect', function () {
// 监听服务端消息
socket.on('message', function (msg) {
console.log('data from server: ' + msg);
});
// 监听服务端关闭
socket.on('disconnect', function () {
console.log('Server socket has closed.');
});
});
document.getElementsByTagName('input')[0].onblur = function () {
socket.send(this.value);
};
</script>
</body>
</html>
var http = require('http');
var socket = require('socket.io'); // socket.io@2.0.4
// 启http服务
var server = http.createServer(function (req, res) {
res.writeHead(200, {
'Content-type': 'text/html',
});
res.end();
});
server.listen('8080', () => {
console.log('Server is running at port 8080...');
});
// 监听socket连接
socket.listen(server).on('connection', function (client) {
// 接收信息
client.on('message', function (msg) {
client.send('hello:' + msg);
console.log('data from client: ---> ' + msg);
});
// 断开处理
client.on('disconnect', function () {
console.log('Client socket has closed.');
});
});
小结
给耐心看完这篇文章的你点赞,相信到这里,开头提到的问题你都已经有了答案。
我们会发现,大部分解决跨域的办法都是 ‘绕过去‘ 的机制。
日常工作中我们使用较多的是cros和nginx代理;在使用canvas的时候也会经常遇到画布被污染等提示报错,熟悉跨域的原理之后我们都能轻松应对。欢迎大家一起探讨,共同进步!