原生JS实现跨域

本文系原创,转载前请邮件联络作者:scorpionlc@yeah.net或直接在评论中留言等候回复


这是一篇对于跨域的总结,将涵盖跨域的四种方法:

  • jsonp
  • cors
  • 降域
  • postMessage

在回顾每种方法时都会结合自己的实践。


>>> 什么是跨域?

在介绍跨域之前首先要了解何为“同源策略”(Same Origin Policy),浏览器(注意:主体是浏览器)出于安全方面的考虑,只允许与本域(同协议、同域名、同端口)下的数据接口进行交互,不同源的客户端脚本在没有授权的情况下,是不能读写对方资源的。

可以设想一下:如果没有同源策略,如果我自己建了一个网站,然后在没有支付宝客户端脚本授权的情况下轻松操控支付宝的脚本,随意传入我的个人信息,或者获得其他用户支付宝的数据,那将是非常危险的。同源策略有效地阻止了诸如此类的危险行为。

但是请设想这样一种场景:我自己建设了一个网站,这时候需要在网站上建设一个天气控件,背后的数据我必须从一些天气网站或者数据接口中进行获取,但是由于同源策略的限制,我无法实现这一目标。因此跨域就应运而生了。JS在不同域之间进行数据传输或者通信,譬如AJAX向一个不同源的服务端去请求数据,或者利用JS获取页面中不同域的iframe数据,从而实现不同域数据的相互访问,这些情境归根结底都是跨域。


>>> 跨域方法1:jsonp

jsonp全称:json with padding,这个名称非常地形象。意思就是异步请求跨域服务端时,不直接返回数据,而是返回一个JS方法,数据是其中的参数。其实就相当于数据变成了馅料,填充(padding)在一个方法里面,然后返回并运行。

为什么会用这么巧妙的一种方法呢?实际上,在书写HTML时如果需要引用JQuery,只需要在页面中加上<script src = "http://code.jquery.com/xxx"></script>就可以了,之后在HTML中就能调用JQuery中已经封装好的各种方法,但是code.jquery.com与请求页面的域名肯定不一样,jsonp正是借鉴了这一点来实现跨域的数据访问。

我的电脑是Windows系统,首先我在我的host文件中添加以下新域名:

1
2
3
4
5
# New Hosts
127.0.0.1 a.com
127.0.0.1 b.com
127.0.0.1 a.lyndon.com
127.0.0.1 b.lyndon.com

为何要在host文件中添加这些?因为在浏览器地址栏中输入域名后,需要根据域名去寻找对应的IP地址,这就是所谓的DNS解析,首先是在浏览器的缓存中寻找,如果没有找到,就去系统的host文件中寻找,再没有找到,就去路由器缓存中找,再往深处就是ISP DNS,根域名服务器。

我在本地启动server-mock,最原始的客户端页面和服务端页面代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<div class="container">
<p class="show">0000</p>
<button class="btn">change</button>
</div>
<script>
function $(id){
return document.querySelector(id);
}
$(".btn").addEventListener("click", function(){
var xhr = new XMLHttpRequest();
xhr.open("get", "/change", true);
xhr.send();
xhr.onreadystatechange = function(){
if(xhr.readyState === 4 && xhr.status === 200){
console.log(JSON.parse(xhr.responseText));
append(JSON.parse(xhr.responseText));
}
}
});
function append(data){
$(".show").innerText = data.data[0];
}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
app.get('/change', function(req, res){
array = [
"1111",
"2222",
"3333",
"4444",
"5555"
];
var data = [];
data.push(array[parseInt(Math.random() * array.length)]);
res.send({
data: data
});
});

在这种情境下,是能够进行正常请求的,因为请求页面请求的是同域服务端的数据。

但是当我稍对客户端页面的代码做更改,就会出现不一样的结果。

1
xhr.open("get", "http://b.lyndon.com:8080/change", true);

因为http://a.com:8080http://b.lyndon.com:8080不同域,浏览器限制了我的跨域请求。

这时候使用jsonp的思路来做一些调整,这时候我就不再使用AJAX方法,而是加入一个script标签,点击“change”按钮时,scriptsrc属性将直接从服务端返回一个方法(回调函数),数据将作为其中的参数。客户端页面和服务端页面代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function $(id){
return document.querySelector(id);
}
// jsonp
$(".btn").addEventListener("click", function(){
var script = document.createElement("script");
script.src = "http://b.lyndon.com:8080/change?callback=process";
document.head.appendChild(script);
// 及时删除,防止加载过多的JS
document.head.removeChild(script);
});
function process(data){
$(".show").innerText = data[0];
}
1
2
3
4
5
6
7
8
9
10
11
12
app.get('/change', function(req, res){
array = [
"1111",
"2222",
"3333",
"4444",
"5555"
];
var data = [];
data.push(array[parseInt(Math.random() * array.length)]);
res.send(req.query.callback + "(" + JSON.stringify(data) + ")");
});

