分类 前端 下的文章

如何拦截jsonp的回调函数

Date: 2017-09-20 21:43

最近在公司做的一个需求:在某个页面发送特定请求之后,拦截它的后续操作,以便自定义某些操作。听起来好像有点奇葩,但这类需求应该还是存在的,解决方案比较独特,值得记录一下。

分析页面请求情况,发现是个jsonp请求,就引出了问题所在,如何拦截jsonp的回调函数?

要解决这个问题,就要先回顾jsonp的原理,其实也很简单,就是利用<script>元素不受跨域限制的特性,来发起请求,拿到构造好的响应之后(用callback函数包裹),就自然会调用callback函数:

举个例子:

<script>
    function test(data){
        //do something
    }
</script>
<script src="/api?callback=test"></script>

假设在文档中,有一个函数test, 对接收的参数进行某些操作。然后有个script标签去取api这个接口的数据,而如果 api 接口返回的数据【恰好】是长这样的(需要后端接口构造这样的格式): test({a:b}) 。那就相当于调用前面的test函数,并把{a:b}作为参数传入。

既然原理已经知道了,那就看如何检测请求了。这里以JQUERY为例来说明,其他库没特别研究过,应该也差不多。

(以下步骤没有看过jQuery源码,所以可能有略描述错误)
jQuery发起jsonp请求,大致可以分为几个步骤:

  • 新建一个script标签,并且构造 src属性,即把get参数拼上去,再生成一个callback=xxx的属性,最后得到的可能是这样一个URL:https://xxx.com/api/?a=b&callback=jQuery110206309371989766117_1506762129565&_=1506762129566
  • 把构造好的script标签插入到文档中
  • 然后在全局定义一个函数,例如在这个例子中,就是function jQuery110206309371989766117_1506762129565(){//do something} 作为回调函数,script标签获得结果后,就会调用这个函数。

想明白了这几步,那现在的问题是:如何知道文档被插入了一个script标签。
这里需要绑定dom事件:DOMNodeInserted 当文档中有元素插入,就会触发这个事件。具体的:

var html = document.getElementsByTagName('html')[0]
html.addEventListener("DOMNodeInserted", function(e){
    //do something
}, false);

在上面的代码中,取到根节点HTML元素,再绑定DOMNodeInserted 事件,这样一旦文档中有元素插入,就会触发。在根据传入的事件变量e来判断具体元素是否是script标签,并且script的scr属性是否是我们要拦截的URL (事件变量e还有很多属性,具体可以打印看看)。这样一旦确定了目标,就可以将全局函数callback函数重写了:

var html = document.getElementsByTagName('html')[0]
html.addEventListener("DOMNodeInserted", function(e){
    if (e.target.tagName == "SCRIPT" && e.srcElement.src.match('api') ){
        var src = e.srcElement.src;
        var callback = src.match(/callback=(\w+)/)
        callback = callback[1]
        window[callback] = function (data) {
            //重写callback方法
        }
    }
}, false);

在上面的代码中,首先判断这个插入的元素是否是script,然后src是否符合拦截需求。确定后,则从src属性中提取出callback参数,拿到回调函数的函数名,这时候在重写回调方法:window[callback] = function (data) {}

到了这里就大功告成了,其实整个原理也不复杂,大概总结一下流程:

  • 判断页面有插入了script标签,并且src属性是需要拦截的
  • 从src属性从提取出回调函数函数名
  • 重写回调函数

如何跨域

跨域是个大问题

最近工作中经常需要在前端调用接口,则时常会碰到跨域的问题,积累一些思考,总结一下。另外也深刻感觉到,如果一个东西,不自己实践一下,仅看书是很难理解透彻的。

首先跨域就要区分什么是域。这个很容易理解,不再赘述,无非就是协议、域名、端口等都要一致。

而跨域就是在不同的域之间数据的通信。而且需要注意的是,跨域只发生在前端,后端是没有跨域这一说的。

比如a.com 中,想要获取b.com的内容,这个时候通常就是a.com中通过js发起一个http请求(也就是Ajax),去请求b.com的数据。 这样:

<script>
var xml = new XMLHttpRequest();
 xml.open('get','http://xxx.com/api',true)
 xml.send(null)
 xml.onreadystatechange = function(){
    alert('change')
 }
</script>

但是打开页面,这一段js代码执行之后,就会发现报了这样一个错:

XMLHttpRequest cannot load http://xxx.com/api. No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'null' is therefore not allowed access.

大致是说,http://xxx.com/api 这里面的响应头(response header)里没有指定 Access-Control-Allow-Origin,因此我们的请求头 origin:null 不允许访问这个地址。

这是什么问题?这还要从浏览器的同源策略说起,浏览器为了安全起见,不允许去请求与当前页面不在同一个域的资源!所以当你请求其他域的资源,浏览器会阻断你,除非在响应头里指明了Access-Control-Allow-Origin,并且在这个Access-Control-Allow-Origin中有你当前所在的域,才会让你访问!

这就是为什么前面说的,跨域只发生在前端,后端没有跨域这一说法,正是浏览器不让我们访问! 但是有时候,正常的跨域请求又是很必须的,怎么办?

就按照上面说说,在Access-Control-Allow-Origin指明你的域,如Access-Control-Allow-Origin:a.com 或者Access-Control-Allow-Origin : *。这也就是通常所说的,CROS跨域资源共享。使用这种方法,需要对方服务器允许我们跨域,也就是说,要么对方服务是开放的,要么就得提前与对方协商。麻烦是麻烦,但是增加安全性。

举个例子: a.com/index.html中需要去请求b.com/api.php的页面,想要跨域成功,就需要api.php中,设定响应头:

<?php
header('Access-Control-Allow-Origin:a.com');
//或者header('Access-Control-Allow-Origin: *');

这样就能跨域成功,请求不会被浏览器阻断。

这是一种方式,但是还有另外更常用的【奇技淫巧】供我们使用。也就是jsonp。jsonp的原理:

虽然一般情况下不能请求其他域的数据,但是却有两个例外:script标签,和img标签,这两个标签可以随意地引用其他页面的数据也不会被阻断。例如:

<script src="http://c.com/index.js"></script>
<script src="http://d.com/test.js"></script>
<img src="http://xxx.com/a.png" alt="">

那我们就可将需要请求的数据,交给这两个标签来请求就好了!
<script src="http://c.com/api.php"></script>
这样不就相当于给其他域发了一个请求吗?对的就是这样。 但是有两个问题:
只能发送get请求,对于需要通过post传递的数据,就无能为力了。
另外,如果此时打开一看,就发现还是报错了:
Uncaught SyntaxError: Unexpected token :
//也可能是报其他的错,一般都是Unexpected token xxx
但是如果通过抓包能看到,请求正常发出,并且服务器也返回了数据。但是!我们这里是通过script标签引入的,也就是说引入的是一堆数据,而我们知道script需要引入的是js文件啊,当然出错了!
那如何解决呢?这时很多网上的断章取义文章都会给你说,指定一个回调函数,处理获取到的数据就行了,例如:

function test(response){
   console.log(response)
}
<script src="http://c.com/api.php?callback=test"></script>

//在后面增加callback参数,加载数据之后,就会调用test函数来处理获取到的数据。

如果真的是这样的话,那就好了。但是想一下,如果http://c.com/api.php?callback=test返回的是 'helloworld',也就相当于:

<script>hello world</script>

那你能调用个鬼啊!

也就是说,这里我们期望的数据是这样的:

<script src="test(response)"></script>

只有如此才能调用我们的test()函数。jsonp的重点就在这里,服务器返回的是一串以我们callback参数为函数名包裹着的参数,这时才能调用我们的回调函数。这才是重点,网上很多一知半解的人写的文章根本都没讲到这一点。

也就是说,如果服务器不特别为你返回这种形式的响应,那所谓的jsonp中的回调函数还是没法调用的。

如何返回的,还是举一个服务器端的例子:

//c.com/api.php
$callback = $_GET['callback'];

$result = [];
$result['msg'] = 'hello';
$result = json_encode($result);

echo "$callback($result)";

首先先获取到 传过来的 callback 参数,当然不一定要叫callback 。然后再把json化后的result数组放入$callback($result)。于是乎运行下来:响应的值就是:

test({"msg":"hello"})
也就相当于:<script src="test({"msg":"hello"})"></script>。直到这里,才能调用我们我们预定义好的回调函数test。

也就是说,如果服务器不为你特别返回这种形式的响应,那所谓jsonp还是不能完成跨域的。

上面说到,img标签也能跨域请求,但是script标签相比,它不能操作获取到的响应数据,所以较jsonp相比,更加有限。

总结一下:

所谓的跨域方法,并非就能突破浏览器的同源策略限制,如果对方服务器不支持,还是没法跨域。
跨域发生在前端,因为有浏览器的同源策略限制,才会有跨域这种说法。后端请求数据的话,则无限制,也就说说,如果前端无法跨域获取数据,可以考虑在后端获取数据返回给前端使用。