前端常见跨域解决方案

Web 开发中,前后端数据交互经常会碰到请求跨域的问题,这是无法避免的。但是跨域也并不是一个很难解决的问题,有很多种方案可以解决。

一、什么是跨域

跨域的诞生是为了避免同源策略,在浏览器最基本的安全问题就是同源策略。那什么是同源策略呢?

1、什么是同源策略?

同源策略 SOPSame origin policy)是一种约定,由 Netscape 公司 1995 年引入浏览器,它是浏览器最核心也最基本的安全功能,如果缺少了同源策略,浏览器很容易受到 XSSCSFR 等攻击。

所谓同源是指 “协议+域名+端口” 三者相同,即便两个不同的域名指向同一个 ip 地址,也非同源。

同源策略会限制以下几种行为:

  • CookieLocalStorageIndexDB 无法获取;

  • DOMJS 对象无法获取;

  • AJAX 请求无法发送,被浏览器拦截。

但是有三个标签是允许跨域加载资源:

  • <img src=XXX>
  • <link href=XXX>
  • <script src=XXX>

常见的跨域类型包括以下一些:

URL说明是否允许通信
http://www.example.com/a.js
https://www.example.com/b.js
不同协议不允许
http://www.example1.com/a.js
http://www.example2.com/b.js
不同域名不允许
http://www.example.com/a.js
http://192.168.1.1/b.js
域名与域名所对应的IP不允许
http://www.example.com/a.js
http://x.example.com/b.js
http://example.com/c.js
主域相同,子域不同不允许
http://www.example.com:8080/a.js
http://www.example.com/b.js
同个域名,不同端口不允许
http://www.example.com/a.js
http://www.example.com/b.js
http://www.example.com/lab/c.js
同域名同端口,不同文件目录允许

2、没有同源策略的危险场景

  • 没有同源策略限制的接口请求

cookie 是一个可以验证身份的东西,目的是让服务器知道是谁发出的这次请求。如果请求了接口进行登录,服务端验证通过后会在响应头加入 set-cookie 字段,下次再发出请求时,浏览器会自动将 cookie 附加在请求中,服务端就能知道请求者的身份。

下面有这个场景:

你登录了一个银行网站并登录,并获取到了服务端返回的 cookie,这个时候,你打开了一个钓鱼网站,由于没有同源策略的限制,它在后台向服务端发送了请求,并且在请求中带着你的登陆信息,这样一来,这个不法网站就相当于登录了你的账号,可以为所欲为了,造成财产损失。这就是我们说的的 CSRF 攻击方式。

  • 没有同源策略限制的 DOM 查询

某一天你收到了一条短信,提示你你的银行账户有风险,你点击附带在短信里的链接「www.togobolg.cn 」进入了银行界面。由于是熟悉的银行界面,你输入了账户密码,去查看钱有没有少。大意的你没有看清网站的地址不是正确的地址 「www.togoblog.cn」,而是错误的地址「www.togobolg.cn」,你的账号密码就被盗取了。这种攻击的原理就是攻击者在一个假的网站内通过 iframe 内嵌了一个真的网站,因为没有同源策略限制,就可以通过获取 DOM 来获得你的输入。

二、跨域问题解决方案

1、jsonp

原理:通常为了减轻 web 服务器的负载,我们把 jscssimg 等静态资源分离到另一台独立域名的服务器上,在 html 页面中再通过相应的标签从不同域名下加载静态资源,这是被浏览器允许。基于此原理,我们可以通过动态创建 script,再请求一个带参网址实现跨域通信

jsonp 缺点:只能实现 get 一种请求。

原生实现:

<script>
  var script = document.createElement('script');
  script.type = 'text/javascript';

  // 传参一个回调函数名给后端,方便后端返回时执行这个在前端定义的回调函数
  script.src = 'http://www.domain2.com:8080/login?user=admin&callback=handleCallback';
  document.head.appendChild(script);

  // 回调执行函数
  function handleCallback(res) {
    console.log(JSON.stringify(res));
  }
</script>

服务端返回如下(返回时即执行全局函数):

handleCallback({"status": true, "user": "admin"})

