从第一次开始写项目的时候就遇到过跨域问题,主要是在使用Ajax方式请求数据时发生的。一直以来,遇到的情况也不少,在这里做个小总结。
浏览器的跨域
跨域是前端网络层面的一个问题,出现跨域问题的原因浏览器存在一个同源策略,这个安全策略会限制两个不同源的服务之间的数据交互。所以,大多数情况,我们的响应没那么容易拿到。
先看看浏览器发送ajax请求并且接收响应的简化图:
上图的情况就是模拟一次HTTP请求响应的流程,浏览器会在响应结果返回时,按照同源策略校验响应数据。
-
如果响应结果满足同源策略,那么就给到用户。
-
如果响应结果
不满足同源策略,就产生一个跨域错误。
概念很简单,这里关键点是:受到浏览器同源策略的影响,请求能够正常发送,但是响应需要被校验,当校验失败则产生跨域错误。
一、跨域原因
通过浏览器与服务端进行通信,有时候也不会产生跨域错误,前提是同源。
同源的定义在MDN上有明确规定: 如果两个源的URL中协议、域名、和端口都相同,则视为同源,否则不同源。
如果不清楚HTTP和URL相关概念可以参考我主页的前两篇文章。
🌰:
下表给出了与 URL http://store.company.com/dir/page.html 的源进行对比的示例:
所以,只有交互双方同源,才可以正常通信。
二、解决方案
跨域的问题本质上与浏览器相关,但是否跨域,这个问题往往和很多方面有关,例如:
-
是否使用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("服务端启动了");
});
这里主要是将ctx的body,也就是响应体的内容变为一个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>
响应体:
控制台打印:
这里容易被忽略的是回调函数的执行,其实是在响应被解析完成后自动执行的。
一句话总结: JSONP通过script标签,在url上拼接参数,并以带参数的回调函数作为响应返回,在回调函数被执行后完成了数据的跨域交互。
优点: 实现简单。
缺点: script标签只能发送GET请求,且这种方式容易受到XSS攻击。
2.浏览器的解决方案——CORS
CORS叫做跨域资源共享,是一种基于HTTP头的机制,浏览器支持在使用AJAX技术时利用这种机制,以降低跨域HTTP请求的风险。
前面我们说过,由于不同源,浏览器在校验响应数据后,会拒绝接收响应数据。CORS机制就是为了解决这个痛点,让浏览器根据HTTP响应头的某个标志信息,来决定是否真的拒绝。
这个属性就是响应头里的:Access-Control-Allow-Origin : xxxxx,表示服务端允许的请求源。当响应回到浏览器时,浏览器就会检验发出请求的源是否是受服务端认可。
不过光看这个属性,我一直都很不习惯,我还是喜欢这样记忆原理:
- 浏览器对于响应数据的限制会听服务器的,当服务器明确表示同意这次请求时(响应头设置Access..),浏览器才接收响应;当服务器对这次请求不理睬时(未设置上述属性),浏览器不接收这次响应。
理解了CORS的逻辑,我们继续探究一下HTTP请求的类型,因为这也会影响响应头的具体内容。
🌰:
这里我设置了不同的请求头就造成了两个不一样的结果,前一个只请求了一次,另一个请求了两次。
一次是简单请求,一次是有预检的请求。简单请求就是我们理解的普通HTTP请求,预检请求则是正式发送请求前浏览器发送的一次请求。
-
预检请求:
跨源资源共享(CORS)通过发送预检请求(OPTIONS)来检查,服务器是否会允许接下来要发送的真实请求。在预检中,预检请求头包含下一次的HTTP请求方法和下一次真实请求的请求头数据。
这里请求源地址也会被预检请求携带,也就是说,在预检请求发送时,会携带下面三个字段:
- 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时不可以使用*号,强制使用会造成跨域失败。
- JS中使用xhr的话,使用
- 细节二:部分响应头默认获取不到
- 配置
'Access-Control-Expose-Headers' : 'authorization'....,在服务端暴露响应头。
- 配置
只要不是简单请求,那么在正式请求发送前,一定有一次预检请求的发送。
3.设置代理
上面的JSONP思路奇特,平时看起来很简单的CORS其实细节很多,这二者都是和浏览器打交道,并且使用的前提也是请求的服务器在自己手上,毕竟这样才能调整服务端的实现细节。
事实是,经常需要请求第三方服务器资源,这样上述的两种方案都不现实。既然跨域是因为浏览器不接收响应,那么干脆不让浏览器去请求资源,而使用自己搭建的服务器A去请求目标服务器B的资源。
像这样,服务器之间的交互没有浏览器同源策略的限制,很容易就获取了资源,并且自己搭建一个服务器去请求也非常简单,可以使用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方式设置允许跨域,让浏览器拿到了响应。
以上就是有关Ajax技术三种基本的跨域实现方案。
总结
- JSONP的实现
- 服务端的函数体返回
- 前端的请求发送(script)
- CORS方案的细节
- 简单请求
- 预检请求
- 预检请求携带的内容以及服务端的响应
- 请求的细节
- cookie默认不携带
- 特殊响应头默认不暴露
- 代理服务器
- 实现原理
以上就是本期内容,由于笔力有限,可能有许多细节没有讲全,欢迎各位补充,如果这篇文章对你有帮助,那么请给个小赞,这将是我持续创作的动力!