因为在客户端加入了回调函数,因此在服务端稍作更改即可,返回的是一个function_name(data),这样一来,即使脱离了server-mock,也可以愉快地执行了。

  • 客户端域名为:a.com:8080

  • 单独执行html


>>> 跨域方法2:CORS

使用CORS方法和AJAX原代码几近类似,主要工作是在服务端加上响应头res.header(“Access-Control-Allow-Origin”, “xxx”),只要响应头中包含了请求头(Origin),就可以实现跨域,相当于数据请求的决定权在于服务端是否同意,因此CORS对于代码的修改也只需修改服务端代码即可。

客户端和服务端的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<div class="ct">
<ul class="nums">
<li>111</li>
<li>222</li>
<li>333</li>
</ul>
<button class="btn">换一组</button>
</div>
<script>
function $(id){
return document.querySelector(id);
}
$(".btn").addEventListener("click", function(){
var xhr = new XMLHttpRequest();
xhr.open("get", "http://b.com:8080/getNums", true);
xhr.send();
xhr.onreadystatechange = function(){
if(xhr.readyState === 4 && xhr.status === 200){
appendHtml(JSON.parse(xhr.responseText));
}
}
})
function appendHtml(nums){
var html = "";
for(var i = 0; i < nums.length; i++){
html += "<li>" + nums[i] + "</li>";
}
console.log(html);
$(".nums").innerHTML = html;
}
</script>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
app.get('/getNums', function(req, res) {
var array = [
"444",
"555",
"666",
"777",
"888",
"999",
"000"
]
var data = [];
for(var i = 0; i < 3; i++){
data.push(array[parseInt(Math.random() * array.length)]);
array.splice(parseInt(Math.random() * array.length), 1);
}
res.header("Access-Control-Allow-Origin", "http://b.com:8080");
res.send(data);
});

在以上的服务端代码中,设定的允许域为http://b.com:8080,在进行访问时,如果打开localhost:8080,虽然存在数据交换但是无法更新页面。

将访问页的域名改为http://b.com:8080即可正常访问。

如果为了方便,希望来自所有域的请求都可以自由获取服务端的数据,那么只需要改为:res.header("Access-Control-Allow-Origin", "*");即可。


>>> 跨域方法3:降域

降域使得处于不同域的两个HTML文件实现相互访问或相互操作成为可能。一个非常典型的使用场景:在一个页面中存在一个iframe,但是iframe中的网页与包含网页不同域,使用降域的方法可以实现两个页面内容的同步更改,因为只有处于同域条件才能使用JS操作其中的元素。

需要注意的一点是:降域的使用是存在限制的,域名中需要有一致的父级域名才可以使用降域

比如:a.lyndon.comb.lyndon.com,它们拥有一致的父级域名:lyndon.com,因此可以进行降域从而实现跨域,而a.comb.com无法进行降域,同理,类似于a.jrg.comb.lik.com也不行。

降域的实现很简单,以刚才提及的使用场景为例:只需要在两个html文件的script中加入共同的代码document.domain="lyndon.com";即可。

以下展现a.html和b.html的代码:

1
2
3
4
5
6
7
8
9
10
11
<div class="main">
<input type="text" placeholder="http://a.lyndon.com:8080/a.html">
</div>
<iframe src="http://b.lyndon.com:8080/b.html" frameborder="0"></iframe>
<script>
document.querySelector(".main input").addEventListener("input", function(){
console.log(this.value);
window.frames[0].document.querySelector("#input").value = this.value;
});
document.domain = "lyndon.com";
</script>
1
2
3
4
5
6
7
8
<input type="text" id="input" placeholder="http://b.lyndon.com:8080/b.html">
<script>
document.querySelector("#input").addEventListener("input", function(){
console.log(this.value);
window.parent.document.querySelector("input").value = this.value;
});
document.domain = "lyndon.com";
</script>

这里的window.frames返回的是一个类数组对象,成员为页面内所有的框架,包括frame元素和iframe元素,window.frames内的每个成员是框架内的窗口(框架的window对象),如果需要获取每个框架的DOM树,就需要像以上代码一样写成window.frames[0].document的形式。

