前端 Ajax 跨域请求方案沙里淘金

4,511 阅读8分钟
原文链接: www.jianshu.com

1. 所谓跨域

跨域是一种浏览器同源安全策略,也即浏览器单方面限制脚本的跨域访问。很多人可能误认为资源跨域时无法请求,实质上请求是可以正常发起的(指通常情况下,部分浏览器存在部分特例),后端也可能正常进行了处理,只是在返回时被浏览器所拦截。可以论证这一点的著名案例就是CSRF跨站攻击。

另外,所谓跨域都是在讨论浏览器行为,包括各种webview容器,其中犹以 XmlHttpRequest 为主。正是由于javascript跑在浏览器之上,所以ajax的跨域成了痛点。

2. 跨域形成

请求的url与当前页面不同即产生跨域,除常理上的站点直接性不同(百度域名下访问谷歌资源),同个站点也可以产生跨域:

  1. 协议跨域,例如从 http 站点访问 https 站点。
  2. 主机跨域,例如从 a.baidu.com 访问 b.baidu.com
  3. 端口跨域,例如从80端口的站点访问8080端口的站点。

请求域名和直接请求该域名对应的ip之间也算跨域。

内部判断规则:url首部匹配

window.location.protocol + window.location.host

简单性的将协议、主机名和端口号抽出进行对比,不同即跨域,所以也是不会去转化为ip地址的。

3. 跨域方案之Jsonp

谈起Jsonp在跨域处理方案中也算鼎鼎大名,这是一种非官方的解决方案,源于浏览器允许一些带src属性的标签跨域,例如iframe、script、img等。而Jsonp即是利用了script加载外部脚本的功能。

例如常规下的请求

get => http://a.test.com/users

=>>

[{
    username : '沐心chen',
    sex : '男',
    address : '广东深圳'
},{
    username : '李彦宏',
    sex : '男',
    address : '山西阳泉'
}]

由于浏览器的同源策略被阻止,此时前端使用script脚本去加载:

<script src="http://a.test.com/users"></script>

显然可以成功请求到,只是单纯的json数据无法使用。此时如果后端介入,返回之前包装成如下形式:

jsonp([{
    username : '沐心chen',
    sex : '男',
    address : '广东深圳'
},{
    username : '李彦宏',
    sex : '男',
    address : '山西阳泉'
}])

对于js而言,这就是一个普通的函数调用

jsonp(...params)

那么只要前端定义jsonp这个函数,它就会被执行并传入json数据。

var jsonp = function(data){
    //输出json
    console.dir(data);
}

jsonp跨域的流程走完,只是单纯到这一步还不行,因为它将导致后端无法正确处理非jsonp的请求,所以通常会约定一个参数callback,带上回调的函数名。

<script src="http://a.test.com/users?callback=jsonp"></script>

后端得到callback参数时,使用该值包装json数据,否则正常处理。

需要注意的是,处理jsonp的函数必须在window下,也即

window.jsonp = function(data){
    console.dir(data);
}

方案虽然可行,但也同时意味着jsonp只能发起get请求,对于post就无能为力了。

知道了原理,使用起来相对还是麻烦,那么如何用js简单封装一个jsonp方案呢?

var getJsonp = function(url, success){
    //声明window下的jsonp函数
    window.jsonp = function(data){
        //jsonp函数被执行将data转发到success函数
        success(data);
    }
    var src = '';
    //判断地址是否带其它参数决定callback怎么拼接
    if(url.IndexOf('?') != -1){
        src = url + '&callback=jsonp';
    }else{
        src = url + '?callback=jsonp';
    }
    //动态创建script标签
    var script = document.createElement('script');
    script.type = "text/javascript";
    script.src = src;
    document.head.appendChild(script);
}

//用法
getJsonp('http://test.com/users', function(data){
    console.log('得到jsonp数据:',JSON.stringify(data));
});

上面只是一个简单的封装思路,如果需要做的更好可以允许指定callback,还可以在回调函数之后销毁script脚本,这些留给大家去发挥(思考一下,如果每个开发者都统一用callback,你可以跨域访问,别人也可以跨域访问,安全上面起不到更好的保障,与后端协议好一个自定义的参数,将能稍微避免一些,当然,所谓安全大都只是防范君子)。

浏览器支持:几乎所有

4. 跨域解决方案之CORS

CORS,也即 Cross-Origin Resource Sharing(跨域资源共享),它需要现代浏览器的支持,是一种更安全的官方解决方案。

CORS使得以下常见场景得到支持:

  1. 使用 XMLHttpRequest 或 Fetch 发起跨站 HTTP 请求。
  2. web 字体(css 中通过 @font-face 使用跨站字体资源)
  3. 使用 drawImage 绘制 Images/video 画面到 canvas

