前言
什么是跨域?跨域的来由?以及有哪几种跨域方式?学习本文将收获以下几个知识点:
- 什么是跨域,怎么产生跨域,以及常见的几种解决方案。
什么是跨域?
了解跨域前,先认识下同源政策
所谓"同源"指的是"三个相同"。
- 协议相同
- 域名相同
- 端口相同
什么是协议、域名、端口?举例来说:'juejin.cn:8080/a.html' 这个网站,协议是 https://, 域名是 juejin.cn, 端口是 8080(默认端口80可以省略)它的同源情况如下。
https://juejin.cn:8080/b.html:同源http://juejin.cn:8080/a.html:不同源(协议不同)https://juejin.con:8080/a.html:不同源(域名不同)https://juejin.cn/a.html:不同源(端口不同)
如果非同源就形成了跨域,跨域共有三种行为受到限制。
(1) Cookie、LocalStorage 和 IndexDB 无法读取。
(2) DOM 无法获得。
(3) AJAX 请求不能发送。
详细产生跨域的流程
为了方便进一了解跨域,我将用代码演示下产生跨域的完整流程。
1.构建项目
为了方便我将使用vite快速构建一个react + ts 项目
yarn create vite
项目结构是这样的
2.模拟一个接口
在src/App.tsx下加入一段请求代码
App.tsx
useEffect(() => {
fetch("http://localhost:8001/test1")
.then((response) => {
return response.json();
})
.then((res) => {
console.log(res);
});
}, []);
运行项目都发现将会报错,因为没有服务器接受此接口
好了接下来我们将使用node写个服务,用于接收/test1接口
在根目录下创建testserver.js文件
testserver.js 代码
testserver.js
const http = require("http");
const PORT = 8001;
const server = http.createServer((request, response) => {
// 设置返回格式
response.setHeader("Content-type", "application/json");
const url = request.url.split("?")[0];
console.log(`我进来了, url: ${url}`);
if (url === "/test1") {
return response.end(
JSON.stringify({
code: 0,
data: {},
})
);
}
// 处理404
response.writeHead(404, { "content-type": "text/plain" });
response.write("404 Not Foundd");
response.end();
});
server.listen(PORT, () => {
console.log(`端口:${PORT} 开启服务成功!`);
});
好了到此我们已完成了接口的开发,在终端执行 node testserver.js 命令
至此我们就有了接受
/test1接口的服务器,重新刷下页面,将会发现报错变了
当你看到
CORS 的时候,恭喜你成功弄出了跨域。
接下来,理一下原理:
- vite代理了一个项目
http://localhost:3001/我们称之为3001项目。 - 3001项目发送了一个
http://localhost:8001/test1的请求。 http://localhost:3001/与http://localhost:8001/test1产生通讯,因为端口不一致,不符合同源策略,即产生跨域。
3.跨域了,那么请求到底发出去没有?
跨域并不是请求发不出去,请求能发出去,服务端能收到请求并正常返回结果,只是结果被浏览器拦截了。
我们看到在跨域时候在8001服务端打印了相应的日志,证实服务端能收到请求并正常返回了结果。
解决跨域方案
一、jsonp
可能大家都很了解,但应该没有多少人去真正实现过,因为一般在项目都不会用到。
原理
网页通过添加一个<script>标签,向服务器请求JSON数据,这种做法不受同源政策限制;服务器收到请求后,将数据放在一个指定名字的回调函数里传回来。
分析下原理:
<script>标签不受同源政策限制。- jsonp需要后端配合,因为
数据需要放在一个指定名字的回调函数里传回来。
实现
src/App.tsx
const jsonp = (req: { url: string; params: any; callback: string }) => {
let { url, params, callback } = req;
return new Promise((resolve, reject) => {
let script = document.createElement("script");
(window as any)[callback] = (data: unknown) => {
resolve(data);
document.body.removeChild(script);
};
params = { ...params, callback }; // wd=b&callback=show
let arrs = [];
for (let key in params) {
arrs.push(`${key}=${params[key]}`);
}
script.src = `${url}?${arrs.join("&")}`;
document.body.appendChild(script);
});
};
useEffect(() => {
jsonp({
url: "http://localhost:8001/jsonp",
params: { wd: "jsonp" },
callback: "jsonpCallback",
}).then((data) => {
console.log(data);
});
}, []);
上面代码通过动态添加<script>元素,向服务器8001发出请求。注意,该请求的查询字符串有一个callback参数,用来指定回调函数的名字,这对于JSONP是必需的。
改造testseaver.js
const http = require("http");
const PORT = 8001;
const server = http.createServer((request, response) => {
// 设置返回格式
response.setHeader("Content-type", "application/json");
const url = request.url.split("?")[0];
console.log(`我进来了, url: ${url}`);
if (url === "/test1") {
return response.end(
JSON.stringify({
code: 0,
data: {},
})
);
}
if (url === "/jsonp") {
const theRequest = new Object();
const str = request.url.split("?")[1];
const strs = str.split("&");
for (var i = 0; i < strs.length; i++) {
theRequest[strs[i].split("=")[0]] = decodeURI(strs[i].split("=")[1]);
}
// 返回一个字符串的回调函数,用于页面触发
return response.end(`${theRequest.callback}('要返回的数据')`);
}
// 处理404
response.writeHead(404, { "content-type": "text/plain" });
response.write("404 Not Foundd");
response.end();
});
server.listen(PORT, () => {
console.log(`端口:${PORT} 开启服务成功!`);
});
上面代码 response.end 的时候返回了一个字符串的回调函数。
注意: testseaver.js 文件每次修改都需要执行下 node testseaver.js。
重新执行代码之后,我们可以在控制台看到打印出了后台返回的信息,至此jsonp跨域已完成。
思考:为什么后端要返回了一个字符串的回调函数?
我们知道总共有三个标签是允许跨域加载资源:
- img
- link
- script
那为什么只可以使用
script呢? 我们将App.jsx代码改成img试试
const jsonp = (req: { url: string; params: any; callback: string }) => {
let { url, params, callback } = req;
return new Promise((resolve, reject) => {
let script = document.createElement("img");
(window as any)[callback] = (data: unknown) => {
resolve(data);
document.body.removeChild(script);
};
params = { ...params, callback }; // wd=b&callback=show
let arrs = [];
for (let key in params) {
arrs.push(`${key}=${params[key]}`);
}
script.src = `${url}?${arrs.join("&")}`;
document.body.appendChild(script);
});
};
我们会发现接口确实请求成功了,但却得不到数据
原因是接口返回的回调函数并没有触发。
使用script的原因: 由于<script>元素请求的脚本,直接作为代码运行。这时,只要浏览器定义了相应的回调函数该函数就会立即调用。作为参数的JSON数据被视为JavaScript对象,而不是字符串,因此避免了使用JSON.parse的步骤。
二、反向代理
什么是反向代理?
反向代理服务器位于用户与目标服务器之间,但是对于用户而言,反向代理服务器就相当于目标服务器,即用户直接访问反向代理服务器就可以获得目标服务器的资源。同时,用户不需要知道目标服务器的地址,也无须在用户端作任何设定。反向代理服务器通常可用来作为Web加速,即使用反向代理作为Web服务器的前置机来降低网络和服务器的负载,提高访问效率。
反向代理原理?
代理服务器来接受客户端的网络访问连接请求,然后服务器将请求有策略的转发给网络中实际工作的业务服务器,并将从业务服务器处理的结果,返回给网络上发起连接请求的客户端。
前端反向代理主要本地反向代理与nginx反向代理
本地反向代理
本地反向代理主要指平时本地开发时候用到的,比如:
- webpack的 devServer.proxy
- vite的 server.proxy 以 vite 为列,配置 vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://localhost:8001',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
},
}
}
})
在App.tsx文件中发请求
useEffect(() => {
fetch("/api/test1")
.then((response) => {
return response.json();
})
.then((res) => {
console.log(res);
});
}, []);
它的原理是什么?
首先明白一点是反向代理的基本原理是利用 服务器与服务器之间的请求不存在跨域的原则。
以上代码中 vite 启了一个3000端口的服务, 服务发了一个 /api/test1 请求没有指定协议、域名、端口号时会默认应用本地服务 http://localhost:3000/api/test1,该请求会走到3000端口的服务,3000端口的服务在将它转到 http://localhost:8001 服务,完成反向代理。
以下我们将用代码实现下
在项目下创建test文件,文件下有index.html、serverA.js、serverB.js文件
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<button id="button">发送请求</button>
<script>
const button = document.querySelector("#button");
button.onclick = function () {
fetch('/test').then((response) => {
return response.json();
})
.then((res) => {
console.log(res);
});
};
</script>
</body>
</html>
serverA.js
const http = require("http");
const fs = require("fs");
// 创建服务器
http
.createServer(function (request, response) {
var pathname = new URL(request.url, "http://localhost:8080/").pathname;
console.log("pathname", pathname);
if (pathname === "/test") {
const options = {
host: "localhost",
port: "8081",
path: pathname,
};
http
.request(options, (res) => {
var body = "";
res.on("data", function (data) {
body += data;
});
res.on("end", function () {
// 数据接收完成
response.end(body);
});
})
.end();
return;
}
// 从文件系统中文件内容
if (pathname.indexOf("html") > -1) {
fs.readFile(pathname.slice(1), function (err, data) {
if (err) {
console.log(err);
// HTTP 状态码: 404 : NOT FOUND
// Content Type: text/html
response.writeHead(404, { "Content-Type": "text/html" });
} else {
// HTTP 状态码: 200 : OK
// Content Type: text/html
response.writeHead(200, { "Content-Type": "text/html" });
// 响应文件内容
response.write(data.toString());
}
// 发送响应数据
response.end();
});
return;
}
// 处理404
response.writeHead(404, { "content-type": "text/plain" });
response.write("404 Not Foundd");
response.end();
})
.listen(8080, () => {
console.log("服务开启成功 http://localhost:8080/index.html");
});
serverB.js
var http = require("http");
// 创建服务器
http
.createServer(function (request, response) {
const url = request.url.split("?")[0];
console.log(`我进来了, url: ${url}`);
if (url === "/test") {
response.setHeader("Content-type", "application/json");
return response.end(
JSON.stringify({
code: 0,
data: {
port: 8081,
},
})
);
}
// 处理404
response.writeHead(404, { "content-type": "text/plain" });
response.write("404 Not Foundd");
response.end();
})
.listen(8081, () => {
console.log("服务开启成功 http://localhost:8081");
});
原理及流程
- 终端切换至test文件下运行A、B服务器
node serverA.js、node serverB.js,这时候项目 index.heml 托管在A服务器下,访问http://localhost:8080/index.html将会展示对应项目页面:
2. 在页面点击发送请求将会走到A服务下(因为项目托管在A服务下),A服务在根据条件,分别转到不同服务下,实现跨域。
nginx反向代理
本篇文章就不做研究,市面有许多优秀文章如: nginx反向代理
三、CORS
CORS是一个W3C标准,全称是"跨域资源共享"(Cross-origin resource sharing)。
CORS需要浏览器和服务器同时支持。目前,所有浏览器都支持该功能,IE浏览器不能低于IE10。
整个CORS通信过程,都是浏览器自动完成,不需要用户参与。对于开发者来说,CORS通信的代码基本不用做任何改动。浏览器一旦发现AJAX请求跨源,就会自动添加一些附加的头信息,有时还会多出一次附加的请求,但用户不会有感觉。
因此,实现CORS通信的关键是服务器。只要服务器实现了CORS接口,就可以跨源通信。
代码实现
App.tsxt添加请求
useEffect(() => {
fetch("http://localhost:8001/cors")
.then((response) => {
return response.json();
})
.then((res) => {
console.log(res);
});
}, []);
testserver.js添加处理 cors 请求的处理
if (url === "/cors") {
response.setHeader("Access-Control-Allow-Origin", "*");
return response.end(
JSON.stringify({
code: 0,
data: {},
})
);
}
这样我们就实现了跨域,我们看代码发现主要是服务端添加了 Access-Control-Allow-Origin 字段。
Access-Control-Allow-Origin
该字段是必须的。它的值要么是请求时Origin字段的值,要么是一个*,表示接受任意域名的请求。
Origin是什么? 浏览器发出CORS请求。会在头信息之中,增加一个Origin字段。
但这样会有个问题,当携带有cookie时候依然报跨域的错
App.tsx设置cookie
const setCookie = (cData: ICookie[]) => {
const date = new Date();
cData.forEach((ele) => {
date.setTime(date.getTime() + (ele.time || 1) * 24 * 60 * 60 * 1000);
const expires = "expires=" + date.toUTCString();
document.cookie = ele.key + "=" + ele.value + "; " + expires + "; path=/";
});
};
useEffect(() => {
setCookie([{ key: "user", value: "ttt" }]);
fetch("http://localhost:8001/cors", {
credentials: "include", // 不管同源请求,还是跨域请求,一律发送 Cookie。
})
.then((response) => {
return response.json();
})
.then((res) => {
console.log(res);
});
}, []);
CORS请求默认不发送Cookie和HTTP认证信息。如果要把Cookie发到服务器,一方面要服务器同意,指定
Access-Control-Allow-Credentials字段。
需要注意的是,如果要发送Cookie,Access-Control-Allow-Origin就不能设为星号,必须指定明确的、与请求网页一致的域名。
if (url === "/cors") {
response.setHeader("Access-Control-Allow-Origin", "http://localhost:3000");
response.setHeader("Access-Control-Allow-Credentials", true);
return response.end(
JSON.stringify({
code: 0,
data: {},
})
);
}
到这里还有一些没有考虑到的地方
浏览器将CORS请求分成两类:简单请求(simple request)和非简单请求(not-so-simple request)。
只要同时满足以下两大条件,就属于简单请求。
(1) 请求方法是以下三种方法之一:
- HEAD
- GET
- POST
(2)HTTP的头信息不超出以下几种字段:
- Accept
- Accept-Language
- Content-Language
- Last-Event-ID
- Content-Type:只限于三个值
application/x-www-form-urlencoded、multipart/form-data、text/plain以上列子属于简单请求,下面主要说下非简单请求。
非简单请求
预检请求
非简单请求是那种对服务器有特殊要求的请求,比如请求方法是PUT或DELETE,或者Content-Type字段的类型是application/json。
非简单请求的CORS请求,会在正式通信之前,增加一次HTTP查询请求,称为"预检"请求(preflight)。
浏览器先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些HTTP动词和头信息字段。只有得到肯定答复,浏览器才会发出正式的XMLHttpRequest请求,否则就报错。
将代码改造成非简单请求App.tsx
fetch("http://localhost:8001/cors", {
credentials: "include", // 不管同源请求,还是跨域请求,一律发送 Cookie。
method: "PUT", // 请求方法
headers: {
"Content-Type": "text/plain;charset=UTF-8",
},
body: JSON.stringify({
// 请求信息
page: 1,
pageNo: 0,
}),
})
.then((response) => {
return response.json();
})
.then((res) => {
console.log(res);
});
我们打开控制台,查看all的请求,发现cors请求有两个,其中一个为预检请求
"预检"请求的HTTP头信息。会携带
Access-Control-Request-Method字段,表明请求方法。
上图中 响应头并没有反回对应的字段,所以浏览器就会认定,服务器不同意预检请求,因此触发一个错误。
代码改造testserver.js
if (url === "/cors") {
response.setHeader("Access-Control-Allow-Origin", "http://localhost:3000");
response.setHeader("Access-Control-Allow-Credentials", true);
response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT");
response.setHeader("Access-Control-Max-Age", 1728000);
// response.setHeader("Access-Control-Allow-Headers", 'xxxx');
return response.end(
JSON.stringify({
code: 0,
data: {},
})
);
}
(1)Access-Control-Allow-Methods
该字段必需,它的值是逗号分隔的一个字符串,表明服务器支持的所有跨域请求的方法。注意,返回的是所有支持的方法,而不单是浏览器请求的那个方法。这是为了避免多次"预检"请求。
(2)Access-Control-Allow-Headers
如果浏览器请求包括Access-Control-Request-Headers字段,则Access-Control-Allow-Headers字段是必需的。它也是一个逗号分隔的字符串,表明服务器支持的所有头信息字段,不限于浏览器在"预检"中请求的字段。
(3)Access-Control-Allow-Credentials
该字段与简单请求时的含义相同。
(4)Access-Control-Max-Age
该字段可选,用来指定本次预检请求的有效期,单位为秒。上面结果中,有效期是20天(1728000秒),即允许缓存该条回应1728000秒(即20天),在此期间,不用发出另一条预检请求。
注意设置 **Access-Control-Max-Age** 要不然每次都会发送预检请求
以上参考 CORS
总结
自己敲一遍比较好。 仓库地址: gitee.com/dengruifeng…