JavaScript浅析 -- 同源策略和跨域

一、同源策略

什么是浏览器的同源策略?浏览器出于安全考虑,只允许相同域下的资源进行交互,不同源下的脚本在没有明确授权的情况下,不能读写对方的资源,这就是同源策略。

而所谓的同源,指的是协议、域名、端口号相同。举个例子:http://github.com:80,其中http://就是协议,而github.com就是域名,80是端口(默认端口可以省略)。只有这三个完全相同才算同源,任何一个不同都不算。假设当前的网址是http://www.a.com:80/a/index.js,同源情况如下:

  • https://www.a.com:80 不同源(协议不同)
  • http://www.a.com:90 不同源(端口号不同)
  • http://a.com:80 不同源(域名不同)
  • http://abc.a.com:80 不同源(域名不同)
  • http://www.b.com:80 不同源(域名不同)
  • http://192.110.110.110:80 不同源(192.110.110.110www.a.com对应的ip也认为域名不同)
  • http://www.a.com:80/b/index.js 同源(只要前部分相同,后面不同文件夹也可以)

如果两个网站不同源,则交互会受到限制,具体如下:

  • 不能共享cookie、storage和IndexedDB。
  • 不能互相操作dom。
  • 不能向非同源地址发送 AJAX 请求(可发但浏览器会拒绝接受响应)。

而为啥要有这个策略限制呢?试想一下,在一个窗口刚登录过银行网站http://www.bank.com,然后又切换到另一个页面http://hacker.com,如果这个与银行非同源页面能够访问到银行页面的cookie,而cookie里却保存着银行的账号和密码,这是一件非常危险的事情。

二、跨域方案

虽然这个同源策略是为了安全而诞生的。但有时候开发我们却需要有一些跨域请求或操作。比如使用第三方服务而需要请求第三方服务器,这就是所谓的跨域。

那如何避开浏览器的同源策略实现跨域呢?此处主要整理了八种方案,主要分为三类:

  • AJAX 请求的跨域
  • iframe 的跨域(又可分为主域相同和主域不同)
  • 通过服务器代理实现跨域
(一)AJAX 请求的跨域
1. JSONP

JSONP是跨域中非常常见的一种形式,他支持所有老版的浏览器。其主要是利用页面上请求<script>脚本不受同源策略限制的特性,来实现跨域。主要步骤如下:

  • 创建一个接收后处理数据的回调函数,并在被请求的url后面增加callback=funcName
  • 创建一个<script>元素,src指定为上面增加了callback的url。
  • 发起脚本请求,服务器返回数据后会自动执行script脚本从而执行了回调函数。

(1) 原生版本

// 前端页面代码
<script>
   // 服务器收到请求后会将数据放在回调函数的参数位置返回
    function jsonpCallback(data) {
        console.log(data.msg); // 作为参数的JSON数据被视为js对象而非字符串,不需要JSON.parse
    }
</script>
<script src="http://127.0.0.1:8080?callback=jsonpCallback"></script>
// 后端代码,node版本
const url = require('url');
const http = require('http');

// 启动http服务 
http.createServer((req, res) => {
    const data = { msg: 'success' };
    const callback = url.parse(req.url, true).query.callback; // 解析url取函数名
    res.writeHead(200); // 返回成功的状态码200
    res.end(`${callback}(${JSON.stringify(data)})`); // 向前端返回数据
}).listen(8080, '127.0.0.1'); // 监听来自127.0.0.1:8080端口的请求

(2)jquery的ajax版本

// 前端代码
$.ajax({
    url: 'http://127.0.0.1:8080',
    type: 'get',
    dataType: 'jsonp',  // 请求方式为jsonp
    jsonpCallback: "jsonpCallback",    // 回调函数名
    data: {}
});

(3)vue版本

this.$http.jsonp('http://127.0.0.1:8080', {
    params: {},
    jsonp: 'jsonpCallback'
}).then((data) => {
    console.log(data); 
})

JSONP的优点和缺点如下:

