一文 | 跨域及其解决方案-LMLPHP

为什么有跨域这个问题

简单的说,是因为浏览器的同源策略

同源策略限制了从同一个源加载的文档或脚本如何与来自另一个源的资源进行交互。这是一个用于隔离潜在恶意文件的重要安全机制。(引用于MDN定义

如果两个链接的协议、域名、端口都一致,那么这两个URL同源,否则不同源。

假设A站点的链接为https://news.a.com/www/index.html,B站点为下列链接时其同源检测如下表

同源策略分为以下两种:

  • DOM同源策略,禁止对不同源的DOM元素进行操作。
  • XHR同源策略,禁止使用XHR对象向不同源的服务器发起请求。

举个栗子,Jarry登陆了A站点准备购物,与此同时Jarry正在B站点网上冲浪。如果没有同源策略,那么B站点的脚本可以轻轻松松的修改A站点的DOM结构或者向A站点的服务器发起不恰当的请求,导致存在安全隐患。

IE浏览器有两个意外

  • 授信范围:两个相互之间高度互信的域名,不受同源策略的限制。
  • 端口:IE没有将端口号加入到同源策略的组成部分之中。

什么是跨域

当A站点与B站点不同源(只要协议、域名、端口三者其一不一致)时,A站点无法获取到B站点的服务或者数据,此时就产生了跨域

一文 | 跨域及其解决方案-LMLPHP
上图中,站点https://www.jarrychung.com企图向不同源站点https://www.baidu.com发起GET请求,导致报错。

如何解决跨域

1 JSONP

需要服务端支持。

JSON是一种常用的数据交换格式,而JSONP是JSON的一种使用模式,可以通过这种模式来进行跨域获取数据。重要的是,JSONP使用简便,没有兼容性问题。

同源策略下,不同源的站点无法相互获取到数据,但img/iframe/script标签是个例外,这些标签可以通过src属性获取到不同源的服务器。当正常的请求一个JSON数据时,服务端会返回JSON格式的数据。当使用JSONP模式发送请求时,服务端返回的是一段可执行的JavaScript代码。

// 举个例子
// 正常请求服务器(https://news.a.com/news?id=666)时,数据如下:
{"id": 666,"text":"Jarry Chung"}
// JSONP模式请求(https://news.a.com/news?id=666?callback=fn)时,数据如下:
fn({"id": 666,"text":"Jarry Chung"})
// 然后使用回调函数便可以处理获得的数据

注意:JSONP只支持GET请求,服务端可能在JSONP响应中夹带恶意代码,判断是否请求成功是困难的。

2 跨域资源共享(CORS)

需要服务端支持。

CORS经常被称为现代化版本的JSONP,能够发起所有种类的HTTP请求,以及拥有良好的错误处理。

跨源资源共享标准的工作原理是添加自定义的HTTP头部,允许服务器描述允许使用Web浏览器读取该信息的起源集。此外,对于可能对服务器数据产生副作用的HTTP请求方法,规范要求浏览器“预检”请求,从而请求支持的方法。服务器使用HTTP OPTIONS请求方法,然后,在服务器“批准”后,使用实际的HTTP请求方法发送实际请求。服务器还可以通知客户端是否让“凭据”(包括Cookie和HTTP认证数据)与请求一起发送(翻译自MDN)。

CORS的基本思想是使用自定义的HTTP头部允许服务端和浏览器互相认识,从而让服务端决定是否允许请求以及响应。

  • Access-Control-Allow-Origin:指定授权访问的域
  • Access-Control-Allow-Methods:授权请求的方法(GET, POST, PUT, DELETE,OPTIONS等)
// 举个例子
var exp = require('express');
var app = exp();
app.all('*', function(req, res, next) {
    // 设置允许的源
    res.header("Access-Control-Allow-Origin", "*");
    // 设置允许的HTTP请求方式
    res.header("Access-Control-Allow-Methods","PUT,POST,GET,DELETE,OPTIONS");
    res.header("Access-Control-Allow-Headers", "X-Requested-With");
    res.header("Content-Type", "application/json;charset=utf-8");
    next();
});
app.get('/user/:id/:pw', function(req, res) {
    res.send({id:req.params.id, password: req.params.pw});
});
app.listen(8000);

注意:是最为推荐的方案,但古董级浏览器不支持CORS,如IE8以下的浏览器。

3 Nginx反向代理

主要在服务端上实现。

浏览器有同源策略,但是服务端没有这个限制,因此可以将请求发送给反向代理服务器,由服务器去请求数据,然后再将数据返回给前端。而前端几乎不需要做任何处理。
一文 | 跨域及其解决方案-LMLPHP

server {
    # 监听80端口,可以改成其他端口
    listen       80;
    # 当前服务的域名
    server_name  www.a.com;

    location / {
        proxy_pass http://www.a.com:81;
        proxy_redirect default;
    }
    # 添加访问目录为/api的代理配置
    # 目录为/api开头的请求将被转发到82端口
    # 还记得吗,端口不同也是不同源
    location /apis {
        rewrite  ^/apis/(.*)$ /$1 break;
        proxy_pass   http://www.a.com:82;
    }

4 window.name + iframe

在浏览器实现。

window.name的值是当前窗口的名字,要注意的是每个iframe都有包裹它的window,而这个window是top window的子窗口,因此它自然也有window.name的属性。window.name属性,如果没有被修改,那么其值在不同的页面(甚至不同域名)加载后依旧存在。另外,其值大小通常可达到2MB

其思想为:在一个页面中内嵌一个iframe标签,由这个iframe进行获取数据,将获取到的数据赋值给window.name属性,然后由页面获取该属性的值。既巧妙的绕过了同源策略,同时该操作也是安全的。

但这里有一个问题,即页面和该页面下的iframe src不同源的话,这个页面是无法操作iframe的,因此导致取不到name值。

name属性的特性在这时候就很好用了,当前页设置的值, 在页面重新加载(非同域也可以)后, 只要没有被修改,值依然不变。可以让iframe的location指向为与页面相同的域,等iframe加载完后页面就可以取到name值了。

<body>
  <script type="text/javascript">
    // 代码参考自:https://www.cnblogs.com/zichi/p/4620656.html
    function crossDomain(url, fn) {
      iframe = document.createElement('iframe');
      iframe.style.display = 'none';
      var state = 0;

      iframe.onload = function() {
        if(state === 1) {
          // 处理数据
          fn(iframe.contentWindow.name);
          // 清楚痕迹
          iframe.contentWindow.document.write('');
          iframe.contentWindow.close();
          document.body.removeChild(iframe);
        } else if(state === 0) {
          state = 1;
          // proxy.html为与页面同级的空白页面
          iframe.contentWindow.location = 'http://localhost:81/proxy.html';
        }
      };

      iframe.src = url;
      document.body.appendChild(iframe);
    }

    // 调用
    // 服务器地址
    var url = 'http://localhost:82/data.php';
    // 处理数据 data就是window.name的值(string)
    crossDomain(url, function(data) {
      var data = JSON.parse(iframe.contentWindow.name);
      console.log(data);
    });
  </script>
</body>

5 location.hash + iframe

主要在浏览器实现,需要服务端支持。

https://www.a.com/news#JarryChungIsSoCool这个URL中,location.hash的值为JarryChungIsSoCool。因为改变hash值不会导致刷新页面,因此可以利用location.hash属性来传递数据。缺点是数据容量以及类型受到限制、数据内容直接暴露出来。

其思想为:若index页面要获取不同源服务器的数据,那么动态插入一个iframe,将iframe的src属性指向该服务器地址。由于同源策略,此时top window和包裹这个iframe的子窗口仍是无法通信的,因此改变子窗口的路径,将数据当作hash值添加到改变后的路径,然后就能够进行通信(这一点与利用window.name跨域的原理几乎一致),能够通信后可以在iframe中将页面的地址改变,将数据加在index页面地址的hash值上。index页面监听地址的hash值变化便能够取得数据。

6 document.domain + iframe

在浏览器实现。

该方案适用于主域名一致,子域名不一致的情况。两个页面使用JavaScript将document.domain设置为相同主域名,从而实现跨域。

<!-- 主页面 a.html -->
<iframe src="http://child.domain.com/b.html"></iframe>
<script>
    document.domain = 'domain.com';
    var user = 'Jarry Chung';
</script>

<!-- 子页面 b.html -->
<script>
    document.domain = 'domain.com';
    // 获取父窗口中 user 变量
    alert(window.parent.user); // 'Jarry Chung'
</script>

7 postMessage()

在浏览器实现。

postMessage()是HTML5新增的方法,可以实现跨文本档通信、多窗口通信、跨域通信。示意图如下:
一文 | 跨域及其解决方案-LMLPHP
index.html将需要的数据请求发送给iframe或者另一个页面,iframe或另一个页面监听到message后响应,取得数据后利用postMessage()接口将数据返回给index.html

postMessage()有两个参数:

  • data:需要传递的数据,HTML5规范中该参数的类型可以是JS的任意基本类型或者可复制的对象,但有部分浏览器只支持传递字符串,因此可能需要将该值处理成字符串后再传递。
  • origin:字符串参数,指明目标窗口的源,协议+主机+端口号[+URL],URL会被忽略,可以不写。postMessage()方法只会将message传递给指定窗口,当然如果愿意也可以把参数设置为*,这样可以传递给任意窗口。

8 WebSocket协议

需要服务端支持。

WebSocket协议是HTML5一种新的协议,实现了浏览器与服务器全双工通信,同时允许跨域通讯。用法如下:

var ws = new WebSocket('wss://echo.websocket.org');
// 连接打开后发送消息
ws.onopen = function (evt) {
    console.log('Connection open ...');
    ws.send('Hello WebSockets!');
};
// 接受消息后关闭连接
ws.onmessage = function (evt) {
    console.log('Received Message: ', evt.data);
    ws.close();
};
// 监听关闭连接
ws.onclose = function (evt) {
    console.log('Connection closed.');
};

写在最后

个人经验表示在实战中碰见跨域的情况并不多,但是如果碰见了往往都会掉坑里面。

作为一线开发者,做好知识储备是在需要的时候迅速解决问题的必要条件。

01-21 22:29