那些年,那些跨域问题

浏览器在请求不一样域的资源时,会由于同源策略的影响请求不成功,这就是一般被提到的“跨域问题”。做为前端开发,解决跨域问题应该是一个被熟练掌握的技能。而随着技术不断的更迭,针对跨域问题的解决也衍生出了多种解决方案。咱们一般会根据项目的不一样须要,而采起不一样的方式。这篇文章,将详细总结跨域问题的相关知识点,以便在遇到相同问题的时候,能有一个清晰的解决思路。javascript

跨域问题的产生背景

早期为了防止CSRF(跨域请求伪造)的攻击,浏览器引入了同源策略(SOP)来提升安全性。css

CSRF(Cross-site request forgery),跨站请求伪造,也被称为:one click attack/session riding,缩写为:CSRF/XSRF。 —— 浅谈CSRF攻击方式html

而所谓"同源策略",即同域名(domain或ip)、同端口、同协议的才能互相获取资源,而不能访问其余域的资源。在同源策略影响下,一个域名A的网页能够获取域名B下的脚本,css,图片等,可是不能发送Ajax请求,也不能操做Cookie、LocalStorage等数据。同源策略的存在,一方面提升了网站的安全性,但同时在面对先后端分离、模拟测试等场景时,也带来了一些麻烦,从而不得不寻求一些方法来突破限制,获取资源。前端

JS跨域

这里所说的JS跨域,指的是在处理跨域请求的过程当中,技术面会偏浏览器端较多一些,通常是利用浏览器的一些特性进行hack处理,从而避开同源策略的限制。java

JSONP

因为同源策略不会阻止动态脚本的插入到文档中去,因此催生出了一种很经常使用的跨域方式: JSONP(JSON with Padding)。node

原理提及来也很简单:react

假设,咱们源页面是在a.com,想要获取b.com的数据,咱们能够动态插入来源于b.com的脚本:git

script = document.createElement('script');
script.type = 'text/javascript';
script.src = 'http://www.b.com/getdata?callback=demo';复制代码

这里,咱们利用动态脚本的src属性,变相地发送了一个http://www.b.com/getdata?callback=demo的GET请求。这时候,b.com页面接受到这个请求时,若是没有JSONP,会正常返回json的数据结果,像这样:github

{ msg: 'helloworld' }复制代码

而利用JSONP,服务端会接受这个callback参数,而后用这个参数值包装要返回的数据:ajax

demo({msg: 'helloworld'});复制代码

这时候,若是a.com的页面上正好有一个demo的函数:

function demo(data) {
  console.log(data.msg);
}复制代码

当远程数据一返回的时候,随着动态脚本的执行,这个demo函数就会被执行。

到这里,你应该能明白这个技术为何叫JSONP了吧?就是由于使用这种技术服务器会接受回调函数名做为请求参数,并将JSON数据填充进回调函数中去。

不过通常在实际开发的时候,咱们通常会利用jQuery对JSONP的支持,而避免手写不少代码。从1.2版本开始,jQuery中加入了对JSONP的支持,可使用$.getJSON方法来请求跨域数据:

//callback后面的?会由jQuery自动生成方法名
$.getJSON('http://www.b.com/getdata?callback=?', function(data) {
  console.log(data.msg);
});复制代码

还有一种更加经常使用的方法是,利用$.ajax方法,只要指定dataTypejsonp便可:

$.ajax({
  url: 'http://www.b.com/getdata?callback=?', //不指定回调名,可省略callback参数,会由jQuery自动生成
  dataType: 'jsonp',
  jsonpCallback: 'demo', //可省略
  success: function(data) {
    console.log(data.msg);
  }
});复制代码

虽然JSONP在跨域ajax请求方面有很强的能力,可是它也有一些缺陷。首先,它没有关于JSONP调用的错误处理,一旦回调函数调用失败,浏览器会以静默失败的方式处理。其次,它只支持GET请求,这是因为该技术自己的特性所决定的。所以,对于一些须要对安全性有要求的跨域请求,JSONP的使用须要谨慎一点了。

因为JSONP对于老浏览器兼容性方面比较良好,所以,对于那些对IE8如下仍然须要支持的网站来讲,仍然被普遍应用。不过,针对高级浏览器,建议仍是使用接下来会介绍的CORS方法。

