共计 8564 个字符,预计需要花费 22 分钟才能阅读完成。
解锁跨域的九种姿势
作者:Pawn 时间:2019.01.24
本文首发:Pawn 博客
github: https://github.com/LiChangyi
描述:分析跨域,解锁跨域的九种姿势。
写在前面
针对本文的九种方法我均写的有相应的 demo 演示(对应的前端文件,后端文件和配置文件),强烈建议不熟悉的朋友都去尝试一下。本文 github 地址,fontService 是前端地址文件,service 是后端文件。
网络上存在很多不同的跨域文章,我在学习的时候基本上也是去看他们的文章,但是有些地方的确理解起来有点困难,所以本文就这样产生了,希望能写一点和现在网络上文章中都不一样的东西。同时也把我自己的看法写进去,和大家相互交流自己的看法。
跨域在以前一直折磨着每个前端开发者。但是现在,三大框架的普及,我们在开发的过程中,只修改小小的配置,便没有这些顾虑。但实质上还是 webpack-dev-server 已经帮我们处理了这一个问题,把当前请求,代理到目标服务器上面,然后把获取到的数据返回。所以,现在很多前端开发者,包括我在写这篇文章之前,对跨域都不是很了解,只有一个个模模糊糊的概念,只会用 jsonp 去获取,或者直接用 jq 的 jsonp 方法去解决跨域,都不会去了解为什么这样就能解决跨域?甚至,很多人对跨域都已经放弃了,因为三大框架的学习,完善的脚手架功能,简化了我们,项目部署也有后端的同学帮我们解决。但是个人认为跨域是每一个前端开发者需要必备的能力,不论是跨域获取数据,还是后面进行 ssr 服务端渲染的配置都需要了解一点跨域,了解一点请求的代理。
为什么存在跨域
我们在解决的一个问题的同时我们应该先去了解这个问题是如何产生的。
之所以要使用跨域,最终的罪魁祸首都是浏览器的同源策略,浏览器的同源策略限制我们只能在相同的协议、IP 地址、端口号相同,如果有任何一个不通,都不能相互的获取数据。这里注意一下,http 和 https 之间也存在跨域,因为 https 一般采用的是 443 端口,http 采用的是 80 端口或者其他。这里也存在端口号的不同。想要详细了解请看 => MDN 对浏览器的同源策略的说明
虽然同源策略的确很可恶,但是如果没有同源策略用户将会陷入很境界。比如,你正在吃着火锅哼着歌,逛着淘宝买东西,但是这时你的同学给你发了一个网址,然后你直接打开来看,假如没有同源策略,他在该网站中用一个 iframe 的标签然后把 src 指向淘宝网,这时没有同源策略,他便可以直接通过 js 操作 iframe 的页面,比如说获取 cookie 或者用 js 模拟点击这些操作 (因为你已经登录过了,可以不用再次登录就点击了),危害是很大的。大家可以先去做一个小测验,你在本地创建一个 a.html 和 b.html2 个文件,然后在 a.html 中用 iframe 中插入 b.html,你会发现当前 2 个页面是一个域的,你可以在 a 中通过 js 控制 b,和在 b 中直接用 js 操作没有区别。但是如果你插入的不是同一个域下面的页面,比如你插入的是淘宝,你会发现你不能通过 js 操作,你 console.log(iframe.contentWindow) 你会发现只有少数的几项。大家可以去看看大佬的文章:浅谈 CSRF 攻击方式,虽然没有永远牢靠的盾,但是有同源策略的存在,会让攻击的成本变得高一点。
虽然同源策略危害很大,但是我们还是在一定的场景下面需要进行跨域处理,比如说百度全家桶,你在百度地图和百度搜索 2 者之间肯定是放在 2 个域下面的(我没有具体的去了解,但是我猜想肯定是这样的)。在开发地图的时候假如需要应用搜索的时候就不得不用跨域了。比如:百度搜索,输入文字出现内容提示,如果我没有判断错误就是采用的 jsonp 来做得跨域。大家在学习了 jsonp 跨域的时候,可以去尝试去获取一下。
进入正题
1.JSONP
说起如何去解决跨域,我相信每个人脑袋中跳出来的第一个词就是 jsonp。因为浏览器的同源策略不限制 script、link、img 三个标签,比如我们经常用这三个标签来加载其他域的资源,我个人的看法这就已经算是跨域了。jsonp 的跨域就是应用 script 标签可以进行获取远程的 js 脚本文件。
// 比如 1.js 脚本文件
say(‘haha’);
我在 html 里面引入 1.js 文件,那么他讲会执行 say 函数,我们需要传入的数据就是 haha。
所以 jsonp 的方法就是动态的创建一个 script 标签,然后设置 src 为我们需要进行跨域的地址。当然这个方法需要后台的设置。大家可以看我写的代码,
前端文件在 fontEndService/www/demo1/index,html
btn.onclick = () => {
jsonp(‘http://127.0.0.1:8888/api/getdata?jsonp=displayData’);
}
function jsonp(url) {
let script = document.createElement(‘script’);
script.setAttribute(‘src’, url);
document.getElementsByTagName(‘head’)[0].appendChild(script);
}
function displayData(data) {
msg.innerText = JSON.stringify(data);
}
然后后端代码是用 koa 写的一个简约的接口,文件在 service/app.js
// 简约的后端代码,我们直接调用前端传过来的需要执行的函数
router.get(‘/api/getdata’, ctx => {
const params = get_params(ctx.request.url)
const data = {
title: ‘ 数据获取 ’,
list: [0, 1, 2]
}
ctx.body = `${params.jsonp || ‘callback’}(${JSON.stringify(data)})`;
})
前端通过 script 标签给后台发送一个 get 请求,在 jsonp=displayData(一个我们接受到数据然后执行的方法,该方法是前端的),当我后台接受到请求后,就返回一个,执行 displayData 这个方法的脚本。然后把我们需要传递的数据放在形参里面。这样就相当于我们在前端里面执行 displayData 这个方法。用这个方法来实现跨域资源的共享。
此方法是比较常用的一个方法,简单粗暴。但是此方法有一个致命的缺点就是只支持 GET 请求。所以说如果前端页面仅仅是作为页面的展示,就只获取数据的话,只用此方法就没有任何问题。
2.iframe+document.domain
这个方法,个人感觉不是特别的常用,因为这个跨域方法要求 2 个域之间的主域名相同,子域不同,比如 a.xxx.com 和 b.xxx.com。如果不同的话是不行的。
此方法的思想就是设置页面的 document.domain 把他们设置成相同的域名,比如都设置成 xxx.com。这样来绕过同源策略。很简单的一个方法,具体的代码文件请看 github。
代码里面的测试案列是,前端文件在 7777 端口,后台文件在 8888 端口,前端如果需要请求后端的数据就存在跨域,所以我们在后端 8888 端口写一个提供数据的中转 html,然后通过 ajax 或者其他的方法请求到数据,然后把数据往外暴露。此方法需要 2 个 html 都需要设置相同的主域。
3.iframe+location.hash
这种方法是一个很奇妙的方法,虽然我感觉很鸡肋,但是它的实现方法很巧妙,我在学习的时候都感觉到不可思议,还能这么玩?
首先我们需要了解 hash 是什么?
比如有一个这样的 url:http://www.xxx.com#abc=123,那么我们通过执行 location.hash 就可以得到这样的一个字符串 #abc=123,同时改变 hash 页面是不会刷新的。这一点,相信很多学习三大框架的朋友应该都见识过 hash 路由。所以说我们可以根据这一点来在# 后面加上我们需要传递的数据。
加入现在我们有 A 页面在 7777 端口(前端显示的文件),B 页面在 8888 端口,后台运行在 8888 端口。我们在 A 页面中通过 iframe 嵌套 B 页面。
从 A 页面要传数据到 B 页面我们在 A 页面中通过,修改 iframe 的 src 的方法来修改 hash 的内容。然后在 B 页面中添加 setInterval 事件来监听我们的 hash 是否改变,如果改变那么就执行相应的操作。比如像后台服务器提交数据或者上传图片这些。
从 B 页面传递数据到 A 页面
经过上面的方法,那么肯定有聪明的朋友就在想那么,从 B 页面向 A 页面发送数据就是修改 A 页面的 hash 值了。对没错方法就是这样,但是我在执行的时候会出现一些问题。我们在 B 页面中直接:
parent.location.hash = “#xxxx”
这样是不行的,因为前面提到过的同源策略不能直接修改父级的 hash 值,所以这里采用了一个我认为很巧妙的方法。部分代码:
try {
parent.location.hash = `message=${JSON.stringify(data)}`;
} catch (e) {
// ie、chrome 的安全机制无法修改 parent.location.hash,
// 利用一个中间 html 的代理修改 location.hash
// 如 A => B => C 其中,当前页面是 B,AC 在相同的一个域下。B 不能直接修改 A 的 hash 值故修改 C,让 C 修改 A
// 文件地址:fontEndService/www/demo3/proxy.html
if (!ifrProxy) {
ifrProxy = document.createElement(‘iframe’);
ifrProxy.style.display = ‘none’;
document.body.appendChild(ifrProxy);
}
ifrProxy.src = `http://127.0.0.1:7777/demo3/proxy.html#message=${JSON.stringify(data)}`;
}
如果可以直接修改我们就直接修改,如果不能直接修改,那么我们在 B 页面中再添加一个 iframe 然后指向 C 页面(我们暂时叫他代理页面,此页面和 A 页面是在相同的一个域下面),我们可以用同样的方法在 url 后面我们需要传递的信息。在代理页面中:
parent.parent.location.hash = self.location.hash.substring(1);
只需要写这样的一段 js 代码就完成了修改 A 页面的 hash 值,同样在 A 页面中也添加一个 setInterval 事件来监听 hash 值的改变。
我们现在再来理一下思路。我们现在有三个页面,A,B,C。
A 页面是我们前端显示的页面;
B 页面我们可以把他当做 A 页面也后端数据交互的一个中间页面,完成接受 A 的数据和向后台发送请求。但是由于同源策略的限制我们不能在 B 页面中直接修改 A 的 hash 值,所以我们需要借助一个与 A 页面在同一个域名下的 C 页面。
C 页面我们把他当中一个代理页面,我们因为他和 A 页面在一个域下,所以可以修改 A 的 hash 值。所以 B 页面修改 C 页面的 hash 值,然后 C 页面修改 A 页面的 hash 值。(C 就是一个打工的)
此方法虽然我个人感觉实现的思路很巧妙但是,使用价值似乎不高,因为他实现的核心思路就是通过修改 URL 的 hash 值,然后用定时器来监听值的改变来修改。所以说最大的问题就是,我们传递的数据会直接在 URL 里面显示出来,不是很安全,同时 URL 的长度是一定的所以传输的数据也是有限的。
4.iframe+window.name
相比于前面 2 种 iframe 的方法,这种方法的使用人数要多一点。因为他有效的解决了前面 2 种方法很大的缺点。这种方法的原理就是 window.name 属性在于加载不同的页面(包括域名不同的情况下),如果 name 值没有修改,那么它将不会变化。并且这个值可以非常的长(2MB)
方法原理:A 页面通过 iframe 加载 B 页面。B 页面获取完数据后,把数据赋值给 window.name。然后在 A 页面中修改 iframe 使他指向本域的一个页面。这样在 A 页面中就可以直接通过 iframe.contentWindow.name 获取到 B 页面中获取到的数据。
A 页面中部分代码:
let mark = false;
let ifr = document.createElement(‘iframe’);
ifr.src = “http://127.0.0.1:8888/demo4”;
ifr.style.display = ‘none’;
document.body.appendChild(ifr);
ifr.onload = () => {
// iframe 中数据加载完成,触发 onload 事件
if (mark) {
msg.innerText = ifr.contentWindow.name;// 这就是数据
document.body.removeChild(ifr);
mark = false;
} else {
mark = true;
// 修改 src 指向本域的一个页面(这个页面什么都没有)
ifr.contentWindow.location = “http://127.0.0.1:7777/demo4/proxy.html”;
}
}
5.postMessage
postMessage 是 HTML5 引入的 API。他可以解决多个窗口之间的通信(包括域名的不同)。我个人认为他算是一种消息的推送,可以给每个窗口推送。然后在目标窗口添加 message 的监听事件。从而获取推送过来的数据。
A 页面中部分代码:
<iframe id=”iframe” src=”http://127.0.0.1:8888/demo5″ frameborder=”0″></iframe>
iframe.contentWindow.postMessage(‘getData’, ‘http://127.0.0.1:8888’);
// 监听其他页面给我发送的数据
window.addEventListener(‘message’, e => {
if (e.origin !== ‘http://127.0.0.1:8888’) return;
msg.innerText = e.data;
})
这里我们给目标窗口 127.0.0.1:8888 推送了 getData 的数据。然后在 B 页面中添加事件的监听。
B 页面中部分代码:
window.addEventListener(‘message’, e => {
// 判断来源是不是我们信任的站点,防止被攻击
if (e.origin !== ‘http://127.0.0.1:7777’) return;
const data = e.data;
if (data === ‘getData’) {
// 根据接受到的数据,来进行下一步的操作
myAjax(‘/api/getdata’, notice);
}
})
function notice(data) {
// 向后台请求到数据以后,在向父级推送消息
top.postMessage(JSON.stringify(data), ‘http://127.0.0.1:7777’)
}
我个人认为这种方式就是一个事件的发布与监听,至少说可以无视同源策略。
6.cors
其实对于跨域资源的请求,浏览器已经把我们的请求发放给了服务器,浏览器也接受到了服务器的响应,只是浏览器一看我们 2 个的域不一样就把消息给拦截了,不给我们显示。所以我们如果我们在服务器就告诉浏览器我这个数据是每个源都可以获取就可以了。这就是 CORS 跨域资源共享。
在后台代码中我以 KOA 列子
const Koa = require(‘koa’);
const router = require(‘koa-router’)();
// 引入中间件
const cors = require(‘koa2-cors’);
const app = new Koa();
// 根据后台服务器的类型,打开跨域设置
// cors 安全风险很高,所以,实际线上的配置肯定要比这个更加复杂,需要根据自己的需求来做
app.use(cors());
router.get(‘/api/getdata’, ctx => {
ctx.body = {
code: 200,
msg: ‘ 我是配置有 cors 的服务器传输的数据 ’
}
})
app.use(router.routes(), router.allowedMethods());
console.log(‘ 配置有 cors 的服务器地址在:http://127.0.0.1:8889’);
app.listen(8889);
这样的话,任何源都可以通过 AJAX 发起请求来获取我们提供的数据。针对不同语言的服务器后端有不一样的处理方法,但是实质是一样的。
配置了 CORS 的浏览器请求响应信息跨域请求响应信息
7.NGINX
采用 nginx 做代理应该是目前跨域解决方案最好的一种。现在强调前后端分离,前端根据后端提供的接口进行数据的交互然后渲染页面。在前端三大框架的同时,开发过程中不需要我们针对跨域配置很多。在网页上线以后。我们经常采用 nginx 来加载静态的资源,我们把我们前端打包好的文件放到 nginx 的目录下面,让 nginx 来处理客服端的静态资源的请求。然后后端部署到另外一个端口号上面,当我们需要进行数据的交互的时候,通过 nginx 代理把后端数据代理到前端页面。这样的步骤是相较于传统的跨域是最简单也是最有效的一种方法,因为 nginx 又没有同源策略。不用考虑什么兼容性也不用考虑数据大小。我们在服务器 (或者测试代码的时候在本地) 安装 nginx 服务,然后找到我们 nginx 的配置文件,添加以下配置文件:
server {
# 把页面部署的端口
listen 8080;
# 静态页面存放的目录
root /var/www/html;
index index.html index.htm index.php;
# 只代理 /api 开头的接口,其他接口不代理
location /api/ {
# 需要代理的地址,输入我们的后台 api 地址
proxy_pass http://127.0.0.1:8888;
}
}
这样,我们可以代理不同 url 开头的请求到不同的后端进行处理,对以后服务器做负载均衡和反向代理也很简单。
8.nodejs
其实这种办法和上一种用 nginx 的方法是差不多的。都是你把请求发给一个中间人,由于中间人没有同源策略,他可以直接代理或者通过爬虫或者其他的手段得到想到的数据,然后返回(是不是和 VPN 的原理有点类似)。
当然现在常见的就是用 nodejs 作为数据的中间件,同样,不同的语言有不同的方法,但是本质是一样的。我上次自己同构自己的博客页面,用 react 服务器端渲染,因为浏览器的同源策略,请求不到数据,然后就用了 nodejs 作为中间件来代理请求数据。
部分代码:
const Koa = require(‘koa’);
// 代理
const Proxy = require(‘koa-proxy’);
// 对以前的异步函数进行转换
const Convert = require(‘koa-convert’);
const app = new Koa();
const server = require(‘koa-static’);
app.use(server(__dirname+”/www/”,{ extensions: [‘html’]}));
app.use(Convert(Proxy({
// 需要代理的接口地址
host: ‘http://127.0.0.1:8888’,
// 只代理 /api/ 开头的 url
match: /^\/api\//
})));
console.log(‘ 服务运行在:http://127.0.0.1:7777’);
app.listen(7777);
是不是和 nginx 很类似呀!!
9.webSocket
webSocket 大家应该都有所耳闻,主要是为了客服端和服务端进行全双工的通信。但是这种通信是可以进行跨端口的。所以说我们可以用这个漏洞来进行跨域数据的交互。
我个人认为,这种方法实质上和 postMessage 差不多,但是不是特别的常用吧!(仅仅个人看法)
所以说我们可以很轻松的构建基于 webSocket 构建一个客服端和服务端。代码在 github 建议大家都多多去运行一下,了解清楚。这里就不贴了。
最后
哇!长长短短的写了 5000 多字终于写到最后了!!写总结真的比写代码还困难。
个人觉得,第 1 种方法和第 6 种方法是以前常用的一种方法,毕竟以前基本上都是刀耕火种的前端时代。然后 2,3,4 种方法基本上现在很少有人会用,包括我没去详细了解之前也不会,但是里面有很多思想却值得我们去思考,比如第 3 种方法,反正我个人觉得很巧妙。第 5,9 种个人认为,这 2 种方法虽然可以解决跨域,但是把他们用在跨域有点大材小用了解就好。第 7,8 种方法,觉得应该是现在每个前端 er 都应该掌握的方法,应该以后解决跨域的主要方法。
所有的代码都在 github 上面,地址在文章开头,我强烈建议都 clone 下来跑一边代码,最好是结合自己的理解把 9 种方法都去实现一下。
由于我也是才了解跨域不久,本文有很多地方仅仅是我个人看法,欢迎大佬补充、勘误!