优点:
(1)支持所有浏览器即使是旧版浏览器。
(2)可用于k向一些不支持 CORS 的网站请求数据。
(3)不需要 XMLHttpRequest 或 ActiveX 的支持,所以不受 XMLHttpRequest 同源策略限制;请求完毕后自动调用 callback 并回传结果。

缺点:
(1)只支持get请求。
(2)无法捕获请求时的连接异常,只能通过超时进行处理。
(3)无法解决iframe页面之间的数据通信问题。

2. CORS

所谓CORS,全称Cross-Origin Resource Sharing跨域资源共享,是一个W3C标准,专门用于解决 AJAX 的跨域问题。它允许浏览器向跨源服务器发出 XMLHttpRequest 请求(如果没有增加 CORS 支持则不能向非同源发送 AJAX 请求,会被浏览器拦截)。

CORS 需要浏览器和服务器同时支持才生效,前端不需要做任何操作。当浏览器发现发送的请求是跨域请求时,会自动在请求头加上Origin字段表明当前的域(协议+域名+端口号),非简单请求还会先发送一次额外请求进行预检,但这都是浏览器自动完成的,用户和前端并不需要增加操作。而请求之后服务器也会返回一个http响应,如果返回的响应中带有Access-Control-Allow-Origin,并与上面请求头的Origin相匹配的话,那么即允许跨域;否则抛出错误被 XMLHttpRequest 的onerror回调函数捕获。注意,这种错误状态码可能是200,所以不能通过状态码去判断。

所以,可以见得,实现CORS的关键是后端要增加相应的字段。具体见下面的例子(还可以返回更多的头信息如Access-Control-Allow-Credentials等,此处只写了关键的一步):

// 服务端代码,node版本
const http = require('http');

// 启动http服务 
http.createServer((req, res) => {
    res.writeHead(200, {
        'Access-Control-Allow-Origin': '*', // 也可以直接写允许请求的域如http://www.baidu.com;*表示所有的域都可以请求,适合公开接口
        'Content-Type': 'text/html;charset=utf-8',
    });
    res.end();
}).listen(8080, '127.0.0.1'); // 监听来自127.0.0.1:8080端口的请求

CORS 的优缺点如下:

优点:
(1)比 JSONP 更强大,支持所有类型的 HTTP 请求。
(2)是W3C标准,大部分浏览器自动完成,只需服务器增加些许字段,非常方便。

缺点:仅支持 IE 10 以上等新版浏览器。

3. WebSocket

WebSocket 是HTML5一种新的协议。它实现了浏览器与服务器的双向通信,同时不受同源策略限制,允许跨域通讯,使用ws://(非加密)和wss://(加密)作为协议前缀。

// 前端代码,websocket版本
var ws = new WebSocket('ws://http://127.0.0.1:8080/websocket/chat'); // 创建连接,安全连接用wss

// 建立连接时调用
ws.onopen = function() {
    console.log('Connection open ...');
    ws.send('Hello WebSocket!'); // 发送消息给服务端 
}

// 接收服务器发送过来的消息
ws.onmessage = function() {
    var data = msg.data;
    if(typeof data === String) {
        console.log(data);
    }
    if(data instanceof ArrayBuffer) {
        // 处理ArrayBuffer逻辑...
    }
    ws.close(); // 关闭连接
}

// 关闭时调用
ws.onclose = function() {}

// 错误处理
ws.onerror = function(err) {}

一般使用WebSocket,我偏向使用socket.io。后者对前者的API进行了封装,使其更易用。前者只支持 IE 10 以上等新版浏览器,而后者兼容了旧版浏览器。所以此处增加个socket.io的例子。

// 前端代码,socket.io版本
<script src="./socket.io.js"></script>
<script>
var socket = io('http://127.0.0.1:8080');

// 连接成功处理
socket.on('connect', function() {
    // 监听接收服务端消息
    socket.on('message', function(data){});

    // 服务端关闭调用
    socket.on('disconnect', function(){});
});

socket.send('Hello WebSocket!'); // 发送消息给服务端
</script>
// 后端代码,node + socket.io版本
var http = require('http');
var socket = require('socket.io');

