跨域的正确打开方式

3,201 阅读15分钟

跨域是前端开发日常工作中经常会面对的一个问题。日常工作中我们都会使用像webpack-dev-server构建我们的开发环境的接口代理、亦或是使用Charles等接口代理工具。上线后可以通过运维同学配合nginx或是cors等方案来解决。

什么是跨域?

在JavaScript中,有一个很重要的安全性限制,被称为“Same-Origin Policy”(同源策略)。这一策略对于JavaScript代码能够访问的页面内容做了很重要的限制,即JavaScript只能访问与包含它的文档在同一域下的内容。跨域是指浏览器不能执行其他网站的脚本。MDN上的解释(浏览器的同源策略限制了从同一个源加载的文档或脚本如何与另一个源的资料进行交互,这是一个用于隔离潜在恶意文件的重要机制)。简而言之就是浏览器对脚本实施的安全机制。

同源的定义

有两个页面的协议、端口(如果有指定)和主机都相同,则两个页面具有相同的源,即为同源。若协议/端口/主机 有一项不同,则为说明两者非同源。

Url 调用 Url 结果
www.peanutyu.site/home www.peanutyu.site/api/* 调用成功,非跨域
www.peanutyu.site/home www.peanut.site/api/* 调用失败,主域名不同
www.peanutyu.site/home www.peanutyu.site/api/* 调用失败,协议不同
www.peanutyu.site/home blog.peanutyu.site/api/* 调用失败,子域名不同
www.peanutyu.site/home www.peanutyu.site:8080/api/* 调用失败,端口号不同

JSONP跨域(JSON with padding)

在HTML标签里,一些标签比如script、img、iframe这些获取资源的标签是没有跨域限制的,JSONP就是我们去动态的创建一个Script标签再去请求一个带参网址来实现跨域通信。由于script标签加载资源的方式是GET请求,所以JSONP只能发送GET请求。

后台接口设计

const xxService = require('../../service/xxService');
exports = module.exports = new class {
  constructor() {}
  
  jsonp () {
    let [cb, username ] = [];
    if (ctx.query) {
      ({ cb, username } = ctx.query);
    }
    const data = await xxService.xxMethods(username);
    // cb参数是前后端约定的方法名字,后端返回一个直接执行的方法给前端,前端获取这个方法后立马执行,并且把返回的数据放在方法的参数里。
    ctx.body = `${cb}(${JSON.stringify(data)})`;
  }
}

前端方法实现

原生的实现方式

  const script = document.createElement('script');
  const body = document.body;
  script.src = 'http://127.0.0.1:3000/api/jsonp?cb=callbackJsonp&username=peanut';
  body.appendChild(script);

  function callbackJsonp(res) {
    const div = document.createElement('div');
    div.innerText = JSON.stringify(res);
    body.appendChild(div);
    body.removeChild(script);
  }

Jquery的实现方式

  $.ajax({
    url: 'http://blog.peanutyu.site/api/*',
    type: 'GET',
    dateType: 'jsonp', // 设置请求方式为jsonp
    jsonpCallback: 'callbackJsonp',
    data: {
      'username': 'peanut',
    },
  });

  function callbackJsonp(res) {
    console.log(res);
  }

iframe跨域

document.domain + iframe 跨域

这种跨域方式要求主域名相同。比如www.peanut.site、blog.peanut.site、 a.peanutyu.site这三者主域名都是peanutyu.site。主域名不同就不能使用这种跨域方式。

浏览器不同域的页面之间是不可以通过JS来进行交互操作的。但是不同的页面,是能够获取到彼此的window对象的。但是,我们只能获取到一个几乎无用的window对象。比如一个页面它的地址为http://www.peanutyu.site/a.html,在这一个页面里有一个iframe,它的src为http://peanutyu.site/b.html,这个页面和它内部的iframe是不同域的,所以我们是无法通过在页面中书写js代码来获取iframe中的东西的。我们只需要把http://www.peanutyu.site/a.html和http://peanutyu.site/b.html都设置成相同的域名即可。

但需要注意的是document.domain的设置是有限制的,我们只能把document.domain设置成自身或更高一层的父域,并且主域必须相同。blog.peanutyu.site中某个文档可以设置document.domain为blog.peanutyu.site或者peanutyu.site中的任何一个,但是不能设置为a.blog.peanutyu.site。因为这是当前域的子域,也不可设置为baidu.com,因为主域不同。

假设我们要在http://www.peanutyu.site/a.html的页面里访问http://peanutyu.site里面的数据

在http://www.peanutyu.site/a.html设置document.domain

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>A页面</title>
</head>
<body>
  <iframe id="iframe" src="http://peanutyu.site/b.html" style="display:none;"></iframe>
  <script>
    $(function () {
      try {
        document.domain = "peanutyu.site"; //这里将document.domain设置成一样
      } catch (e) { }
      $("#iframe").load(function () {
        var iframe = $("#iframe").contentDocument.$;
        iframe.get("http://peanutyu.site/api", function (data) {
          console.log(data);
        });
      });
    });
  </script>
</body>
</html>

在http://peanutyu.site/b.html也需要设置document.domain。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>B页面</title>
</head>
<body>
  <script>
    $(function () {
      try {
        document.domain = "peanutyu.site"; //这里将document.domain设置成一样
      } catch (e) { }
    });
  </script>
</body>
</html>

这里需要注意,在A页面内需要等待加载完B页面之后才可以获取到B页面中的对象。获取到对象我们便可以直接发送ajax请求,不过这种跨域方式只可以在主域相同的时候使用。

window.name + iframe 跨域

当iframe页面跳转到其他地址时,其window.name值保持不变并且可以支持存储非常长的name(2MB)。但是浏览器规定浏览器跨域iframe禁止互相调用或者传递值。但是调用iframe时window.name却不变,我们正好可以使用这个特性来互相传值,当然跨域下是不容许读取iframe的window.name的值。

因为规定如果index.html页面和该页面里的iframe的src如果不同源,就无法操作iframe内部的任何内容,所以也获取不到iframe的window.name属性了。不过既然要同源,我们可以准备一个和主页面http://www.peanut.com/a.html相同域下的代理空页面http://www.peanut.com/proxy.html来指定src。

假设我们有一个页面http://peanutyu.site/a.html需要从http://peanut.site/data.html内获取到数据

data页面代码

window.name = '我是data页面的数据';

a页面代码

const iframe = document.createElement('iframe');
iframe.style.display = 'none';
let state = 0;

iframe.onload = function() {
  if (state === 1) {
    const data = iframe.contentWindow.name;
    iframe.contentWindow.document.write('');
    iframe.contentWindow.close();
    document.body.removeChild(iframe);
  } else {
    state = 1;
    iframe.contentWindow.location = 'http://peanutyu.site/proxy.html';
  }
}
iframe.src = 'http://peanut.site/data.html';
document.body.appendChild(iframe);

在iframe载入的过程中,迅速重置iframe的location等同于重新载入页面,便会重新调用iframe的onload方法这时我们的会走到条件为state === 1的内部,获取iframe的window.name的值,由于调用iframe时window.name不变,所以我们便取到了不同域内window.name的值。

跨域资源共享CORS

简介

CORS需要浏览器和服务器同时支持。目前,所有浏览器都支持该功能,IE浏览器不能低于IE10。 整个CORS通信过程,都是浏览器自动完成,不需要用户参与。对于开发者来说,CORS通信与同源的AJAX通信没有差别,代码完全一样。浏览器一旦发现AJAX请求跨源,就会自动添加一些附加的头信息,有时还会多出一次附加的请求,但用户不会有感觉。 因此,实现CORS通信的关键是服务器。只要服务器实现了CORS接口,就可以跨源通信。

两种请求

浏览器将CORS请求分成两类:简单请求(simple request)和非简单请求(not-so-simple request)。只要同时满足以下两大条件,就属于简单请求。 (1) 请求方法是下面三种方法之一:

  • HEAD
  • GET
  • POST

(2) HTTP的头信息不超出以下几种字段:

  • Accept
  • Accept-Language
  • Content-Language
  • Last-Event-ID
  • Content-Type:只限于三个值application/x-www-form-urlencoded、multipart/form-data、text/plain

凡是不同时满足上面两个条件,就属于非简单请求。浏览器对这两种请求的处理,是不一样的。

简单请求

基本流程

对于简单请求,浏览器直接发出CORS请求。具体来说,就是在头信息之中,增加一个Origin字段。下面是一个例子,浏览器发现这次跨源AJAX请求是简单请求,就自动在头信息之中,添加一个Origin字段。

GET /cors HTTP/1.1
Origin: http://api.bob.com
Host: api.alice.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...

上面的头信息中,Origin字段用来说明,本次请求来自哪个源(协议 + 域名 + 端口)。服务器根据这个值,决定是否同意这次请求。

如果Origin指定的源,不在许可范围内,服务器会返回一个正常的HTTP回应。浏览器发现,这个回应的头信息没有包含Access-Control-Allow-Origin字段(详见下文),就知道出错了,从而抛出一个错误,被XMLHttpRequest的onerror回调函数捕获。注意,这种错误无法通过状态码识别,因为HTTP回应的状态码有可能是200。

如果Origin指定的域名在许可范围内,服务器返回的响应,会多出几个头信息字段。

Access-Control-Allow-Origin: http://api.bob.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: FooBar
Content-Type: text/html; charset=utf-8

上面的头信息之中,有三个与CORS请求相关的字段,都以Access-Control-开头。

Access-Control-Allow-Origin

该字段是必须的。它的值要么是请求时Origin字段的值,要么是一个*,表示接受任意域名的请求。

Access-Control-Allow-Credentials

该字段可选。它的值是一个布尔值,表示是否允许发送Cookie。默认情况下,Cookie不包括在CORS请求之中。设为true,即表示服务器明确许可,Cookie可以包含在请求中,一起发给服务器。这个值也只能设为true,如果服务器不要浏览器发送Cookie,删除该字段即可。

Access-Control-Expose-Headers

该字段可选。CORS请求时,XMLHttpRequest对象的getResponseHeader()方法只能拿到6个基本字段:Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma。如果想拿到其他字段,就必须在Access-Control-Expose-Headers里面指定。上面的例子指定,getResponseHeader('FooBar')可以返回FooBar字段的值。

withCredentials 属性

上面说到,CORS请求默认不发送Cookie和HTTP认证信息。如果要把Cookie发到服务器,一方面要服务器同意,指定Access-Control-Allow-Credentials字段。

Access-Control-Allow-Credentials: true

另一方面,开发者必须在AJAX请求中打开withCredentials属性。

var xhr = new XMLHttpRequest();
xhr.withCredentials = true;

否则,即使服务器同意发送Cookie,浏览器也不会发送。或者,服务器要求设置Cookie,浏览器也不会处理。但是,如果省略withCredentials设置,有的浏览器还是会一起发送Cookie。这时,可以显式关闭withCredentials。

xhr.withCredentials = false;

需要注意的是,如果要发送Cookie,Access-Control-Allow-Origin就不能设为星号,必须指定明确的、与请求网页一致的域名。同时,Cookie依然遵循同源政策,只有用服务器域名设置的Cookie才会上传,其他域名的Cookie并不会上传,且(跨源)原网页代码中的document.cookie也无法读取服务器域名下的Cookie。

非简单请求

预检请求

非简单请求是那种对服务器有特殊要求的请求,比如请求方法是PUT或DELETE,或者Content-Type字段的类型是application/json。 非简单请求的CORS请求,会在正式通信之前,增加一次HTTP查询请求,称为"预检"请求(preflight)。 浏览器先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些HTTP动词和头信息字段。只有得到肯定答复,浏览器才会发出正式的XMLHttpRequest请求,否则就报错。 下面是一段浏览器的JavaScript脚本。

var url = 'http://api.alice.com/cors';
var xhr = new XMLHttpRequest();
xhr.open('PUT', url, true);
xhr.setRequestHeader('X-Custom-Header', 'value');
xhr.send();

上面代码中,HTTP请求的方法是PUT,并且发送一个自定义头信息X-Custom-Header。 浏览器发现,这是一个非简单请求,就自动发出一个"预检"请求,要求服务器确认可以这样请求。下面是这个"预检"请求的HTTP头信息。

OPTIONS /cors HTTP/1.1
Origin: http://api.bob.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom-Header
Host: api.alice.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...

"预检"请求用的请求方法是OPTIONS,表示这个请求是用来询问的。头信息里面,关键字段是Origin,表示请求来自哪个源。除了Origin字段,"预检"请求的头信息包括两个特殊字段。

Access-Control-Request-Method

该字段是必须的,用来列出浏览器的CORS请求会用到哪些HTTP方法,上例是PUT。

Access-Control-Request-Headers

该字段是一个逗号分隔的字符串,指定浏览器CORS请求会额外发送的头信息字段,上例是X-Custom-Header。

预检请求的回应

服务器收到"预检"请求以后,检查了Origin、Access-Control-Request-Method和Access-Control-Request-Headers字段以后,确认允许跨源请求,就可以做出回应。

HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2.0.61 (Unix)
Access-Control-Allow-Origin: http://api.bob.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Content-Type: text/html; charset=utf-8
Content-Encoding: gzip
Content-Length: 0
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plain

上面的HTTP回应中,关键的是Access-Control-Allow-Origin字段,表示http://api.bob.com可以请求数据。该字段也可以设为星号,表示同意任意跨源请求。

Access-Control-Allow-Origin: *

如果浏览器否定了"预检"请求,会返回一个正常的HTTP回应,但是没有任何CORS相关的头信息字段。这时,浏览器就会认定,服务器不同意预检请求,因此触发一个错误,被XMLHttpRequest对象的onerror回调函数捕获。控制台会打印出如下的报错信息。

XMLHttpRequest cannot load http://api.alice.com.
Origin http://api.bob.com is not allowed by Access-Control-Allow-Origin.

服务器回应的其他CORS相关字段如下。

Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 1728000
Access-Control-Allow-Methods

该字段必需,它的值是逗号分隔的一个字符串,表明服务器支持的所有跨域请求的方法。注意,返回的是所有支持的方法,而不单是浏览器请求的那个方法。这是为了避免多次"预检"请求。

Access-Control-Allow-Headers

如果浏览器请求包括Access-Control-Request-Headers字段,则Access-Control-Allow-Headers字段是必需的。它也是一个逗号分隔的字符串,表明服务器支持的所有头信息字段,不限于浏览器在"预检"中请求的字段。

Access-Control-Allow-Credentials

该字段与简单请求时的含义相同。

Access-Control-Max-Age

该字段可选,用来指定本次预检请求的有效期,单位为秒。上面结果中,有效期是20天(1728000秒),即允许缓存该条回应1728000秒(即20天),在此期间,不用发出另一条预检请求。

浏览器的正常请求和回应

一旦服务器通过了"预检"请求,以后每次浏览器正常的CORS请求,就都跟简单请求一样,会有一个Origin头信息字段。服务器的回应,也都会有一个Access-Control-Allow-Origin头信息字段。

下面是"预检"请求之后,浏览器的正常CORS请求。

PUT /cors HTTP/1.1
Origin: http://api.bob.com
Host: api.alice.com
X-Custom-Header: value
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...

上面头信息的Origin字段是浏览器自动添加的。下面是服务器正常的回应。

Access-Control-Allow-Origin: http://api.bob.com
Content-Type: text/html; charset=utf-8

上面头信息中,Access-Control-Allow-Origin字段是每次回应都必定包含的。

与JSONP的比较

CORS与JSONP的使用目的相同,但是比JSONP更强大。 JSONP只支持GET请求,CORS支持所有类型的HTTP请求。JSONP的优势在于支持老式浏览器,以及可以向不支持CORS的网站请求数据。

WebSocket协议跨域

WebSocket 是 HTML5 开始提供的一种在单个 TCP 连接上进行全双工通讯的协议。

WebSocket 使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在 WebSocket API 中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。

原生WebSocket API使用起来不太方便,我们使用Socket.io,它很好地封装了webSocket接口,提供了更简单、灵活的接口,也对不支持webSocket的浏览器提供了向下兼容。

前端代码

<div>
  <input type="text" id="inputText">
</div>
<script src="example.com/socket.io.js"></script>
<script>
var socket = io('http://www.peanutyu.site');

// 连接成功处理
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.getElementById('inputText').onblur = function() {
    socket.send(this.value);
};
</script>

Node Server

var http = require('http');
var socket = require('socket.io');

// 启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.'); 
    });
});

HTML5 postMessage

HTML5 window.postMessage是一个安全的、基于事件的消息API。

在需要发送消息的源窗口调用postMessage方法就可以向外发送消息。其中源窗口可以是以下的几种情况。

  1. 全局的window对象 (var win = window)
  2. 文档窗口中的iframe(var win = iframe.documentWindow)
  3. 当前文档窗口的父窗口(var win = window.parent)
  4. JavaScript打开的新窗口(var win = window.open())

发送消息

win.postMessage(msg, targetOrigin);

postMessage接受两个参数

  1. msg, 需要发送的消息,可以是一切JavaScript参数;如字符串、数字、对象、数组等。
  2. targetOrigin, 这个参数为需要消息的目标域,假设www.peanutyu.site的网页上需要往www.peanut.site的网页上传递消息,那么这个参数就是http://www.peanut.site/。如果目标窗口的协议、主机地址或端口这三者的任意一项不匹配targetOrigin提供的值,那么消息就不会被发送;只有三者完全匹配,消息才会被发送。该值也可以传入一个字符串'*'表示无限制)

接收消息

window.addEventListener('message', function receiveMessage(event) {
  if (event.origin === 'http://www.peanut.site') {
    console.log(event.data); // 传递的数据
  }
}, false);

event的属性有

  1. data 从其他 window 中传递过来的对象。
  2. origin 调用 postMessage 时消息发送方窗口的 origin . 这个字符串由 协议、“://“、域名、“ : 端口号”拼接而成。例如 “example.org (隐含端口 443)”、“example.net (隐含端口 80)”、“example.com:8080”。请注意,这个origin不能保证是该窗口的当前或未来origin,因为postMessage被调用后可能被导航到不同的位置。
  3. source 对发送消息的窗口对象的引用; 您可以使用此来在具有不同origin的两个窗口之间建立双向通信。

Nginx代理

Nginx配置

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

请求的时候直接用回前端这边的域名http://localhost:9999,这就不会跨域,然后Nginx监听到凡是localhost:9099/api这个样子的,都转发到真正的服务端地址http://localhost:9871

axios.get('http://localhost:9999/api/iframePost', params).then(result => console.log(result)).catch(() => {});

Nginx转发的方式似乎很方便!但这种使用也是看场景的,如果后端接口是一个公共的API,比如一些公共服务获取天气什么的,前端调用的时候总不能让运维去配置一下Nginx,如果兼容性没问题(IE 10或者以上),CROS才是更通用的做法吧。

Node中间层接口转发

目前中后台比较常用的接口处理方式。Spa页面通过服务器根路由或者/index渲染由前端来控制路由跳转。剩下/api路径下开发我们的接口请求。

后台配置

// 页面路由
router.get('/index', async function(ctx, next) {
  // 打包JS时间戳
  let timeT = moment().valueOf();
  // 配置基本版本号
  let buildPath = config.assetsServerName;
  try {
    let env = process.env.NODE_ENV;
    // 从Redis内获取的JS版本号
    let configInfo = await RedisService.getServerConfigInfoByEnv(env);
    if(configInfo) {
      let info = JSON.parse(configInfo);
      if(info && info['build']) {
        buildPath = info['build'].url;
      }
    }
    // 渲染SPA页面
    await ctx.render('index', {assetsPath: buildPath, tag: timeT});
  } catch(e) {
    // 报错渲染配置版本号SPA页面
    await ctx.render('index', {assetsPath: buildPath, tag: timeT});
  }
})

// 接口路由
router.get('/api/list', xxController.methods); // 获取列表方法、 具体逻辑处理通过controller完成

前端代码

_reqData() {
  axios.get('/api/list', {}).then(result => {console.log(result)}).catch(() => {});
}

componentWillMount() {
  this._reqData();
}

参考链接