返回

聊聊前端跨域的爱恨情仇

今天是过完春节以后的第二周啦,而我好像终于回到正常工作的状态了呢,因为突然间就对工作产生了厌倦的情绪,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
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中调用不蒜子接口
在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 接口:

 1// GET api/user/5?callback=
 2[HttpGet("{id}")]
 3public IActionResult Get(string id, string callback)
 4{
 5    var userInfo = UserInfoService.Find(x => x.UserId == id);
 6    if (userInfo == null) return NotFound();
 7    if (string.IsNullOrEmpty(callback))
 8    {
 9        //返回JSON
10        Response.ContentType = "application/json";
11        return Json(userInfo);
12    }
13    else
14    {
15        //返回JSPNP
16        Response.ContentType = "application/javascript";
17        return Content($"{callback}({JsonConvert.SerializeObject(userInfo)})");
18    }
19}

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

 1//基于Fecth API调用JSONP
 2showUserByFetch:function(){
 3      fetch("https://localhost:5001/api/user/1")
 4        .then(function(response) {
 5          return response.json();
 6        })
 7        .then(function(user) {
 8          showUser(user);
 9        });
10}

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

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

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

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

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

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

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

通过Script标签调用JSONP
通过Script标签调用JSONP

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

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

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

 1// Bind script tag hack transport
 2jQuery.ajaxTransport("script",
 3function(s) {
 4
 5    // This transport only deals with cross domain or forced-by-attrs requests
 6    if (s.crossDomain || s.scriptAttrs) {
 7        var script, callback;
 8        return {
 9            send: function(_, complete) {
10                script = jQuery("<script>").attr(s.scriptAttrs || {}).prop({
11                    charset: s.scriptCharset,
12                    src: s.url
13                }).on("load error", callback = function(evt) {
14                    script.remove();
15                    callback = null;
16                    if (evt) {
17                        complete(evt.type === "error" ? 404 : 200, evt.type);
18                    }
19                });
20
21                // Use native DOM manipulation to avoid our domManip AJAX trickery
22                document.head.appendChild(script[0]);
23            },
24            abort: function() {
25                if (callback) {
26                    callback();
27                }
28            }
29        };
30    }
31});

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

 1//基于$.ajax()调用JSONP
 2showUserByAjax:function(){
 3      $.ajax({
 4            type: "get",
 5            url: "http://localhost:5000/api/user/1",
 6            dataType: "jsonp",
 7            jsonp: "callback",
 8            data: "",
 9            success: function (user) {
10                showUser(user);
11            }
12        });
13},

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 实际的使用效果。

 1//CORS策略:简单粗暴一刀流
 2 services.AddCors(opt=>{
 3    opt.AddPolicy("AllowAll", builder => {
 4        builder.AllowAnyOrigin();
 5        builder.AllowAnyHeader();
 6        builder.AllowAnyMethod();
 7    });
 8 });
 9
10//CORS策略:允许指定域
11services.AddCors(opt=>{
12    opt.AddPolicy("AllowOne", builder => {
13        builder.WithOrigins("http://localhost:8888")
14            .AllowAnyHeader()
15            .AllowAnyMethod()
16            .WithExposedHeaders("X-ASP-NET-Core","X-UserName")
17            .AllowCredentials();
18    });
19});

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

1[EnableCors("AllowOne")]
2[Route("api/[controller]")]
3[ApiController]
4public class UserController:Controller
5{
6	//...
7}

好了,现在我们来测试在 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中对简单请求的图解
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中对非简单请求的图解
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预检请求
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这里来获取,谢谢大家!

Built with Hugo
Theme Stack designed by Jimmy