浏览器在Ajax技术层面的跨域问题和解决方案

648 阅读9分钟

从第一次开始写项目的时候就遇到过跨域问题,主要是在使用Ajax方式请求数据时发生的。一直以来,遇到的情况也不少,在这里做个小总结。

浏览器的跨域

跨域是前端网络层面的一个问题,出现跨域问题的原因浏览器存在一个同源策略,这个安全策略会限制两个不同源的服务之间的数据交互。所以,大多数情况,我们的响应没那么容易拿到。

先看看浏览器发送ajax请求并且接收响应的简化图:

image.png

上图的情况就是模拟一次HTTP请求响应的流程,浏览器会在响应结果返回时,按照同源策略校验响应数据。

  • 如果响应结果满足同源策略,那么就给到用户。

  • 如果响应结果满足同源策略,就产生一个跨域错误。

image.png

概念很简单,这里关键点是:受到浏览器同源策略的影响,请求能够正常发送,但是响应需要被校验,当校验失败则产生跨域错误。

一、跨域原因

通过浏览器与服务端进行通信,有时候也不会产生跨域错误,前提是同源

同源的定义在MDN上有明确规定: 如果两个源的URL中协议、域名、和端口都相同,则视为同源,否则不同源。

如果不清楚HTTP和URL相关概念可以参考我主页的前两篇文章。

🌰: 下表给出了与 URL http://store.company.com/dir/page.html 的源进行对比的示例:

image.png

所以,只有交互双方同源,才可以正常通信。

二、解决方案

跨域的问题本质上与浏览器相关,但是否跨域,这个问题往往和很多方面有关,例如:

  • 是否使用HTML标签?

  • 是否使用Ajax技术?

  • 是否携带cookie?

  • 是否通过特殊手段访问其他URL(非用户点击)?

为了明确主线,本文主要围绕Ajax技术讨论跨域问题。

1.早期解决方案——JSONP

在引入Ajax技术之前,跨域问题就已经困扰着开发者。而且当时的浏览器层面还没有对应的解决方案,人为开发的JSONP方案就出现了。

JSONP方案借助<script></script>通信不受浏览器同源策略影响的特点,向服务端请求数据。

首先,我们先搭建一个简易的后端服务器:

const Koa = require("koa");
const Router = require("koa-router");
const app = new Koa();
const router = new Router();

//定义路由
router.get('/getDataByJSONP', (ctx) => {
    const callback = ctx.query.callback || "callback";
    //其他参数的解析...
    const data = {
        message: "详细的数据",
    };
    //设置json格式,方便后续操作
    ctx.type = "application/json";
    ctx.body = `${callback}(${JSON.stringify(data)})`;
});

// 将router中间件添加到app中
app.use(router.routes());

app.listen(3000, () => {
    console.log("服务端启动了");
});

这里主要是将ctxbody,也就是响应体的内容变为一个JSON格式的字符串,传递给浏览器。并且这个响应体的内容是一个函数的调用,只要前端执行该函数,就能够得到对应的内容。

另一部分,我们需要考虑的就是如何调用函数?

很简单,封装一个函数,能够自动执行,并且根据url使用script标签发送请求。

需要注意的细节:

  • 回调函数需要挂载在window对象上以便被访问
  • 回调函数的销毁
  • 明确回调函数的执行时机
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>jsonp</title>
</head>
<body>
    <script>
        //向localhost:3000/getDataByJSONP接口发送请求
        function request(url,data) {  
            return new Promise(resovle => {
                window[callbackName] = function (res){
                    resovle(res);
                    script.remove(script);
                    delete window[callbackName];
                }
                const script = document.createElement('script');
                //这里处理拼接函数名,还可以携带一些参数
                script.src = url + `?callback=${callbackName}`;
                //这里将会触发请求
                document.body.appendChild(script);
            })    
        }
        request("http://localhost:3000/getDataByJSONP","printMessage").then(res => {
            console.log(res);
        })
    </script>
</body>
</html>

响应体:

image.png


控制台打印:


image.png


这里容易被忽略的是回调函数的执行,其实是在响应被解析完成后自动执行的。

一句话总结: JSONP通过script标签,在url上拼接参数,并以带参数的回调函数作为响应返回,在回调函数被执行后完成了数据的跨域交互。

优点: 实现简单。

缺点: script标签只能发送GET请求,且这种方式容易受到XSS攻击。

2.浏览器的解决方案——CORS

CORS叫做跨域资源共享,是一种基于HTTP头的机制,浏览器支持在使用AJAX技术时利用这种机制,以降低跨域HTTP请求的风险。

前面我们说过,由于不同源,浏览器在校验响应数据后,会拒绝接收响应数据。CORS机制就是为了解决这个痛点,让浏览器根据HTTP响应头的某个标志信息,来决定是否真的拒绝。

这个属性就是响应头里的:Access-Control-Allow-Origin : xxxxx,表示服务端允许的请求源。当响应回到浏览器时,浏览器就会检验发出请求的源是否是受服务端认可。

不过光看这个属性,我一直都很不习惯,我还是喜欢这样记忆原理:

  • 浏览器对于响应数据的限制会听服务器的,当服务器明确表示同意这次请求时(响应头设置Access..),浏览器才接收响应;当服务器对这次请求不理睬时(未设置上述属性),浏览器不接收这次响应。

