同源策略
什么时候会跨域:协议、域名、端口号三者有一个不同就是跨域。
对跨域的辨析:跨域请求并非是浏览器限制了发起跨站请求,而是请求可以正常发起并到达目标服务器,但是服务器返回的结果会被浏览器拦截。
先来看一下不进行额外处理的跨域请求。
$.ajax({
url: 'http://10.92.191.223:3000/test',
success: function(data){
console.log(data);
}
})
意料之中的是,控制台会报 CORS 跨域的错误。
跨域解决方案
其他跨域方案可参考 10 种跨域解决方案(附终极方案)
JSONP
原理:
<script>
、<link>
、<img>
引入的资源不受同源策略限制,并且动态载入的 script 脚本会自动执行。所以以请求 script 脚本的方式来调用 api 即可(但需要后端配合格式化返回的数据)。优点:兼容性很好。
缺点:
- 只支持 GET 请求它(因为 script 脚本的请求方式就是 GET)。
- 只支持跨域 HTTP 请求,不能解决不同域的两个页面之间如何进行 JavaScript 调用的问题。
实现流程:创建一个
<script>
并载入页面中,src 是跨域的 api 接口地址,但后面需要带上一个标记有回调函数的请求参数,如http://10.92.191.223:3000/test/?callback=handleCallback
。后端接受到请求后需要进行特殊的处理,将回调函数名和数据拼接成一个函数调用的形式返回给前端,如handleCallback({"status": "success", "message": "跨域成功"})
。因为是 script 脚本,所以前端请求到这个脚本后会立即执行这个脚本内容,即调用这个回调函数。
// 前端代码
function jsonp(url, callback) {
// 创建一个唯一的回调函数名称
let fn = Symbol();
// 先在 window 上定义这个回调函数,后端返回数据后会立即执行这个回调函数
window[fn] = function(response) {
try {
callback(response);
} finally {
delete window[fn];
document.body.removeChild(script);
}
};
let script = document.createElement("script");
script.type = 'text/javascript';
// 判断 url 是否已经有其他的查询参数
if(url.indexOf("?") === -1) {
url += `?callback=${fn}`;
} else {
url += `&callback=${fn}`;
}
script.src = url;
document.body.appendChild(script);
}
function handleCallback(data) {
console.log('成功拿到后端返回的数据,并执行回调函数');
console.log(data);
}
// 后端代码
const express = require('express')
const app = express()
app.get('/', (req, res) => res.send('Hello World!'))
app.get('/test', (req, res) => {
res.end('handleCallback({"status": "success", "message": "跨域成功"})');
})
app.listen(3000, () => {
console.log('Example app listening on port 3000!')
})
jQuery 的 JSONP
jQuery 也已经封装好了 JSONP ,而且使用十分简单,不需要我们再去写 JSONP 函数。使用方式如下。
// 前端代码
$.ajax({
url: 'http://10.92.191.223:3000/test',
dataType: 'jsonp', // 指定服务器返回的数据类型
// 不使用 jsonpCallback 指定回调函数名时,jQuery 会生成一个随机串来充当回调函数名,此时直接在 seccess 中处理返回的数据即可
success: function(data){
console.log("请求成功后的回调函数");
console.log(data);
}
})
// 后端代码
app.get('/test', (req, res) => {
res.end(req.query.callback + '({"status": "success", "message": "跨域成功"})');
})
CORS
- 原理:在服务端设置
Access-Control-Allow-Origin
响应头,允许哪些域名可以访问资源。
res.header("Access-Control-Allow-Origin", "*");
与跨源有关的响应头字段
Access-Control-Allow-Origin
:指定了允许访问该资源的外域 URI,设置为通配符 * 则表示所有网站都可以访问该资源。Access-Control-Expose-Headers
:指定浏览器可以使用或读取 response 中的 哪些响应头。在跨域访问时,XMLHttpRequest 对象的 getResponseHeader 方法只能拿到 6 个基本字段:Cache-Control
、Content-Language
、Content-Type
、Expires
、Last-Modified
、Pragma
,如果要获取其他头部信息则需要服务器设置本响应头。Access-Control-Allow-Headers
:指明了实际请求中所允许携带的头部字段。Access-Control-Allow-Methods
:指明了实际请求中所允许使用的 HTTP 方法。Access-Control-Allow-Credentials
:表示是否允许浏览器发送 Cookie。该字段只能设置为 true, 表示服务器明确许可。如果服务器不允许浏览器发送 Cookie,删除该字段即可。Access-Control-Max-Age
:指定了预请求的请求结果能够被缓存多久,在此有效期内可以直接发起实际请求而不用先通过预请求确认。
Cookie 相关
默认情况下 CORS 请求不会携带 Cookie,如果要带 Cookie 的话,则需要满足以下几个要求才行:
- 服务器同意接受 Cookie,设置了
Access-Control-Allow-Credentials: true
字段。 - 服务器的
Access-Control-Allow-Origin
字段不能设置为通配符 *,必须指定为和请求网页一致的域名。 - 请求头需要设置
withCredentials: true
和crossDomain: true
。 - 浏览器 Cookie 依然遵循同源政策,需要将 Domain 属性设置为相应服务器的域名。
简单请求
满足以下两个请求就是简单请求,反之则是非简单请求:
- 请求方法是以下三种方法之一:
HEAD
、GET
、POST
。 - HTTP的头信息不超出以下几个字段:
Accept
、Accept-Language
、Content-Language
、Last-Event-ID
、Content-Type
(只限于这几个值:application/x-www-form-urlencoded
、multipart/form-data
、text/plain
)
- 请求方法是以下三种方法之一:
简单请求的流程:
- 浏览器直接发出 CORS 请求,也就是在请求头之中,增加一个 Origin 字段,表示本次请求来自哪个源(协议 + 域名 + 端口)。服务器根据这个值,决定是否同意这次请求。
- 如果 Origin 指定的源不在许可范围内,服务器会返回一个正常的 HTTP 回应。但浏览器发现响应头中没有包含
Access-Control-Allow-Origin
字段,浏览器就知道发生了跨域错误,于是抛出一个错误被XMLHttpRequest
的onerror
回调函数捕获(也就是我们跨域时在控制台看到的那个报错)。注意这种错误无法通过状态码识别,因为 HTTP 回应的状态码有可能是 200。 - 如果 Origin 指定的域名在许可范围内,服务器返回的响应头就必须包含
Access-Control-Allow-Origin
字段,以及上述一些可选的其他字段。
非简单请求
非简单请求是那种对服务器有特殊要求的请求,比如请求方法是
PUT
或DELETE
,或者Content-Type
字段的类型是application/json
。非简单请求的流程:
- 非简单请求的 CORS 请求会在正式通信之前增加一次 HTTP 查询请求,也就是预请求。浏览器先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些 HTTP 方法和请求头。只有得到肯定答复后浏览器才会发出正式的
XMLHttpRequest
请求,否则就报错。 - 预请求使用的是 OPTIONS 请求方法(用来从服务器获取更多信息,不会对服务器资源产生影响),其中包含了
Origin
、Access-Control-Request-Method
、Access-Control-Request-Headers
等几个请求头,分别表示请求的来源、稍后的 CORS 正式请求使用的请求方法和使用到的请求头。 - 服务器收到预请求以后,基于上述的几个请求头来判断是否接受稍后的实际请求,确认允许本次跨域请求后就作出回应,包括:
Access-Control-Allow-Origin
、Access-Control-Allow-Methods
、Access-Control-Allow-Headers
,以及上述一些其他的可选字段。如果服务器否定了预请求,则返回一个正常的 HTTP 请求,但没有任何 CORS 相关的头信息字段,此时浏览器就会抛出 CORS 请求被拒绝的错误。
- 非简单请求的 CORS 请求会在正式通信之前增加一次 HTTP 查询请求,也就是预请求。浏览器先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些 HTTP 方法和请求头。只有得到肯定答复后浏览器才会发出正式的
WebSocket
- 原理:WebSocket 是一种双向通信协议,在建立连接之后 server 与 client 都能主动向对方发送或接收数据,并且不受同源策略限制。
// 前端代码
<script src="https://cdn.bootcss.com/socket.io/2.3.0/socket.io.js"></script>
window.onload = function(){
var socket = io('ws://localhost:3000');
socket.on('connect', function() {
console.log('客户端和服务端建立起连接');
})
socket.on('onclient', function(params) {
console.log('客户端响应服务端的触发事件', params);
})
socket.emit('onserver', 'onserver');
}
// 后端代码
const http = require('http');
const server = http.createServer();
const socket = require('socket.io')(server);
server.listen(3000, () => {
console.log('Example app listening on port 3000!')
})
socket.on('connection', function (client) {
console.log('服务端监测到有客户端连接');
client.on('onserver', function(params) {
console.log('服务端响应客户端的触发事件', params);
})
client.emit('onclient', 'onclient');
})
中间件服务器
- 原理:同源策略只是对浏览器的限制,服务器之间的请求不受同源策略的影响。所以可以开启一个中间件服务器,前端将 HTTP 请求发送到这个中间服务器上,由这个中间件转发请求到目标服务器上,再将收到的响应数据转发给请求主机。
下面的示例中,目标服务器是 http://10.92.191.223:3000
,中间服务器是 http://10.92.191.223:3001
。
/* 前端请求代码 */
getApi() {
axios({
method: 'get',
url: 'http://10.92.191.223:3001/test',
}).then((res) => {
console.log(res);
}).catch((error) => {
console.log(error);
})
}
/* 中间服务器 */
const express = require('express');
const proxy = require('http-proxy-middleware');
const app = express();
app.all('*', function(req, res, next) {
// 中间服务器需要开启 CORS,否则请求到中间服务器也会有跨域问题
res.header("Access-Control-Allow-Origin", "*");
next();
});
// 路径是 /test 的请求会被转发
app.use('/test', proxy({
target: 'http://10.92.191.223:3000',
changeOrigin: true,
}))
app.listen(3001, () => {
console.log('Example app listening on port 3001!')
})
/* 目标服务器 */
const express = require('express');
const app = express();
app.get('/', (req, res) => res.send('Hello World!'))
app.get('/test', (req, res) => {
res.json({"status": "success", "message": "跨域成功"});
})
app.listen(3000, () => {
console.log('Example app listening on port 3000!')
})