【从原理到实战】彻底搞懂跨域问题 (一)

751 阅读10分钟

前言

什么是跨域: 浏览器为了安全性,设置同源策略导致的, 或者说是一种浏览器的限制 同源策略: 是一种约定,WEB 应用只能请求同一个源的资源 什么时候会跨域: 协议名、域名、端口号 不同

本文将从原理, 到最简代码实现, 演示解决跨域的方法和流程,纸上得来终觉浅 绝知此事要躬行, 只有自己手敲实现过, 才能对其原理理解更加深刻。

一、JSONP

1、原理

JSONP是利用 html 中的script标签没有跨域访问限制的来实现的 工作原理:

  • 客户端通过script标签向服务器发送请求, 同时定义好回调函数接收 <script src="http://example.com/api?callback=handleResponse"></script>
  • 服务器接收到请求后,会把数据进行填充,并且放回回调函数中,例如 handleResponse({data:'response'})
  • 客户端在页面中定义相应的回调函数 handleResponse, 服务器返回的脚本会被浏览器执行, 从而触发回调函数,处理服务器返回的数据(把数据作为参数传给客户端定义的函数并执行), 这样就实现了跨域请求和数据获取

局限: JSONP 的限制是需要通过<script> 标签加载外部脚本, 但是它有一些局限性
JSONP 只支持 GET 请求, 无法通过 POST 或 其他类型请求 JSONP 只能接收 纯文本数据,无法处理 JSON 对象 或 XML 数据

2、实站(最简化代码)

接下来就通过最简代码的方式, 实现JSONP的原理

客户端 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>JSONP</title>
  </head>
  <body>
    JSONP
  </body>
  <script>
    // 像页面中添加一个script标签, 通过script标签进行访问, 返回一个可执行的 函数
    function sendJsonp() {
      const callbackName = `jsonpFn_${Date.now()}`; // 为了防止缓存 加一个时间戳
      // 发送请求前先将函数名放到window上, 赋值一个函数, 当后端收到请求后,会返回一个该函数执行的脚本, 并将函数的参数传入到该函数中
      window[callbackName] = function (data) {
        console.log(data, ".datadatadatadata");
      };
      // 将该函数拼成一个url地址请求到后端
      const url = `http://localhost:4000/jsonp?q=1&w=2&jsonp=${callbackName}`;
      const script = document.createElement("script");
      script.src = url;
      // 将脚本标签添加到head中,发送请求
      document.head.appendChild(script);
    }
    sendJsonp();
  </script>
</html>

服务端代码

const express = require("express");
const app = express();

app.get("/jsonp", (req, res) => {
  // 查询参数
  const { jsonp, q, w } = req.query;
  const result = {
    g: Array.from({ length: 10 }, (_, i) => ({ q: `${w}${i + 1}` })),
  };
  res.send(`${jsonp}(${JSON.stringify(result)})`);
});

app.listen(4000, () => {
  console.log("sever 4000");
});

解释: 从上面简化的代码中,可以看出, 客户端就是通过构建 script 标签, 通过src 的访问将函数名发送给服务端, 并且构建一个函数等待执行, 服务端收到后,拿着函数名在拼装成一个函数调用的字符串, 当客户端发送 script 请求时,会将结果进行执行, 因为函数挂载到了window上, 所以会执行我们之前构造好的函数, 并将data 这个参数传递进去,我们就拿到了结果

image.png

二、CORS

1、概念

CORS 跨域资源共享:是一种浏览器机制,允许跨域请求共享资源,在Web开发中,当一个网页向另一个域名下的资源发起请求时,如果请求的目标域和当前域不同,就会设计到跨域问题, 默认情况下, 浏览器会限制这种跨域请求,以保护用户安全

2、工作流程 (可略)

CORS 跨域资源共享通过在服务端设置相应的响应头来解决跨域问题, 它使用一些特定的HTTP 头部和预检请求已实现跨域通信

  • 1、发出跨域请求: 在浏览器中的网页向其他域名下的资源发起跨域请求时, 浏览器会首先发送一个跨域请求
  • 2、发送预检请求(可选):对于某些类型的跨域请求,例如带有一些自定义的HTTP头部或特殊方法(PUT、DELETE)的请求,浏览器会发送一个OPTIONS请求,称为预检请求,用于向服务器验证跨域请求是否被允许
  • 3、服务器设置响应头: 服务器收到跨域请求后,可以设置响应头来告诉浏览器允许请求的源(Origin)、允许的 HTTP 方法、允许的自定义头部信息
    • Access-Control-Allow-Origin 指定允许访问域名,可以单个 通配符 列表
    • Access-Control-Allow-Methods 指定允许的 HTTP 方法
    • Access-Control-Allow-Headers 指定允许的自定义头
    • access-Control-Allow-Credentials 是否允许发送身份凭证 允许时 Origin不能为*
  • 4、处理跨域响应,如果服务器设置了响应头,浏览器会根据指定的规则处理