var server = http.createServer(function(req, res) {
    res.writeHead(200, {
        'Content-type': 'text/html'
    });
    res.end();
});
server.listen('8080');

// 监听连接
socket.listen(server).on('connection', function(client) {
    // 接收信息
    client.on('message', function(data) {
        client.send(data);
    });

    // 断开处理
    client.on('disconnect', function(){});
});

优点:
(1)不受同源策略的限制,支持与任意服务端通信。
(2)可以进行双向通信,服务端也可以主动给客户端推送消息。(传统的 HTTP 只允许客户端发起请求,只能用轮询来了解服务端是否更新信息,效率低浪费资源。)

(二)iframe实现跨域

iframe的跨域实现,主要有document.domain、location.hash、window.name、postMessage四种方案,下面我们一一解析。

1. document.domain

这种方法适合一级域名相同,二级域名不同的情况下使用。域名相关见如下:

  • 顶级域名:
    .com .net .edu .gov 等属于通用顶级域名
    .com.cn .net.cn .edu.cn 等属于带有国家地区代码顶级域名,而不是所谓的一级域名
  • 一级域名(又叫主域名)就是最靠近顶级域名左侧的字段,下面均属于一级域名:
    baidu.com qq.com
    baidu.com.cn qq.com.cn
  • 二级域名,即最靠近二级域名左侧的字段,二级及以上级别域名称为子域名:
    www.baidu.com www.qq.com
    www.baidu.com.cn www.qq.com.cn
  • 再接下来从右向左便可依次有三级域名、四级域名、五级域名等,依次类推即可。
// 主页面域名http://parent.main.com
<script>
    document.domain = 'main.com'; // 将两个窗口的域名都设置为一级域名
    let iframe = document.getElementsByTagName('iframe')[0];
    let data = iframe.contentWindow.data; // 获取子窗口里的data数据
</script>
// 子窗口域名http://child.main.com
<script>
    document.domain = 'main.com'; // 将子窗口的域名设置为一级域名
    let data = window.parent.data; // 获取父窗口里的data数据
</script>

缺点:只支持主域名相同的父子窗口通信。

2. location.hash

location.hash获取的是当前地址栏url的片段识别符,即http://example.com/x.html#fragment#及后面部分#fragment。由于单纯改变片段识别符并不会导致页面刷新,所以我们可以利用这个特性来让父子窗口互相传值。

// 父窗口修改子窗口的hash
var src = childURL + '#' + data;
document.getElementById('childIFrame').src = src;
// 父窗口监听hash改变
window.onhashchange = function() {
  var data = window.location.hash; // 获取子窗口传过来的数据
}

如果两个窗口不在同一个域下, IE、Chrome 不允许子窗口修改 parent.location.hash 的值,所以要借助于一个和父窗口同域的页面来实现修改 hash 值。

// 子窗口监听hash改变
window.onhashchange = function() {
  var data = window.location.hash; // 获取父窗口传过来的数据
}
// 子窗口也可以修改父窗口的hash,但需要一个与父窗口同域的代理窗口来修改hash值
let iframe = document.createElement('iframe');
iframe.src = parentURL;
iframe.style.display = 'none';
document.body.appendChild(iframe);
iframe.onload = function() {
    parent.parent.location.href = parentURL + '#' + data;
}
// 同域可以直接用下面的写法
// parent.location.href = parentURL + '#' + data;

缺点:会改变url上面的#后的值,数据直接暴露在url上。

3. window.name

window.name是窗口的名字,每个子窗口也有自己的windowwindow.name。只能保存字符串,如果写入的值不是字符串,会自动转成字符串。其特殊之处在于只要窗口不关闭,这个属性便不会消失,且储存容量可高达几MB。如果加载了a.com之后写入window.name,窗口不关闭重新加载b.com,此时window.name还是a.com写入的值;窗口关闭window.name清除。