document.domain

目前,不少大型网站都会使用多个子域名,而浏览器的同源策略对于它们来讲就有点过于严格了。如,来自www.a.com想要获取document.a.com中的数据。只要基础域名相同,即可以经过修改document.domain为基础域名的方式来进行通讯,可是须要注意的是协议和端口也必须相同。

document.a.com中经过设置

document.domain = 'a.com';复制代码

www.a.com中:

document.domain = 'a.com';
var iframe = document.createElement('iframe');
iframe.src = 'http://document.a.com';
iframe.style.display = 'none';
document.body.appendChild(iframe);

iframe.onload = function() {
  var targetDocument = iframe.contentDocument || iframe.contentWindow.document;
  //能够操做targetDocument
}复制代码

最后,推荐一个使用iframe跨域的库github.com/jpillora/xd…

window.name

window.name这个全局属性主要是用来获取和设置窗口名称的,可是经过结合iframe也能够跨域获取数据。咱们知道,每一个iframe都有包裹它的window对象,而这个window是最外层窗口的子对象。因此window.name属性就能够被共享。

下面这个简单的例子,展现了a.com域名下获取b.com域名下的数据:

var iframe = document.createElement('iframe');
var canGetData = false;

//监听加载事件
iframe.onload = function() {
    if (!canGetData) {
        //修改为同源
        iframe.src = 'http://www.a.com';
        canGetData = true;
    } else {
        var data = iframe.contentWindow.name;
        //获取数据后清除iframe,防止不断刷新
        iframe.contentWindow.document.write('');
        iframe.contentWindow.close();
        document.body.removeChild(iframe);
    }
}

iframe.src = 'http://www.b.com/getdata.html';
iframe.style.display = 'none';
document.body.appendChild(iframe);复制代码

b.com/getdata.html中要存放的数据须要存储在window.name属性中:

<script> var data = {msg: 'hello, world'}; window.name = JSON.stringify(data); //name属性只支持字符串,支持最大2MB的数据 </script>复制代码

还有一种iframe结合location.hash的方式,跟该方法十分相似:是经过检测iframe的src的hash属性来传递数据的。因为该方法相应速度较慢,这里就不作介绍了。

window.name+iframe的方法曾经被做为比JSONP更加安全的替代方案,然而对于托管敏感数据的现代Web应用程序来讲,已经不推荐使用window.name来进行跨域消息传递了,而是推荐使用接下来介绍的postMessage API。

window.postMessage

postMessage是HTML5新增在window对象上的方法,目的是为了解决在父子页面上通讯的问题。该技术有个专有名词:跨文档消息(cross-document messaging)。利用postMessage的特性能够实现较为安全可信的跨域通讯。

postMessage方法接受两个参数:

  1. message: 要传递的对象,只支持字符串信息,所以若是须要发送对象,可使用JSON.stringify和JSON.parse作处理
  2. targetOrigin: 目标域,须要注意的是协议,端口和主机名必须与要发送的消息的窗口一致。若是不想限定域,可使用通配符“*”,可是从安全上考虑,不推荐这样作。

下面介绍一个例子:

首先,先建立一个demo的html文件,咱们这里采用的是iframe的跨域,固然也能够跨窗口。

<p>
  <button id="sendMsg">sendMsg</button>
</p>

<iframe id="receiveMsg" src="http://b.html">
</iframe>复制代码

而后,在sendMsg的按钮上绑定点击事件,触发postMessage方法来发送信息给iframe:

window.onload = function() {
  var receiveMsg = document.getElementById('receiveMsg').contentWindow; //获取在iframe中显示的窗口
  var sendBtn = document.getElementById('sendMsg');

  sendBtn.addEventListener('click', function(e) {
    e.preventDefault();
    receiveMsg.postMessage('Hello world', 'http://b.html');
  });
}复制代码

接着,你须要在iframe的绑定的页面源中监听message事件就能正常获取消息了。其中,MessageEvent对象有三个重要属性:data用于获取数据,source用于获取发送消息的窗口对象,origin用于获取发送消息的源。

window.onload = function() {
  var messageBox = document.getElementById('messageBox');

  window.addEventListener('message', function(e) {
    //do something
    //考虑安全性,须要判断一下信息来源
    if(e.origin !== 'http://xxxx') return;
    messageBox.innerHTML = e.data;
  });
}复制代码