3、实战 ( 最简化代码 )

客户端代码

我们通过 livesever 先给页面起一个静态服务, 域名为 http://127.0.0.1:5501/HTTP/xxx/indexMini.html

indexMini.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>
    <script>
      async function getUsers() {
        const response = await fetch("http://localhost:3000/users", {
          method: "POST",
        });
        const data = await response.json();
        console.log(data);
      }
      getUsers();
    </script>
  </body>
</html>

服务端代码

const express = require("express");
const app = express();
app.use((req, res, next) => {
  next();
});
app.post("/users", (req, res) => {
  res.json([{ id: 1, name: "zhangsan11" }]);
});
app.listen(3000, () => {
  console.log("sever 3000");
});

解释: 上面用静态服务起一个客户端 端口 5501, 然后通过express 起一个后端服务 端口3000, 客户端刷新页面会发起一个/users的请求, 一定会发生跨域问题, 'no-cors' to fetch the resource with CORS disabled. 显示 no-cors 获取资源失败, 其实后端是收到了请求,也将数据返回去了, 但是接口的响应表头中没有设置允许的 源 Orign, 所以浏览器对于这种跨域又没有设置 Orign 的情况, 就会报错 阻止客户端收到返回值

1、 跨域问题的解决

app.use((req, res, next) => {
  res.header("Access-Control-Allow-Origin", "*");
  next();
});

实际上只需要后端增加一行代码, 就可以解决跨域问题了, 因为 * 代表所有源都允许, 如果希望指定的源或者给几个指定源添加白名单的形式,可以这样写

const whiteList = ["http://127.0.0.1:5501"];
if (whiteList.includes(req.headers.origin)) {
    res.header("Access-Control-Allow-Origin", req.headers.origin);
}

这样就只能针对特定的源允许跨域请求了

2、针对复杂请求的相关处理与解决

上面我们解决完跨域问题了, 但是还存在几个问题, 我们的请求是post的,还没有添加header, 一旦修改请求方法,或者header增加一些字段,就发现有问题了,所以下面开始处理这些复杂请求 先简单区分什么是复杂请求什么是简单请求: 区分他们有两个指标, 一个是请求方法: HEAD、GET、POST是简单请求, header中常见的 Content-Type 是 application/x-www-form-urlencodedmultipart/form-data 和一些 AcceptAccept-LanguageContent-Language 这些都是简单请求, 简单请求就很简单,不需要在俄额外进行服务端的处理设置了, 可以尽情使用

除此之外,其他的请求方法 和 其他的请求头。都是复杂请求, 复杂请求会额外发送一个 OPTION 请求来询问服务端是否支持, 那么怎么才能支持呢? 就是要分别处理 这里格外需要注意, header 中的 Content-Type: "application/json 是复杂请求, 比如 POST 的时候 我们经常需要这种类型, 那么在跨域的时候就需要特殊处理了,否则会报错 Request header field content-type is not allowed 这种解决只需要在代码中增加一行

 res.header("Access-Control-Allow-Headers", "Content-Type");

如果想增加自定义的头 比如请求时候 再增加一个 custom: 3000

 headers: {
    "Content-Type": "application/json",
    custom: "3000",
 },

这样也只需要后端在允许的Headers中加一下

 res.header("Access-Control-Allow-Headers", "Content-Type, custom");

3、预检请求的优化

复杂请求会发预检请求, 相当于每个接口会发两次请求, 比较消耗资源, 那么是可以对预检请求进行优化, 可以采用以下两种方式

  • 设置预检请求的缓存时长
 res.header("Cache-Control", "max-age=3600"); // 设置1小时缓存 1小时内不再发送预检请求
  • 优化OPTIONS请求大小, 简化返回的内容
if (req.method === "OPTIONS") {
  return res.sendStatus(200);
}

4、跨域时针对cookie问题的解决与设置

跨域时,其实默认是不会携带 cookie 的我们可以手动在客户端,手动加一下 cookie, 然后发现请求头中是不会携带的

解决跨域携带 cookie 也比较简单, 前后端都需要设置

前端: 在请求方法中添加携带凭证 ,不同的请求方式,添加方式也不一样, 具体方法如下:

// fetch 中设置
fetch('url',{
  method:'GET',
  credentials:'include'
})

// axios 中设置
axios.get('xxx', {
  withCredentials: true
})

// ajax
var xhr = new XMLHttpRequests();
xhr.open('GET','xxxxxx', true )
xhr.withCredentials = true
//...

后端:

  res.header("Access-Control-Allow-Credentials", true);

注意: 跨域携带cookie, 后端设置 Access-Control-Allow-Credentials 后, Access-Control-Allow-Origin 不能设置为 *

