聊聊前端跨域的爱恨情仇

                                                                                  版权声明:欢迎订阅公众号【5厘米的理想】,愿生命里的每一个小理想,都能成为生命里的小确幸。本文地址为: http://www.thescrewshack.com/qinyuanpei/article/details/89080530

                                                                                  今天是过完春节以后的第二周啦,而我好像终于回到正常工作的状态了呢,因为突然间就对工作产生了厌倦的情绪,Bug就像无底洞一样吞噬着我的脑细胞。人类就像一颗螺丝钉一样被固定在整部社会机器上,除了要让自己看起来像个正常人一样,还要拼命地让所有人都像个正常人一样。过年刚经历过被催婚的我,面对全人类近乎标准的“幸福”定义,大概就是我此刻这种状态。其实,除了想自己定义“幸福”以外,我还想自己定义“问题”,因为,这样就不会再有“Bug”了。言归正传,今天我想说的是前端跨域这个话题,相信读完这篇文章,你就会明白,这个世界上太多太多的问题,都和你毫无瓜葛。

                                                                                  故事缘起

                                                                                  年前被安排去做一个GPS相关的需求,需要通过百度地图API来计算预计到达时间,这并不是一个有难点的需求,对吧?就在博主为此而幸灾乐祸的时候,一个非常醒目的错误出现在Chrome的控制台中,相信大家都见过无数次啦,大概是说我们的请求受到浏览器的同源策略的限制。那么,第一个问题,什么是同源策略呢?我们知道,一个URL通常有以下几部分组成,即协议、域名、端口和请求资源。由此我们就可以引申出同源的概念,当协议、域名和端口都相同时,就认为它们是在同一个域下,即它们同源。相反地,当协议、域名和端口中任意一个都不相同时,就认为它们在不同域下,此时就发生了跨域。按照排列组合,我们可以有以下常见的跨域场景:

                                                                                  URL 说明 是否允许跨域
                                                                                  www.abc.com/a.js vs www.abc.com/b.js 相同域名下的不同资源 允许
                                                                                  www.abc.com/1/a.js vs www.abc.com/2/b.js 相同域名下的不同路径 允许
                                                                                  www.abc.com:8080/a.js vs www.abc.com:8081/b.js 相同域名下的不同端口 不允许
                                                                                  http://www.abc.com vs https://www.abc.com 相同域名采用不同协议 不允许
                                                                                  http://www.abc.com vs http://wtf.abc.com 相同域名下的不同子域 不允许
                                                                                  http://www.abc.com vs http://www.xyz.com 两个完全不同的域名 不允许
                                                                                  http://192.168.100.101 va http://www.wtf.com 域名及其对应的IP地址 不允许

                                                                                  那么,我们就不仅要问啦,现在微服务啊、RESTful啊这些概念非常流行,在我们实际的工作中,调用第三方的WebAPI甚至WebService,这难道不是非常合理的场景吗?前端的Ajax,即XMLHttpRequest,和我们平时用到的RestSharp、HttpClient、OkHttp等类似,都可以发起一个Http请求,怎么在客户端里用的好好的东西,到了前端这里就突然出来一个**“跨域”的概念呢?这是因为从原理上来说,这些客户端都是受信的“用户”(好吧,假装是被信任的),而浏览器的环境则是一个“开放”**的环境。

                                                                                  URI_Syntax_Diagram

                                                                                  举一个例子,你在家的时候,可以随意地把手插进自己的口袋,因为这是你的私有环境。可是当你在公共环境中时,你是不允许把手插进别人口袋的。所以,浏览器有“跨域”限制,本质上是为了保护用户的数据安全,避免危险地跨域行为。试想,没有跨域的话,我们带上Cookie就可以为所欲为了,不是吗?实际上,同源限制和JavaScript没有一丁点关系,因为它是W3C中的内容,是浏览器厂商要这样做的,我们的请求其实是被发出去了,而它的响应则被浏览器给拦截了,所以我们在控制台中看到“同源策略限制”的错误。

                                                                                  喜闻乐见的跨域拦截

                                                                                  十八般武艺

                                                                                  好了,既然现在浏览器有这个限制,那为了客户着想,我们还是要去解决这个问题(对吧?),虽然我至今想不明白,适配浏览器为什么会成为我们的工作之一[doge]。打开Google搜索“前端跨域”,于是发现了解决跨域问题的各种方案,这里选取最具代表性的JSONP和CORS。

                                                                                  JSONP

                                                                                  首先,我们来说说JSONP,什么是JSONP呢?我们知道,通常RESTful接口返回的都是JSON,而JSONP返回的是一段可以执行的JavaScript代码,我们所需要的数据就被“包裹”在这段代码中,这就是JSONP,即JSON Padding的得名由来。在实际应用中,服务的提供方会根据调用方传入的回调函数(callback)来组织返回数据,譬如callback({“name”:“tom”,“gender”:“male”})。这就说到一个点,并不是所有的API接口在调用的时候出现跨域问题,都可以通过JSONP的方式来解决,因为它需要后端来配合组织返回数据。这里我们以“不蒜子”这个静态博客中使用最多的访问量统计工具为例,通过查看页面源代码,我们了解到它是通过JSONP来返回数据的。为什么它要用这种方式来返回数据呢?其实,我们仔细想想就能明白其中的缘由,因为像Hexo、Jekyll这种静态博客大多都是没有后端服务支持的,所以,它要访问“不蒜子”的统计服务,就必然会存在跨域的问题啊!那怎么解决这个问题呢?当然是选择JSONP啦!这里我们以Postman调用不蒜子接口为例,可以发现它的返回值是下面这个样子:

                                                                                  在Postman中调用"不蒜子"接口

                                                                                  博主计划在接下来的时间里,迁移不蒜子的统计数据到LeanCloud上,届时博主会使用最喜欢的Python,来抓取这些访问量数据,因为JSONP返回的都不是JSON数据,因此再处理这些数据的时候,需要用正则来匹配这些结果。为什么在前端领域没有这些问题呢,因为JSONP返回的是世界上最**“任性”**的语言——JavaScript,当然,这些会是下一篇甚至下下一篇里的内容啦。

                                                                                  CORS

                                                                                  好了,下面我们说说CORS这种方案。CORS,即跨域资源共享,是一种利用HTTP头部信息访问不同域下的资源的机制。我们在前面提到过,发生跨域访问时,其实请求已经发出去了,但响应则被浏览器给拦截住了。那么,CORS说白了就是它可以通过HTTP头部信息,告诉浏览器来自哪些域的请求可以被允许,来自哪些域的请求应该被禁止。如果说JSONP多少带着点“hack”的意味儿,那么CORS就可以说是被官方认可的跨域解决方案啦!这种方案需要启用新的HTTP头部字段,具体可以参考这里

                                                                                  按照定义,浏览器会将CORS请求分为简单请求非简单请求两类。对于简单请求,浏览器会对请求的头部进行“魔改”,即增加一个Origin字段,这样只要后端接口支持CORS跨域,就可以接收这些跨域请求,并做出回应,即在响应的头部信息中返回Access-Control-Allow-Origin等字段。而对于非简单请求,通常会先发出一个OPTIONS的“预检请求”,只有这个验证过程通过以后,主请求才会被发起。那么浏览器是怎么验证请求是否通过的呢?答案就是:检查Origin字段是否包含在Access-Control-Allow-Origin中。当验证不通过时,浏览器就会输出同源策略限制的错误。这就是CORS,浏览器和服务端分别通过响应、请求的HTTP头部信息来**“商量”**要不要跨域。

                                                                                  没有银弹

                                                                                  说了这么多关于“跨域”的话题,其实博主想说的是,没有银弹。这是一位前辈高人,曾经对博主反复说过的话。现在我们来看JSONP,会发现它本质上是利用了浏览器的**“漏洞”**。为什么这样说呢?因为在浏览器中,所有具备src属性的HTML都是可以跨域的,譬如script、img、iframe、link这四个标签,我们赖以生存的CDN加速、图床、插件等等都是基于这一“漏洞”的产物。所以,很多人问为什么$.ajax可以跨域,但原生的XMLHttpRequest则不可以呢?因为jQuery实际上把JSONP做成了一种语法糖,这就就会给人一种ajax可以跨域的错觉。

                                                                                  JSONP?其实就是JS

                                                                                  JSONP实际上返回的是可以执行的JavaScript,即text/javascript,它和我们所使用的大多数JavaScript并无区别,所以,你可以想到,当我们把一个远程地址赋值给script标签的src属性时,它和我们引用CDN上的医院文件并无区别,这正是JSONP的秘密所在,显然它只支持Get方式,当我们想要支持更多方式的时候,我们需要的是CORS,一起来看下面这段代码,我们首先来写一个简单的API接口:

                                                                                  // GET api/user/5?callback=
                                                                                  [HttpGet("{id}")]
                                                                                  public IActionResult Get(string id, string callback)
                                                                                  {
                                                                                      var userInfo = UserInfoService.Find(x => x.UserId == id);
                                                                                      if (userInfo == null) return NotFound();
                                                                                      if (string.IsNullOrEmpty(callback))
                                                                                      {
                                                                                          //返回JSON
                                                                                          Response.ContentType = "application/json";
                                                                                          return Json(userInfo);
                                                                                      }
                                                                                      else
                                                                                      {
                                                                                          //返回JSPNP
                                                                                          Response.ContentType = "application/javascript";
                                                                                          return Content($"{callback}({JsonConvert.SerializeObject(userInfo)})");
                                                                                      }
                                                                                  }
                                                                                  

                                                                                  OK,写完这个接口以后,我们首先来尝试在前端页面中调用这个接口,为了尽可能地减少依赖,我们这里用最新Fetch API来代替$.ajax(),毕竟现在都是2019年了呢,Github和Bootstrap相继宣布从代码中移除jQuery。大家都知道,原生的xhr和Date对象一样,简直难用得要命,而这一切在新的Fetch API下,会变得非常简单:

                                                                                  //基于Fecth API调用JSONP
                                                                                  showUserByFetch:function(){
                                                                                        fetch("https://localhost:5001/api/user/1")
                                                                                          .then(function(response) {
                                                                                            return response.json();
                                                                                          })
                                                                                          .then(function(user) {
                                                                                            showUser(user);
                                                                                          });
                                                                                  }
                                                                                  

                                                                                  果然,就算使用最新的Fetch API,浏览器还是会因为同源限制策略而拦截我们的请求

                                                                                  浏览器中再次出现同源限制错误

                                                                                  那么,试试用JSONP的思路来解决这个问题。注意到,为了兼容JSONP方式调用,我们在API接口中增加了一个callback参数,这个参数实际上就是预先在客户端中定义好的方法的名字啦!既然JSONP返回的是可执行的JavaScript,那么我们在页面里增加一个Script标签好了:

                                                                                  <script src="https://localhost:5001/api/user/1?callback=showUser"></script>
                                                                                  

                                                                                  其中,showUser是一个预先定义好的JS函数,其作用是输出用户信息到页面上:

                                                                                  //展示用户信息
                                                                                  function showUser(user){
                                                                                    var result = document.getElementById('jsonp-result');
                                                                                    result.innerText = '用户ID:' + user.uid + ", 姓名:" + user.name + ', 性别:' + user.gender;
                                                                                  }
                                                                                  

                                                                                  现在,我们可以注意到,在控制台中输出了我们期望的结果,这说明页面中定义的showUser()方法确实被执行了,所以,到这里我们可以对JSONP做一个简单总结:JSONP是一种利用script标签实现跨域的方案,它需要对后端接口进行适当改造以返回可以执行的JavaScript,客户端需要事先定义好接收数据的方法,两者通过callback参数建立起联系,返回类似callback({“name”:“tom”,“gender”:“male”})结构的数据,因此JSONP请求必然且只能是一个GET请求

                                                                                  通过Script标签调用JSONP

                                                                                  既然通过Script标签可以调用一个JSONP接口,那么我们不妨试试动态创建Script标签,然后你就会发现这两种方式的效果是一样的,都可以调用一个JSONP接口,前提是JS中已经存在showUser()方法:

                                                                                  //动态创建scipt调用JSONP
                                                                                  showUserByDynamic:function(){
                                                                                        var self = this;
                                                                                        var script = document.createElement("script");
                                                                                        script.src = "https://localhost:5001/api/user/1?callback=showUser"; 
                                                                                        document.body.appendChild(script); 
                                                                                  },
                                                                                  

                                                                                  事实上,jQuery中针对JSONP的支持正是基于这种原理,虽然jQuery的时代终将过去,可我相信这些背后的原理永远不会过时。顺着这个思路,我们不妨来看看jQuery中是如何实现JSONP的,以下代码可以在这里找到:

                                                                                  // Bind script tag hack transport
                                                                                  jQuery.ajaxTransport("script",
                                                                                  function(s) {
                                                                                  
                                                                                      // This transport only deals with cross domain or forced-by-attrs requests
                                                                                      if (s.crossDomain || s.scriptAttrs) {
                                                                                          var script, callback;
                                                                                          return {
                                                                                              send: function(_, complete) {
                                                                                                  script = jQuery("<script>").attr(s.scriptAttrs || {}).prop({
                                                                                                      charset: s.scriptCharset,
                                                                                                      src: s.url
                                                                                                  }).on("load error", callback = function(evt) {
                                                                                                      script.remove();
                                                                                                      callback = null;
                                                                                                      if (evt) {
                                                                                                          complete(evt.type === "error" ? 404 : 200, evt.type);
                                                                                                      }
                                                                                                  });
                                                                                  
                                                                                                  // Use native DOM manipulation to avoid our domManip AJAX trickery
                                                                                                  document.head.appendChild(script[0]);
                                                                                              },
                                                                                              abort: function() {
                                                                                                  if (callback) {
                                                                                                      callback();
                                                                                                  }
                                                                                              }
                                                                                          };
                                                                                      }
                                                                                  });
                                                                                  

                                                                                  可以注意到,它和我们这里的思路一致,即动态创建一个script标签,然后设置其src属性为目标地址,当其加载完成或者加载失败时,就会从页面的DOM节点中删除该标签,因为数据已经通过指定的callback处理过了。jQuery甚至可以替我们生成对应的callback函数,例如,在这里我们可以这样使用jQuery来实现JSONP跨域,具体使用细节这里不再深究:

                                                                                  //基于$.ajax()调用JSONP
                                                                                  showUserByAjax:function(){
                                                                                        $.ajax({
                                                                                              type: "get",
                                                                                              url: "http://localhost:5000/api/user/1",
                                                                                              dataType: "jsonp",
                                                                                              jsonp: "callback",
                                                                                              data: "",
                                                                                              success: function (user) {
                                                                                                  showUser(user);
                                                                                              }
                                                                                          });
                                                                                  },
                                                                                  

                                                                                  CORS,跨域新标准?

                                                                                  相对JSONP来说,CORS实现起来就非常简单啦,因为主流的Web框架中几乎都提供了CORS的支持,因为CORS可以实现除了GET以外的譬如POST、PUT等请求,所以,它比JSONP这种”Hack“的方式有更为广阔的适用性,而且随着Web标准化的不断推荐,目前CORS可以说是官方主推的跨域方案。这里我们以.NET Core为例来讲解CORS跨域。

                                                                                  CORS,即同源资源共享,其实早在ASP.NET时代,这一机制就已经得到了支持,现在我们以.NET Core来讲,无非是希望大家放下历史包袱,在跨平台的新道路上轻装上阵。好了,在.NET Core中我们有两种CORS方案,一种是在Startup类中以全局配置的方式注入到整个中间件管道中,一种是以特性的方式在更小的粒度上控制CORS。这其实和之前配置路由的思路相近,即我们可以配置全局的路由模板,同样可以在Controller和Action级别上定义路由。在这里,我们先定义两种CORS策略,AllowAll和AllowOne,并以此来测试CORS实际的使用效果。

                                                                                  //CORS策略:简单粗暴一刀流
                                                                                   services.AddCors(opt=>{
                                                                                      opt.AddPolicy("AllowAll", builder => {
                                                                                          builder.AllowAnyOrigin();
                                                                                          builder.AllowAnyHeader();
                                                                                          builder.AllowAnyMethod();
                                                                                      });
                                                                                   });
                                                                                  
                                                                                  //CORS策略:允许指定域
                                                                                  services.AddCors(opt=>{
                                                                                      opt.AddPolicy("AllowOne", builder => {
                                                                                          builder.WithOrigins("http://localhost:8888")
                                                                                              .AllowAnyHeader()
                                                                                              .AllowAnyMethod()
                                                                                              .WithExposedHeaders("X-ASP-NET-Core","X-UserName")
                                                                                              .AllowCredentials();
                                                                                      });
                                                                                  });
                                                                                  

                                                                                  可以注意到,在全局范围内应用AllowAll以后,我们的后端接口将支持来自任意域/端口的跨域访问,这意味着我们之前必须使用JSONP来跨域的地方,现在都可以直接发起跨域请求。到底是不是和我们想得一样呢?答案啊,那必须是肯定的啊!

                                                                                  [EnableCors("AllowOne")]
                                                                                  [Route("api/[controller]")]
                                                                                  [ApiController]
                                                                                  public class UserController:Controller
                                                                                  {
                                                                                  	//...
                                                                                  }
                                                                                  

                                                                                  好了,现在我们来测试在UserController上应用局部的CORS请求,在这个实例中,我们指定只有来自localhost:8888的请求可以跨域,为此博主这里用Python临时开了一个服务器,本文中的前端页面,实际上就是运行在这个服务器上的。你知道我想说什么,“人生苦短,我用Python”。因为我们这里返回的是application/json,所以它是一个非简单请求,这里复习一下简单请求与非简单请求。

                                                                                  简单请求

                                                                                  根据MDN中关于CORS的定义,若请求满足所有下述条件,则该请求可视为“简单请求”,简单请求意味着不会触发CORS 预检请求

                                                                                  • 使用下列方法之一:GET、HEAD、POST。
                                                                                  • Fetch 规范定义了对 CORS 安全的首部字段集合,不得人为设置该集合之外的其他首部字段。该集合为:Accept、Accept-Language、Content-Language、Content-Type (需要注意额外的限制)、DPR、Downlink、Save-Data、Viewport-Width、Width。
                                                                                  • Content-Type 的值仅限于下列三者之一:text/plain、multipart/form-data、application/x-www-form-urlencoded。

                                                                                  MDN中对简单请求的图解

                                                                                  非简单请求

                                                                                  非简单请求和简单请求相反,即不满足简单请求中任一条件的请求都被成为非简单请求。非简单请求,相对简单请求多了一次CORS 预检请求。其过程是,首先由浏览器自动发起一个OPTION请求,该请求中携带HTTP头部字段Origin。在本例中,前端页面部署在http://localhost:8888服务器上,所以,它的Origin字段即为http://localhost:8888。接下来,服务端会返回Access-Control-Allow-Origin/Access-Control-Allow-Headers/Access-Control-Allow-Methods等字段,它对应我们后端定义的AllowOne,注意到这里我们有两个自定义字段X-ASP-NET-Core和X-UserName。在通过预检以后,我们在发起正式请求(本例中为GET请求)的时候,设置后端允许的源,即http://localhost:8888,这样就可以实现基于CORS的跨域请求啦!

                                                                                  MDN中对非简单请求的图解

                                                                                  所以,我们可以注意到,这里会有一个OPTION请求,即“预检请求”。对于AllowOne这个CORS策略而言,它允许来自localhost:8888的跨域请求,允许的请求方法有GET、PUT、POST和OPTION,客户端必须携带一个自定义HTTP头:X-ASP-NET-Core。当这三个条件满足时,即表示通过“预检”。此时,服务端会返回Access-Control-Allow-Origin/Access-Control-Allow-Methods/Access-Control-Allow-Headers等字段。接下来,浏览器发起的正式请求会带上这些字段,并返回我们所需要的JSON数据,这就是CORS跨域的实际过程。

                                                                                  OPTION预检请求

                                                                                  本文小结

                                                                                  这篇文章主要梳理了目前前端跨域的两种主流方案(事实上,在奇葩的前端领域里,最不缺的就是解决方案),即JSONP和CORS。其中,JSONP本质上是返回可以执行的JS,其基本套路是callback({“foo”:“bar”}),利用了HTML中含src的属性天生具备跨域能力的“漏洞”,是一种相对"hack"的方案,要求预先定义好callback,需要改造后端接口,仅支持最简单的GET请求。而CORS,是比较“官方”的跨域解决方案,其原理是利用HTTP头部字段对请求的来源进行检验,CORS支持除GET以外的请求动词,在使用中间件的情况下,无需修改后端接口,可以在全局或者局部配置CORS跨域策略,对后端开发相对友好。自从接触前端领域,对这个领域里的“黑科技”、“骚操作”吐槽无数次了,不过,前后端分离过程中这些事情还是挺有意思的,对吧?好了,以上就是这篇博客里的全部内容了,欢迎大家吐槽!本文中的示例请从:https://github.com/qinyuanpei/dotnet-sse/blob/master/server/index.html这里来获取,谢谢大家!

                                                                                  展开阅读全文

                                                                                  没有更多推荐了,返回首页