总得来讲,postMessage的使用十分简单,在处理一些和多页面通讯、页面与iframe等消息通讯的跨域问题时,有着很好的适用性。

服务器跨域

在实践过程当中,通常咱们喜欢让服务器来多作一些处理,从而尽量让前端简化。这里将介绍两种经常使用的方法:反向代理和CORS。

反向代理

所谓反向代理服务器,它是代理服务器中的一种。客户端直接发送请求给代理服务器,而后代理服务器会根据客户端的请求,从真实的资源服务器中获取资源返回给客户端。因此反向代理就隐藏了真实的服务器。利用这种特性,咱们能够经过将其余域名的资源映射成本身的域名来规避开跨域问题。

下面我将以node.js所写的服务器来作一个演示:

const http = require('http');

const server = http.createServer((req, res) => {

    const proxy_req = http.request({
        port: 8080,
        host: req.headers['host'],
        method: req.method,
        path: req.url,
        headers: req.headers
    });

    proxy_req.on('response', proxy_res => {
        proxy_res.on('data', data => {
            res.write(data, 'binary');
        });

        proxy_res.on('end', () => {
            res.end();
        });

        res.writeHead(proxy_res.statusCode, proxy_res.headers);
    });

    req.on('end', () => {
        proxy_req.end();
    });

    req.on('data', data => {
        proxy_req.write(data, 'binary');
    });
});

server.listen(80);复制代码

以上代码会将请求80端口的资源映射到8080端口上去。原理就是在监听到客户端请求后,启动一个代理服务器,而后获取代理服务器返回的结果,直接返回给客户端。

若是你使用的是express, 则代码量将更少,也很方便:

const express = require('express');
const request = require('request');
const app = express();

const proxyServer = 'localhost:8080';

app.use('/', (req, res) => {  

  const url = proxyServer + req.url;

  req.pipe(request(url)).pipe(res);

});

app.listen(process.env.PORT || 80);复制代码

利用反向代理,你能够将任何请求委托给另外的服务器,从而避免在浏览器端进行跨域操做。不过你须要注意的是:不要使用bodyParser中间件,由于你须要直接将原始请求经过管道传输到外部服务器。

通常来讲,若是你的生产环境上应用和API在同一台服务器上运行,就没有必要使用跨域了。 而在开发阶段采用这种反向代理,则更加方便咱们前端开发和测试。

在使用反向代理上,你也能够借助node-http-proxy库来减小代码量。

CORS

"跨域资源共享"(Cross-origin resource sharing)是W3C出的一个标准。兼容性方面能够支持IE8+(IE8和IE9须要使用XDomainRequest对象来支持CORS),因此如今CORS也已经成为主流的跨域解决方案。

CORS的核心思想是经过一系列新增的HTTP头信息来实现服务器和客户端之间的通讯。因此,要支持CORS,服务端都须要作好相应的配置,这样,在保证安全性的同时也更方便了前端的开发。

浏览器会将CORS请求分为两类:简单请求和非简单请求:

简单请求

在CORS标准中,会根据是否触发CORS preflight(预请求)来区分简单请求和非简单请求。

简单请求须要知足如下几个条件:

1.请求方法只容许:GET,HEAD,POST

2.对于请求头字段有严格的要求,通常状况下不会超过如下几个字段:

  • Accept
  • Accept-Language
  • Content-Language
  • Content-Type

3.当发起POST请求时,只容许Content-Typeapplication/x-www-form-urlencoded,multipart/form-data,text/plain

对于简单请求来讲,服务器和客户端之间的通讯只是进行简单的交换。如图:

简单请求(来源:MDN)

浏览器发送一个带有Orgin字段的HTTP请求头,用来代表请求来源。服务器的Access-Control-Allow-Origin响应头代表该服务器容许哪些源的访问,一旦不匹配,浏览器就会拒绝资源的访问。大部分状况,你们都喜欢将Access-Control-Allow-Origin设置为*,即任意外域都能访问该资源。可是,仍是推荐作好访问控制,以保证安全性。

非简单请求

对于非简单请求,状况就稍微复杂了点。在正式发送请求数据以前,浏览器会先发送一个带有'OPTIONS'方法的请求来确保该请求对于目标站点来讲是安全的,这个请求也被称为”预请求“(preflight)。