利用这个特性,我们可以在当前域下创建一个目标域的子窗口,目标域的数据放在window.name,加载完成后再让子窗口跳到一个父域相同的空白代理页(window.name还是不变),获取这个代理页的window.name赋值给父域即可。注意,一定要有与父域同域的代理页,否则父域是无法直接获取子窗口的window.name的;另外重新跳转页面会触发onload,要注意避免死循环,可以加个loaded标记。

// 当前页面,即http://parent.com
<script>
    let isIFrameLoaded = false, 
        data = '',
        iframe = document.createElement('iframe');
    iframe.style.display = 'none';
    iframe.src = 'http://target.com';
    document.body.appendChild(iframe);

    iframe.onload = function() {
        if(!isIFrameLoaded) { // 首次进入读取到window.name,然后刷新到代理页面window.name不变
            isIFrameLoaded = true; // 下面刷新会再次进入onload,此处标记为已完成避免死循环
            iframe.contentWindow.location = 'http://parent.com/proxy.html';
        } else {
            data = iframe.contentWindow.name; // 获取到目标域下的数据
            // 清除iframe
            iframe.contentWindow.document.write('');
            iframe.contentWindow.close();
            document.body.removeChild(iframe);
        }
    };
</script>
// 目标数据页面,即http://target.com,将想要提供的数据保存在window.name即可
<script>
    // 注意只能是字符串,若内容有引号根据需要可能要进行转义处理
    window.name = JSON.stringify({code: 0, result: {name: 'Peter', age: 18}});
</script>

缺点:
(1)比较繁琐,需要增加代理页面,还要避免重复循环等。
(2)目标数据要放在window.name,格式只能是字符串。
(3)缺少请求源控制,任何页面都可以按同样的方式访问到目标页面的数据。

4. window.postMessage

可以无论hash还是window.name都是利用一些特性来绕个弯达到目的,均属破解。为了解决该问题,HTML5 引入了一个新API跨文档通信 API(Cross-document messaging),无论两个窗口页面是否同源,都可以通过调用window.postMessage(content, target)来进行通信。其中target为协议域名端口号,可以设置“ * ”代表向全部窗口发送,也可以指定“ / ”代表当前域。父子窗口通过监听message事件可以获得来源方的这些信息event.origin(源网址)event.data(携带的数据)event.source(发送消息方的窗口)

// 父窗口,http://origin.com
let iframe = document.createElement('iframe');
let targetURL = 'http://target.com';
iframe.src = targetURL;
iframe.style.display = 'none';
document.body.appendChild(iframe);
iframe.onload = function() {
    // 引用子窗口触发其message事件
    iframe.contentWindow.postMessage('Hello target', targetURL);
}
// 监听当前窗口的message改变来获取数据
window.addEventListener('message', function(event) {
    console.log(event.data); // 获取目标页面的数据 ‘Hello origin’
});
// 子窗口或目标页面,http://target.com
window.addEventListener('message', receiveMessage);
function receiveMessage(event) {
    // 检验数据请求方是否为自己的网址
    if (event.origin !== 'http://origin.com') return;

    if (event.data === 'Hello World') {
        // 引用父窗口触发父窗口的message事件
        event.source.postMessage('Hello origin', event.origin);
    } else {
        console.log(event.data);
    }
}

优点:
(1)多个窗口(嵌套与否均可)之间可以进行跨域通信和操作window属性等,非常强大,也让跨域存储localStorage成为了可能。
(2)可以对来源进行校验,控制是否有权访问。

(三)服务器代理

最后一类实现跨域的方法是通过架设代理服务器来实现跨域。即先请求同源服务器,再由同源服务器请求外部服务器。由于请求的是同源服务器,所以不受浏览器同源策略限制,而服务器之间的请求也没有同源策略这一说,所以以此达到跨域目的。由于对服务器方面了解并不是很深,此处就不做展开,有兴趣可以自行了解下。

至此,八种跨域的方式终于讲完了(写了好久...吃掉每个小点再讲明白真不容易,可能还有图片ping啥的,先躺倒休息会...),以上全部都是简单案例可根据需求进行优化扩充,如有错漏,欢迎指出!