image.png

以上基本就是关于的CORS的相关内容了

三、PostMessage

postMessage 是用于不同窗口或框架之间进行安全通信的 API, 提供了简单灵活的方式来发送和接收数据

1、基本原理

    1. 发送消息的窗口,调用 postMessage 方法来像接收方发送消息(发送的消息和窗口源)
    1. 接收消息的窗口内, 添加事件监听来自其他窗口的消息,通过 window.addEventListener('message',callbackFn) 通过 callbackFn 回调函数接收
    1. 通过 event.origin 属性验证消息来源。 通过与跨域地址的页面进行收发消息, 间接达成与跨域请求

2、实战

1、我们先创建一个 A 页面 , 通过 Live Sever 起一个静态服务, 端口 5501 , A页面代码如下
<!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>
    <iframe
      id="iframe"
      src="http://localhost:4000/b.html"
      onload="loaded()"
      hidden
    ></iframe>
    <button onclick="request({ url: '/list'});">请求列表</button>
  </body>
  <script>
    const bwindow = document.getElementById("iframe").contentWindow;

    window.addEventListener("message", function (e) {
      if (e.origin !== "http://localhost:4000") return;
      console.log(e.data, e, "监听b页面给我发来的消息");
      if (e.data.url === "/users") {
        console.log("users:", e.data);
      }
      if (e.data.url === "/list") {
        console.log("users:", e.data);
      }
    });

    function loaded() {
      request({
        url: "/users",
      });
    }

    function request(data) {
      // 向bwindow发消息
      bwindow.postMessage(data, "http://localhost:4000");
    }
  </script>
</html>

在A页面我们通过隐藏一个 iframe 内嵌一个 端口为 4000 的页面 B, 通过iframe 的 load 方法, 利用iframe的 发送 postMessage 请求到 B页面

2、创建 B 页面 监听 A页面发送过来的消息, 代码如下
<!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>
    B页面
    <script>
      window.addEventListener("message", async function (e) {
        if (e.origin === "http://127.0.0.1:5501") {
          console.log("监听a页面发来的消息", e.origin, e.data, e.source);
          const response = await fetch(e.data.url, e.data);
          const result = await response.json();
          e.source.postMessage(
            {
              url: e.data.url,
              data: result,
            },
            e.origin
          );
        }
      });
    </script>
  </body>
</html>

我们通过B页面接收 来自 "http://127.0.0.1:5501" 源发来的请求, 然后B页面通过 express 起一个服务, 端口为 4000, B页面由这个服务提供, 那么B页面和 服务端交互将不存在跨域问题, 然后我们在B页面发起请求B服务器上的资源,请求完成后,在通过 e.source.postMessage 将数据发送回A页面, 下面是B的服务端代码,起一个 4000 的服务, 然后打开B页面 http://localhost:4000/B.html

const express = require("express");
const app = express();

app.use(express.static("public"));
// 404 错误处理中间件
const whiteList = ["http://127.0.0.1:5501"];

app.get("/users", (req, res) => {
  res.json([{ id: 1, name: "zhangsan" }]);
});

app.get("/list", (req, res) => {
  res.json([
    { id: 1, list: "1" },
    { id: 2, list: "2" },
  ]);
});

app.listen(4000, () => {
  console.log("sever 4000");
});

这样我们就完成了 , A页面请求 B服务,会跨域, 但是 A 通过 B页面去发送请求到 B 服务即可解决跨域这一问题。

总结

本文通过讲解了什么是跨域, 并对两种跨域的方式进行了,代码演示, 并用来解释原理, JSONP是很早的解决跨域的方式, 他利用了script请求可以绕开跨域限制的原理, 但是这种方式只支持GET请求, 并且是不够安全的,容易发生 JSONP 漏洞很容易被别人利用, 如果非要使用最好加上校验 refer的白名单, CORS 解决跨域问题是比较正统的实现, 基本都是后端为主,只是是否携带cookie时需要前端配合, 并且跨域情况下, 请求上也分简单请求和复杂请求, 介绍了复杂请求下的问题解决与策略,并且简单介绍了预检请求的优化和跨域情况下 cookie 的携带设置。 postMessage 原理是A页面想要请求 B服务但是会跨域,但是A页面可以跟B页面通讯,然后让B页面发送请求给B服务, 这样就可以解决跨域问题。为此我们还封装了一下, 通过A页面传递请求参数路径,让B页面发起请求与中转数据, 大家有兴趣的可以试试内嵌掘金这个网站试试能不能通过上述方法能不能请求到掘金服务器。 最后, 单纯只看概念,背面试题,其实无法真正了解,纸上得来终觉浅 绝知此事要躬行, 只有自己手敲实现过, 才能对其原理理解更加深刻。