CORS有以下三种常见的访问控制场景:

  1. 简单请求

    • 只使用 GET 、HEAD 或者 POST 发起请求,如果使用 POST ,那么其数据类型( Content-Type )只能是 application/x-www-form-urlencodedmultipart/form-datatext/plain中的一种。
    • 不使用自定义请求头

      这种请求跟正常的ajax请求几乎没有差异,只是浏览器会在请求头中自动添加一个origin属性,内容为本页面地址。例如我们使用 XMLhttprequest 正常发起一个 GET 请求,源站点为my.com,目标站点为test.com,浏览器实际发出的请求头如下:

      GET /resources/public-data/ HTTP/1.1
      ...
      Origin: http://my.com

      此时浏览器维持判断,当服务端返回的响应头中,存在跨域访问控制属性并匹配本次请求,则跨域成功(正常接收数据)。

      HTTP/1.1 200 OK
      ...
      Access-Control-Allow-Origin: http://my.com

      这种跨域请求非常简单,只需要后端在返回的响应头中添加Access-Control-Allow-Origin属性并将被允许的站点填入即可(多个站点逗号隔开,允许所有站点则设为*

  2. 预请求

    预请求不同于简单请求,它首先会发送一个 OPTIONS 请求到目标站点,以查明该请求是否安全可接受,以防止请求对目标站点的数据造成破坏。当请求具备以下条件,就会被当成预请求处理:

    • 请求以 GET, HEAD 或者 POST 以外的方法发起请求。或者,使用 POST,但请求数据为 application/x-www-form-urlencoded , multipart/form-data 或者 text/plain 以外的数据类型。比如说,用 POST 发送数据类型为 application/xml 数据的请求。
    • 使用自定义请求头

      例:

      var request = new XMLHttpRequest();
      var url = 'http://test.com/users';
      var body = 'test';
      
      function coAccess(){
        if(request)
        {
          request.open('POST', url, true);
          request.setRequestHeader('X-CUSTOMER-HEADER', '沐心chen');
          request.setRequestHeader('Content-Type', 'application/xml');
          request.onreadystatechange = function(state){
              ...
          };
          request.send(body); 
        }
        ...

      上面发送了一个 POST 请求,请求数据类型为application/xml,并携带一个自定义请求头X-CUSTOMER_HEADER,符合预请求的规范。

      此时浏览器与后端的交互过程如下:

      //浏览器预先发起OPTIONS请求
      ,自动添加Origin、Access-Control-Request-Method和Access-Control-Request-Headers
      OPTIONS /resources/post-here/ HTTP/1.1
      ...
      Origin: http://my.com
      Access-Control-Request-Method: POST
      Access-Control-Request-Headers: X-CUSTOMER-HEADER
      
      //后端接收OPTIONS请求,返回响应头中包含Access-Control-Allow-*策略和Access-Control-Max-Age时限
      HTTP/1.1 200 OK
      ...
      Access-Control-Allow-Origin: http://my.com
      Access-Control-Allow-Methods: POST, GET, DELETE, UPATE, PATCH, OPTIONS
      Access-Control-Allow-Headers: X-CUSTOMER-HEADER
      Access-Control-Max-Age: 1728000
      Vary: Accept-Encoding, Origin
      
      //浏览器判断本次请求被允许,真实发起原先的POST请求
      POST /resources/post-here/ HTTP/1.1
      ...
      X-CUSTOMER-HEADER: 沐心chen
      Origin: http://my.com
      
      //服务器返回数据
      HTTP/1.1 200 OK
      ...
      Access-Control-Allow-Origin: http://my.com
      Vary: Accept-Encoding, Origin

      OPTIONS是一个理论上不应该对服务端数据造成影响的请求方式。响应头Access-Control-Allow-Methods表明服务器可以接受POST, GET, DELETE, UPATE, PATCH, OPTIONS的请求方法,而Access-Control-Max-Age则告诉浏览器本次预请求的有效期为20天,在这段时间内针对该站点的请求都不需要再预先发起OPTIONS请求。

  3. 带凭证的请求

    跨站请求一般而言,是不会携带cookie和其它凭证的,但 CORS 允许这样做。

     var request = new XMLHttpRequest();
     var url = 'http://test.com/users';
    
     function coAccess(){
         if(request)
         {
           request.open('GET', url, true);
           request.withCredentials = true;
           request.onreadystatechange = function(state){
               ...
           };
           request.send(body); 
         }
         ...

    我们在request中将withCredentials设置为true,使得该请求携带cookie和凭证,此时服务端必须在响应头中声明Access-Control-Allow-Credentialstrue,否则响应体将被浏览器忽略。

     //浏览器发起请求,携带cookie信息
     GET /resources/access-control-with-credentials/ HTTP/1.1
     ...
     Origin: http://my.com
     Cookie: rememberMe=沐心chen
    
     //服务端返回,设置了更多cookie
     HTTP/1.1 200 OK
     ...
     Access-Control-Allow-Origin: http://my.com
     Access-Control-Allow-Credentials: true
     Vary: Accept-Encoding, Origin
     Set-Cookie:rememberYou=沐心chen

    值得一提的是,带凭证的请求要求服务端具体设置Access-Control-Allow-Origin的值而不允许使用*,否则响应也会被浏览器忽略。如果一切正常,跨域访问将同时允许cookie的读和写。

上面一直没提的一个响应头属性是 Vary,顺带提及一下,如果我们的跨域方案不需要cookie参与,那么Access-Control-Allow-Origin 是允许设置为 * 的,但如果我们具体的去设置它的允许域名,则需要后端在响应头再设置一个 Vary 参数,值为 Accept-Encoding, Origin ,它告诉浏览器,响应是根据请求头里的Origin的值来返回不同的内容的。

尽管 CORS 需要现代浏览器的支持,但几乎不用关心这个问题,因为大部分目前仍存活的浏览器都有作出实现,对于前端来说可能最多是设置允许携带凭证,其它的工作就解放到后端了。

浏览器支持:

Destop Mobile
IE8+ Android2.1
Chrome4+ Safari3.2
firefox3.5+ 其它
Opera12+ ..
Safari4+ ..

5. 结言

本篇文章仅针对前端跨域解决方案,虽然不同站点间传输数据方案不少,但只有真正的痛点需要拿出来剖析,零零散散的不再赘述,如需了解更多可以自行搜索。后端对跨域的处理也许抽时间还会独立写一篇文章解读,感兴趣的朋友走个关注,眼熟ID,不胜感激。

发现挺多站点自行转载文章,也并没有注明出处,写文章并不容易,希望以后可以在转载的同时注明一下来源,稍微尊重别人的劳动成果,谢谢。