在第二段(b.html)的代码中,iframe内部使用的window.parent指向的是父页面。因此第二段代码中的window.parent.document.querySelector("input")对应的是第一段代码中的input,这样的做法在两个代码文件中建立起了相互的连接。

实际效果如下:


>>> 跨域方法4:postMessage(window对象才有postMessage方法

介绍postMessage之前,需要明确一点:iframe元素遵守同源政策,只有当父页面与框架页面来自同一个域名,两者之间才可以用脚本通信,否则只有使用window.postMessage方法

因此可以明确得知:postMessage的使用范围是更加广阔的,且当降域不可行时(如:a.com和b.com无法降域)时,使用postMessage会是一个不错的选择

这里依然以页面与嵌套的iframe消息传递这一场景为例。postMessage(data, origin)方法接受两个参数:

  • data:要传递的数据,为了让所有浏览器都能正常解析,建议使用:JSON.stringify()方法将对象参数序列化
  • origin:目标窗口的源,postMessage()方法会将message传递给指定窗口,同CORS中一样,如果将origin设置为*,就可以将message传递给任意窗口

与postMessage(发送消息)对应的是接收消息,因此与postMessage相互搭配的是监听window的message事件。

以下给出两份添加注释的html代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<div class="ct">
<input type="text" placeholder="http://a.lyndon.com:8080/a.html">
</div>
<iframe src="http://localhost:8080/b.html" frameborder="0"></iframe>
<script>
// 将输入的信息传递给页面上的不同域的iframe(b.html)
document.querySelector(".ct input").addEventListener("input", function(){
console.log(this.value);
window.frames[0].postMessage(this.value, "http://localhost:8080/b.html");
});
// 监听b.html(本页面上的iframe)是否有message传递过来,如果有,将输入框中的内容换成iframe中input里的输入内容
window.addEventListener("message", function(e){
document.querySelector(".ct input").value = e.data;
console.log(e.data);
});
</script>
1
2
3
4
5
6
7
8
9
10
11
12
<input type="text" id="input" placeholder="http://b.lyndon.com:8080/b.html">
<script>
// 当有输入的文字时,向父页面(a.html)发出message
document.querySelector("#input").addEventListener("input", function () {
window.parent.postMessage(this.value, "http://a.lyndon.com:8080/a.html");
});
// 监听a.html是否有message传递过来,如果有,将iframe输入框中的内容换成a.html中input里的输入内容
window.addEventListener("message", function(e){
document.querySelector("#input").value = e.data;
console.log(e.data);
});
</script>

所以归根结底,postMessage就是一个信息交叉的过程。实际执行效果是:


>>> 附加一个自己的实践:使用jsonp获取百度联想词

  • 首先在Console中Network查看百度搜索词的联想词获取地址

联想词的数据地址为:https://sp0.baidu.com/5a1Fazu8AA54nxGko9WTAnF6hhy/su?wd=%E6%BC%82%E4%BA%AE%E7%9A%84&json=1&p=3&sid=1452_21099_18559_21673&req=2&csor=3&pwd=%20&cb=jQuery110208414170774720962_1486043984005&_=1486043984013
精简URL,可以发现:"https://sp0.baidu.com/5a1Fazu8AA54nxGko9WTAnF6hhy/su?wd=" + string即可返回联想词。后面需要加上callback"cb=" + function name来进行返回结果的处理。

  • 动态获取跨域数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function $(id){
if(document.querySelectorAll(id).length > 1){
return document.querySelectorAll(id);
}else{
return document.querySelector(id);
}
}
var txt = $("#txt"),
ul = $("#baidusug"),
script = null;
txt.onkeyup = function (){
ul.innerHTML = "";
if (script) {
document.body.removeChild(script);
}
script = document.createElement("script");
script.src = "https://sp0.baidu.com/5a1Fazu8AA54nxGko9WTAnF6hhy/su?wd=" + txt.value + "&cb=process";
document.body.appendChild(script);
};
function process(json){
for(var i = 0; i < json["s"].length; i++){
var li = document.createElement("li");
li.innerHTML = json.s[i];
ul.appendChild(li);
}
}
  • 最后的结果


>>> 总结

在今后的使用过程中,只需要辨清场景,然后按照因地制宜的原则选择一种跨域方法就好,没有必要完全依赖于一种特定的方法。一言以蔽之:没有最正确的,只有最适合的。