jquery ajax 实现:

$.ajax({
  url: 'http://www.domain2.com:8080/login',
  type: 'get',
  dataType: 'jsonp',  // 请求方式为jsonp
  jsonpCallback: 'handleCallback',    // 自定义回调函数名
  data: {}
});

vue.js 实现:

this.$http.jsonp('http://www.domain2.com:8080/login', {
  params: {},
  jsonp: 'handleCallback'
}).then((res) => {
  console.log(res);
});

2、document.domain + iframe 跨域

此方案仅限主域相同,子域不同的跨域应用场景。

实现原理:两个页面都通过 js 强制设置 document.domain 为基础主域,就实现了同域。

1)父窗口:(http://www.domain.com/a.html)

<iframe id="iframe" src="http://child.domain.com/b.html"></iframe>
<script>
  document.domain = 'domain.com';
  var user = 'admin';
</script>

2)子窗口:(http://child.domain.com/b.html)

<script>
  document.domain = 'domain.com';
  // 获取父窗口中变量
  alert('get js data from parent ---> ' + window.parent.user);
</script>

3、location.hash + iframe 跨域

实现原理: a 欲与 b 跨域相互通信,通过中间页 c 来实现。 三个页面,不同域之间利用 iframelocation.hash 传值,相同域之间直接 js 访问来通信。

具体实现步骤:一开始 a.htmlc.html 传一个 hash 值,然后 c.html 收到 hash 值后,再把 hash 值传递给 b.html,最后 b.html 将结果放到 a.htmlhash 值中。 同样的,a.htmlb.html 是同域的,都是 http://localhost:3000;而 c.htmlhttp://localhost:4000

// a.html
<iframe src="http://localhost:4000/c.html#iloveyou"></iframe>
<script>
window.onhashchange = function () { //检测hash的变化
console.log(location.hash);
};
</script>
// b.html
<script>
  window.parent.parent.location.hash = location.hash;
  //b.html将结果放到a.html的hash值中,b.html可通过parent.parent访问a.html页面
</script>
// c.html
console.log(location.hash);
const iframe = document.createElement('iframe');
iframe.src = 'http://localhost:3000/b.html#idontloveyou';
document.body.appendChild(iframe);

4、window.name + iframe 跨域

window.name 属性的独特之处:name 值在不同的页面(甚至不同域名)加载后依旧存在,并且可以支持非常长的 name 值(2MB)。

其中 a.htmlb.html 是同域的,都是 http://localhost:3000;而 c.htmlhttp://localhost:4000

// a.html(http://localhost:3000/b.html)
<iframe src="http://localhost:4000/c.html" frameborder="0" onload="load()" id="iframe"></iframe>
<script>
  let first = true
  // onload事件会触发2次,第1次加载跨域页,并留存数据于window.name
  function load() {
    if(first){
      // 第1次onload(跨域页)成功后,切换到同域代理页面
      let iframe = document.getElementById('iframe');
      iframe.src = 'http://localhost:3000/b.html';
      first = false;
    }else{
      // 第2次onload(同域b.html页)成功后,读取同域window.name中数据
      console.log(iframe.contentWindow.name);
    }
  }
</script>

b.html为中间代理页,与a.html同域,内容为空。

// c.html(http://localhost:4000/c.html)
<script>
    window.name = '我不爱你'  
</script>

总结:通过iframe的src属性由外域转向本地域,跨域数据即由iframe的window.name从外域传递到本地域。这个就巧妙地绕过了浏览器的跨域访问限制,但同时它又是安全操作。

5、postMessage 跨域

postMessageHTML5 XMLHttpRequest Level 2 中的 API,且是为数不多可以跨域操作的 window 属性之一,它可用于解决以下方面的问题:

  • 页面和其打开的新窗口的数据传递
  • 多窗口之间消息传递
  • 页面与嵌套的iframe消息传递
  • 上面三个场景的跨域数据传递

用法:postMessage(data,origin) 方法接受两个参数

  • datahtml5 规范支持任意基本类型或可复制的对象,但部分浏览器只支持字符串,所以传参时最好用 JSON.stringify() 序列化;
  • origin: 协议+主机+端口号,也可以设置为 "*",表示可以传递给任意窗口,如果要指定和当前窗口同源的话设置为 "/"

a.html:(http://www.domain1.com/a.html)

<iframe id="iframe" src="http://www.domain2.com/b.html" style="display:none;"></iframe>
<script>
  var iframe = document.getElementById('iframe');
  iframe.onload = function () {
    var data = {
      name: 'aym'
    };
    // 向domain2传送跨域数据
    iframe.contentWindow.postMessage(JSON.stringify(data), 'http://www.domain2.com');
  };

  // 接受domain2返回数据
  window.addEventListener('message', function (e) {
    alert('data from domain2 ---> ' + e.data);
  }, false);
</script>

b.html:(http://www.domain2.com/b.html)

<script>
  // 接收domain1的数据
  window.addEventListener('message', function (e) {
    alert('data from domain1 ---> ' + e.data);

    var data = JSON.parse(e.data);
    if (data) {
      data.number = 16;

      // 处理后再发回domain1
      window.parent.postMessage(JSON.stringify(data), 'http://www.domain1.com');
    }
  }, false);
</script>

6、跨域资源共享 CORS

CORS 的全称是 Cross-Origin Resource SharingCORS 是一个新的 W3C 标准,它新增的一组 HTTP 首部字段允许服务器声明哪些来源请求有权限访问哪些资源,换言之它允许浏览器向声明了 CORS 的站进行跨域请求。

所以,只需要服务端设置 Access-Control-Allow-Origin 即可,前端无须设置。但是如果要带 cookie 请求,前后端都需要设置。

后端设置:

var http = require('http');
var server = http.createServer();
var qs = require('querystring');

server.on('request', function (req, res) {
  var postData = '';

  // 数据块接收中
  req.addListener('data', function (chunk) {
    postData += chunk;
  });

  // 数据接收完毕
  req.addListener('end', function () {
    postData = qs.parse(postData);

    // 跨域后台设置
    res.writeHead(200, {
      'Access-Control-Allow-Credentials': 'true',     // 后端允许发送Cookie
      'Access-Control-Allow-Origin': 'http://www.domain1.com',    // 允许访问的域(协议+域名+端口)
      /* 
       * 此处设置的cookie还是domain2的而非domain1,因为后端也不能跨域写cookie(nginx反向代理可以实现),
       * 但只要domain2中写入一次cookie认证,后面的跨域接口都能从domain2中获取cookie,从而实现所有的接口都能跨域访问
       */
      'Set-Cookie': 'l=a123456;Path=/;Domain=www.domain2.com;HttpOnly'  // HttpOnly的作用是让js无法读取cookie
    });

    res.write(JSON.stringify(postData));
    res.end();
  });
});

server.listen('8080');
console.log('Server is running at port 8080...');

前端设置

但是如果要带 cookie 请求,前后端都需要设置。前端设置如下:

原生实现:

var xhr = new XMLHttpRequest(); // IE8/9需用window.XDomainRequest兼容

// 前端设置是否带cookie
xhr.withCredentials = true;

xhr.open('post', 'http://www.domain2.com:8080/login', true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.send('user=admin');

xhr.onreadystatechange = function () {
  if (xhr.readyState == 4 && xhr.status == 200) {
    alert(xhr.responseText);
  }
};

如果使用使用 axios,设置如下:

axios.defaults.withCredentials = true

7、nginx 代理跨域

所谓代理,就是在我们和真实的服务器之间有一台代理服务器,我们所有的请求都是通过它来进行转接的。

7.1、正向代理

正向代理即通常所说的代理。举个例子:我们访问不了 Google,但是我在国外有一台 vps,它可以访问 Google,我访问它,让它访问 Google 后,把数据传给我。

正向代理的过程,隐藏了真实的请求客户端,服务端不知道真实的客户端是谁,客户端请求的服务都被代理服务器代替来请求。

7.2、反向代理

反向代理的方向和正向代理相反。

我们都有过这样的经历,拨打 10086 客服电话,可能一个地区的 10086 客服有几个或者几十个,我们永远都不需要关心在电话那头的是哪一个,叫什么名字,是男生还是女生。我们关心的是我们的问题能不能得到专业的解答,我们只需要拨通了 10086 的总机号码,电话那头总会有人会回答的,只是有时慢有时快而已。那么这里的10086 总机号码就是我们说的反向代理。客户不知道真正提供服务人的是谁。

反向代理隐藏了真实的服务端,当我们请求 www.baidu.com 的时候,就像拨打 10086 一样,背后可能有成千上万台服务器为我们服务,但具体是哪一台,我们不知道,也不需要知道,我们只需要知道反向代理服务器是谁就好了,www.baidu.com 就是我们的反向代理服务器,反向代理服务器会帮我们把请求转发到真实的服务器那里去。Nginx 就是性能非常好的反向代理服务器。

7.3、配置 nginx

如果我们请求的时候还是用前端的域名,然后有个东西帮我们把这个请求转发到真正的后端域名上,不就避免跨域了吗?这时候,nginx 就派上用场了。

nginx 配置:

server{
    # 监听9099端口
    listen 9099;
    # 域名是localhost
    server_name localhost;
    #凡是http://localhost:9099/api这个样子的,都转发到真正的服务端地址http://localhost:9871 
    location ^~ /api {
        proxy_pass http://localhost:9871;
    }    
}

这样一来,前端什么都不需要做了,调用 api 的时候调用前端的代理接口 9099 就可以了,类似于 http://localhost:9099/api 的调用,都会被转发到真正的服务端地址:http://localhost:9871 ,很方便。

8、nodejs 中间件代理跨域

要明白 Node 层为什么能实现跨域,首先要明白一个原理:跨域问题是浏览器的同源策略的安全机制引起的,服务器之间是不存在跨域问题的,这也不是说服务器之间没有安全机制,只是服务器之间的调用无论是通过 http 访问还是通过 rpc 调用都是协议层面的机制,并没有限制必须同源

这也就是 Node 层跨域的实质,我们把静态文件和 Node 中间层放在同一个域下,这样前端资源和 Node 层的通信不会受同源策略影响,然后我们通过 Node 中间层把前端的资源请求转发到真实的请求地址,再通过中间层把请求返回的数据传给前端,这样就实现了跨域。

下面看个简单的 demo

假设,前端有这样的一个请求:

this.axios.get('/api/index').then(res=>{...})

Node 中间件服务:

app.get('/api/index', (req, res) => {
  //请求远程地址的API
  getHttp().then(data => {
    console.log(data);
    res.send(data);
  }).catch(err => {
    res.send(err);
  });
  //返回数据给前端
});

其中 getHttp() 是封装的一个 promise,这个 promise 的作用就是转发请求并在成功后将数据在传递给前端。

const getHttp = () => {
  return new Promise((resolve, reject) => {
    http.get('http://localhost:8080/New/Index', (res) => {
      const { statusCode } = res;
      const contentType = res.headers['content-type'];

      let error;
      if (statusCode !== 200) {
        error = new Error('请求失败\n' +
          `状态码: ${statusCode}`);
      }
      if (error) {
        console.error(error.message);
        // 消费响应数据来释放内存。
        res.resume();
        return;
      }

      res.setEncoding('utf8');
      let rawData = '';
      res.on('data', (chunk) => { rawData += chunk; });
      res.on('end', () => {
        try {
          const parsedData = JSON.parse(rawData);
          resolve(parsedData);
          console.log(parsedData);
        } catch (e) {
          console.error(e.message);
        }
      });
    }).on('error', (e) => {
      console.error(`出现错误: ${e.message}`);
    });

  });
};

三、总结

基本上,在开发中,最后两种方式 —— nginx 代理跨域和 node 中间层代理跨域比较常见。


相关文章 
1、使用 CircleCI + Github 搭建持续集成环境
2、Web 前端工程化(二) —— CI/CD
3、Web 前端工程化(一) —— 什么是前后端分离?
4、谈谈前端开发中的 MVC、MVP、MVVM 模式
5、浏览器的重排和重绘
目录