浏览器和服务器之间具体的交互过程如图所示:

非简单请求(来源:MDN)

浏览器会在预检请求中,多发送两个字段Access-Control-Request-MethodAccess-Control-Request-Headers,前者用于告知服务器实际请求所用的方法,后者用于告知服务器实际请求所携带的自定义请求首部字段。而后,服务器将根据请求头的信息来判断是否容许该请求。

针对非简单请求,服务器端能够设置几个相关字段:

  1. Access-Control-Allow-Methods, 用来限制容许的方法名,
  2. Access-Control-Allow-Header,用来限制容许的自定义字段名
  3. Access-Control-Allow-Credentials,用来代表服务器是否容许credentials标志为true的场景。
  4. Access-Control-Max-Age,用来代表预检请求响应的有效时间
  5. Access-Control-Expose-Headers,用来指定服务器端容许的首部字段集合

另外,若是是在具体的实践过程当中,调试OPTIONS请求可使用

curl -X OPTIONS http://xxx.com复制代码

来进行查看相应头信息。也能够经过chrome://net-internals/#events来获取更加详细的网络请求信息。

优化CORS

针对非简单请求来讲,因为每一个请求都会发送预请求,这就致使接口数据的返回会有所延迟,时间被加长。因此,在使用CORS的过程当中,能够采用一些方案来优化请求,将非简单请求转换成简单请求,从而提升请求的速度。

1.请求缓存

能够在服务器端使用Access-Control-Max-Age来缓存预请求的结果。从而提升网站性能。可是须要注意的是,大部分浏览器不会容许缓存‘OPTIONS‘请求太长时间,如:火狐是24小时(86400s),chromium是10分钟(600s)。

2.针对GET请求

对于GET请求,不必使用Content-Type, 尽量地保持GET请求是简单请求。这样就能够减小Header上所携带的字段。从安全性上考虑,全部的API调用应该尽量使用https协议,而这样能够将一些受权认证信息(如token)直接放在url中去,而没必要放在头部。

3.针对POST请求

对于POST请求,咱们能够尽可能使用FormData这种原生的格式:

function sendQuery(url, postData) {
  let formData = new FormData();
  for(var key in postData) {
    formData.append(key, postData.key);
  }

  return fetch(url, {
    body: formData,
    headers: {
      'Accept': '*/*'
    },
    method: 'POST'
  });
}

sendQuery('http://www.xxx.com', {msg: 'hello'}).then(function(response) {
  //do something with response
});复制代码

附带凭证信息的请求

CORS预请求会将用户的身份认证凭据排除在外,包括cookie、http-authentication报头等。若是须要支持用户凭证,须要在XHR的withCredentials属性设置为true,同时Access-control-allow-origin不能设置为*。在服务器端会利用响应报头Access-Control-Allow-Credentials来声明是否支持用户凭证。

同时,利用withCredentials这个属性,也能够检测浏览器是否支持CORS。下面建立一个带有兼容性处理的cors请求:

function createCORSRequest(method, url){
    var xhr = new XMLHttpRequest();
    if ("withCredentials" in xhr){
        xhr.open(method, url, true);
    } else if (typeof XDomainRequest != "undefined"){
        xhr = new XDomainRequest();
        xhr.open(method, url);
    } else {
        xhr = null;
    }
    return xhr;
}

var request = createCORSRequest("POST", "http://www.xxx.com");
if (request){
    request.onload = function(){
        //do something with request.responseText
    };
    request.send();
}复制代码

若是浏览器支持fetch,则使用它作跨域请求更加方便:

fetch('http://www.xxx.com', {
  method: 'POST',
  mode: 'cors',
  credentials: 'include' //接受凭证
}).then(function(response) {
  //do something with response
});复制代码

总结

以上介绍的这些跨域方法,可能有些已经不多使用了,可是这些方法在解决问题的思路上都有着必定的参考意义。因此当面对不可避免的跨域问题的时候,也但愿这篇文章对你能有所帮助。

参考资料

developer.mozilla.org/zh-CN/docs/…

damon.ghost.io/killing-cor…

blog.teamtreehouse.com/cross-domai…

相关文章
相关标签/搜索