小姐姐:看不懂跨域怎么办?

208 阅读8分钟

前言

什么是跨域?跨域的来由?以及有哪几种跨域方式?学习本文将收获以下几个知识点:

  1. 什么是跨域,怎么产生跨域,以及常见的几种解决方案。

121212.gif

什么是跨域?

了解跨域前,先认识下同源政策
所谓"同源"指的是"三个相同"。

  • 协议相同
  • 域名相同
  • 端口相同

什么是协议、域名、端口?举例来说:'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

项目结构是这样的

image.png

2.模拟一个接口

在src/App.tsx下加入一段请求代码
App.tsx

  useEffect(() => {
    fetch("http://localhost:8001/test1")
      .then((response) => {
        return response.json();
      })
      .then((res) => {
        console.log(res);
      });
  }, []);

运行项目都发现将会报错,因为没有服务器接受此接口

image.png 好了接下来我们将使用node写个服务,用于接收/test1接口
在根目录下创建testserver.js文件

image.png 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 命令

image.png 至此我们就有了接受/test1接口的服务器,重新刷下页面,将会发现报错变了

image.png 当你看到 CORS 的时候,恭喜你成功弄出了跨域。

image.png
接下来,理一下原理:

  1. vite代理了一个项目 http://localhost:3001/ 我们称之为3001项目。
  2. 3001项目发送了一个 http://localhost:8001/test1 的请求。
  3. http://localhost:3001/http://localhost:8001/test1 产生通讯,因为端口不一致,不符合同源策略,即产生跨域。

3.跨域了,那么请求到底发出去没有?

跨域并不是请求发不出去,请求能发出去,服务端能收到请求并正常返回结果,只是结果被浏览器拦截了。 我们看到在跨域时候在8001服务端打印了相应的日志,证实服务端能收到请求并正常返回了结果。 image.png

解决跨域方案

一、jsonp

可能大家都很了解,但应该没有多少人去真正实现过,因为一般在项目都不会用到。

原理

网页通过添加一个<script>标签,向服务器请求JSON数据,这种做法不受同源政策限制;服务器收到请求后,将数据放在一个指定名字的回调函数里传回来。
分析下原理:

  1. <script>标签不受同源政策限制。
  2. 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跨域已完成。

image.png

思考:为什么后端要返回了一个字符串的回调函数?

我们知道总共有三个标签是允许跨域加载资源:

  • 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);
    });
  };

我们会发现接口确实请求成功了,但却得不到数据

image.png

原因是接口返回的回调函数并没有触发。
使用script的原因: 由于<script>元素请求的脚本,直接作为代码运行。这时,只要浏览器定义了相应的回调函数该函数就会立即调用。作为参数的JSON数据被视为JavaScript对象,而不是字符串,因此避免了使用JSON.parse的步骤。

二、反向代理

什么是反向代理?
反向代理服务器位于用户与目标服务器之间,但是对于用户而言,反向代理服务器就相当于目标服务器,即用户直接访问反向代理服务器就可以获得目标服务器的资源。同时,用户不需要知道目标服务器的地址,也无须在用户端作任何设定。反向代理服务器通常可用来作为Web加速,即使用反向代理作为Web服务器的前置机来降低网络和服务器的负载,提高访问效率。 反向代理原理?
代理服务器来接受客户端的网络访问连接请求,然后服务器将请求有策略的转发给网络中实际工作的业务服务器,并将从业务服务器处理的结果,返回给网络上发起连接请求的客户端。

image.png

前端反向代理主要本地反向代理nginx反向代理

本地反向代理

本地反向代理主要指平时本地开发时候用到的,比如:

  1. webpack的 devServer.proxy
  2. 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文件

image.png 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");
  });

原理及流程

  1. 终端切换至test文件下运行A、B服务器 node serverA.js、node serverB.js,这时候项目 index.heml 托管在A服务器下,访问 http://localhost:8080/index.html 将会展示对应项目页面:

image.png 2. 在页面点击发送请求将会走到A服务下(因为项目托管在A服务下),A服务在根据条件,分别转到不同服务下,实现跨域。

nginx反向代理

本篇文章就不做研究,市面有许多优秀文章如: nginx反向代理

image.png

三、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字段。

image.png 但这样会有个问题,当携带有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);
      });
  }, []);

image.png 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-urlencodedmultipart/form-datatext/plain 以上列子属于简单请求,下面主要说下非简单请求。

非简单请求

预检请求

非简单请求是那种对服务器有特殊要求的请求,比如请求方法是PUTDELETE,或者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请求有两个,其中一个为预检请求

image.png "预检"请求的HTTP头信息。会携带Access-Control-Request-Method字段,表明请求方法。

image.png 上图中 响应头并没有反回对应的字段,所以浏览器就会认定,服务器不同意预检请求,因此触发一个错误。 代码改造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…