理解了CORS的逻辑,我们继续探究一下HTTP请求的类型,因为这也会影响响应头的具体内容。

🌰:

image.png

image.png

这里我设置了不同的请求头就造成了两个不一样的结果,前一个只请求了一次,另一个请求了两次。

一次是简单请求,一次是有预检的请求。简单请求就是我们理解的普通HTTP请求,预检请求则是正式发送请求前浏览器发送的一次请求。

  • 预检请求

    跨源资源共享(CORS)通过发送预检请求(OPTIONS)来检查,服务器是否会允许接下来要发送的真实请求。在预检中,预检请求头包含下一次的HTTP请求方法下一次真实请求的请求头数据


image.png

这里请求源地址也会被预检请求携带,也就是说,在预检请求发送时,会携带下面三个字段:

  • Access-Control-Request-Method
  • Access-Control-Request-Headers
  • Origin

而既然有预检请求的发送,服务端也应该处理对应的请求,这里状态码为204表示这个预检请求被服务器同意了。

我在服务端写了如下逻辑:

router.options("/getDataByCors", (ctx) => {
    // 处理预检请求
    ctx.set({
        //对应预检请求带上的数据
        'Access-Control-Allow-Origin': 'http://127.0.0.1:5500',
        //服务端同意的请求方法
        'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
        //表示同意请求头携带的内容
        'Access-Control-Allow-Headers': 'Content-Type',
        //表示预检请求结果的缓存周期
        'Access-Control-Max-Age': '86400' 
    });
    ctx.status = 204; // 无内容响应
});

预检请求完成后就是简单请求的发送,也需要处理:

router.get("/getDataByCors", (ctx) => {
    const data = {
        message: "响应的数据"
    };

    ctx.type = 'application/json';
    ctx.set({
        'Access-Control-Allow-Origin': 'http://127.0.0.1:5500'
    });
    ctx.body = JSON.stringify(data);
});

响应时分别响应预检请求和简单请求,特别的,对于响应预检时要将下面三个属性设置好:

  • Access-Control-Allow-Origin
  • Access-Control-Allow-Method
  • Access-Control-Allow-Headers

接下来,我们了解一下如何区分这两种请求,因为除了简单请求,剩下的就是需要预检的请求了。

简单请求满足的三个主要条件:

  • 1.请求方法是GET POST HEAD之一
  • 2.头部字段满足CORS规范,一般不改动或新增头部就不会打破这个规则
  • 3.如果请求头有Content-Type字段,必须是:
    • text/plain
    • multiplart/form-data
    • application/x-www-form-urlencoded

还有几个不常见的就没列出来了,个人觉得用到了可查阅资料。

上面的内容说明了简单请求和预检请求在HTTP请求响应过程的区别,那么HTTP请求的细节还有哪些呢?

  • 细节一:对于Ajax的跨域请求(简单或者预检),都不会携带cookie
    • JS中使用xhr的话,使用xhr.withCredentials = true;设置带凭证。
    • JS中使用fetch的话,在参数中添加credentials : "include"的键值对。
    • HTTP响应头要加"Access-Controls-Allow-Credentials" : "true",这样表示服务器允许请求携带cookie。
    • 对于附带身份凭证的请求,服务端设置使用Access-Control-Allow-Origin时不可以使用*号,强制使用会造成跨域失败。
  • 细节二:部分响应头默认获取不到
    • 配置'Access-Control-Expose-Headers' : 'authorization'....,在服务端暴露响应头。

只要不是简单请求,那么在正式请求发送前,一定有一次预检请求的发送。

3.设置代理

上面的JSONP思路奇特,平时看起来很简单的CORS其实细节很多,这二者都是和浏览器打交道,并且使用的前提也是请求的服务器在自己手上,毕竟这样才能调整服务端的实现细节。

事实是,经常需要请求第三方服务器资源,这样上述的两种方案都不现实。既然跨域是因为浏览器不接收响应,那么干脆不让浏览器去请求资源,而使用自己搭建的服务器A去请求目标服务器B的资源。

image.png

像这样,服务器之间的交互没有浏览器同源策略的限制,很容易就获取了资源,并且自己搭建一个服务器去请求也非常简单,可以使用axios库。

router.get("/getHeroList", async (ctx) => {
    const axios = require('axios');
    const res = await axios.get('https://game.gtimg.cn/images/lol/act/img/js/heroList/hero_list.js?ts=2887581')
    ctx.set({
        'Access-Control-Allow-Origin': 'http://127.0.0.1:5500',
    });
    ctx.type = 'text/javascript';
    ctx.body = res.data;
});

这里我使用axios做了一个代理请求,拿到了英雄联盟的官网数据,再通过CORS方式设置允许跨域,让浏览器拿到了响应。

image.png

以上就是有关Ajax技术三种基本的跨域实现方案。

总结

  • JSONP的实现
    • 服务端的函数体返回
    • 前端的请求发送(script)
  • CORS方案的细节
    • 简单请求
    • 预检请求
      • 预检请求携带的内容以及服务端的响应
    • 请求的细节
      • cookie默认不携带
      • 特殊响应头默认不暴露
  • 代理服务器
    • 实现原理

以上就是本期内容,由于笔力有限,可能有许多细节没有讲全,欢迎各位补充,如果这篇文章对你有帮助,那么请给个小赞,这将是我持续创作的动力!

参考: 跨源资源共享(CORS) - HTTP | MDN