跨域资源共享(CORS)

  1. 简介
  2. 跨域会造成的问题
  3. 如何解决跨域问题
    1. jsonp
    2. 后端设置
      1. 简单请求
      2. 非简单请求
    3. vue跨域配置
    4. react跨域配置

简介

跨域是前端开发中常见的一个问题,其本身跟http协议没有关系,而是浏览器的一种安全机制,称之为同源策略;

同源策略指的是当前网站和需要访问的目标网站的协议域名端口都要相同,其中主要有一个不相同,就会产生跨域问题;

当前url 目标url 是否跨域 原因
http://www.test.com http://www.test.com/index.html 同源(协议、域名、端口相同)
http://www.test.com https://www.test.com/index.html 协议不同(http/https),不只是这两种协议、如本地的file协议,websocket的ws协议等都算
http://www.test.com http://www.test1.com/index.html 主域名不同(test/test1)
http://www.test.com http://mail.test.com/index.html 子域名不同(www/mail)
http://www.test.com:8000 http://www.test.com:8080/index.html 端口不同(8000/8080)

跨域会造成的问题

跨域会造成很多问题,比如

  • 无法读取非同源网页的 CookieLocalStorageIndexedDB
  • 无法接触非同源网页的 DOM
  • 无法向非同源地址发送 AJAX 请求

对于现在前后端分离的开发模式,我们最常见的跨域问题就是当请求后端接口时候发生的。如果遇到以下错误,就是发生了跨域问题,我们通过fetch,去请求后端接口(http://localhost.com/api/user),由于当前url是本地的,协议是file协议,就会发生跨域问题

如何解决跨域问题

jsonp

在之前最常见的解决跨域问题是通过jsonp来进行的,jsonp的原理是在标签中的请求不会发生跨域问题,可以将请求放到标签上,然后再后台进行数据返回,前端定义一个获取数据的函数,后端将执行函数的代码当做字符串返回,前端会自动执行该代码,从而获取到了数据,但是jsonp有个最大的问题就是jsonp跨域只能处理get请求。

const koa = require('koa');
const Router = require('koa-router');

let server = new koa();
let router = new Router();

server.listen(8080);
server.use(router.routes());

router.get('/api/user', async (ctx, next) => {
    ctx.body = {username: 'daryl', age: 18};
});

router.get('/api/jsonp', async (ctx, next) => {
    const user = JSON.stringify({username: 'daryl', age: 18});
    // jsonp就是这样处理,拿到客户端定义的callback函数,返回函数调用的字符串,将数据包在里面
    const { callback } = ctx.query
    ctx.body = `${callback}(${user})`;
});
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
</head>
<body>
<button id="btn_get">get</button>
<button id="btn_jsonp">jsonp</button>
<script>
    // 直接fetch请求
    let btn_get = document.getElementById('btn_get');
    btn_get.addEventListener("click", () => {
        fetch('http://localhost:8080/api/user',
        )
            .then(response => {
                return response.json();
            })
            // 请求的数据
            .then(data => {
                console.log(data);
            }).catch(err => console.log(err));
    });

    // jsonp 
    let btn_jsonp = document.getElementById('btn_jsonp');
    btn_jsonp.addEventListener('click', () => {

        // 删除上个请求的script标签,避免html中充斥大量的无用script标签
        let prevScript = document.querySelector(`script[src='http://localhost:8080/api/jsonp?callback=getUser']`);
        if (prevScript) {
            document.body.removeChild(prevScript);
        }

        let script = document.createElement('script');
        script.setAttribute('src', 'http://localhost:8080/api/jsonp?callback=getUser');
        document.body.appendChild(script);
    })
    
    // 这里的函数名就是上面的callback参数指定的名字
    function getUser(data) {
        console.log(data);
    }
</script>
</body>
</html>

我们可以看到,当我们直接去fetch的时候,发生了跨域,但是当我们通过jsonp处理后,可以拿到后端返回的数据

后端设置

浏览器将CORS请求分成两类:简单请求(simple request)和非简单请求(not-so-simple request)。

只要同时满足以下两大条件,就属于简单请求。

  1. 请求方法是以下三种方法之一:
  • HEAD
  • GET
  • POST
  1. HTTP的头信息不超出以下几种字段:
  • Accept
  • Accept-Language
  • Content-Language
  • Last-Event-ID
  • Content-Type:只限于三个值application/x-www-form-urlencoded、multipart/form-data、text/plain

凡是不同时满足上面两个条件,就属于非简单请求。

浏览器对这两种请求的处理,是不一样的

简单请求

对于简单请求,浏览器直接发出CORS请求。具体来说,就是在头信息之中,增加一个Origin字段。Origin字段用来说明,本次请求来自哪个源(协议 + 域名 + 端口)。服务器根据这个值,决定是否同意这次请求。

上面的Origin字段是null,是因为我们是使用浏览器直接打开的本地文件,当我们使用live server模拟真实环境时,就会变成真实的Origin

如果Origin指定的源,不在许可范围内,服务器会返回一个正常的HTTP回应。浏览器发现,这个回应的头信息没有包含Access-Control-Allow-Origin字段,就知道出错了,从而抛出一个跨域错误;

我们可以在后端设置Access-Control-Allow-Origin,表示允许那些源进行跨域请求

const koa = require('koa');
const Router = require('koa-router');

let server = new koa();
let router = new Router();

server.listen(8080);
server.use(router.routes());

router.get('/api/user', async (ctx, next) => {
    // * 表示允许所有源进行跨域请求
    ctx.set("Access-Control-Allow-Origin", "*");
    ctx.body = {username: 'daryl', age: 18};
});

后端还可以设置其他响应头,有其他作用,比如:

  • Access-Control-Allow-Credentials

    该字段可选。它的值是一个布尔值,表示是否允许发送Cookie。默认情况下,Cookie不包括在CORS请求之中。设为true,即表示服务器明确许可,Cookie可以包含在请求中,一起发给服务器。这个值也只能设为true,如果服务器不要浏览器发送Cookie,删除该字段即可。

    注意:如果要把Cookie发到服务器,一方面要服务器同意,指定Access-Control-Allow-Credentialstrue,还要将客户端的ajax请求指定withCredentials,例如:

    var xhr = new XMLHttpRequest();
    xhr.withCredentials = true;
    

    或者

    fetch(url, {
      credentials: 'include'
    })
    

    这篇文章跨域设置cookie详细讲解了这个属性和客户端设置cookie相关的内容

  • Access-Control-Expose-Headers

    该字段可选。CORS请求时,XMLHttpRequest对象的getResponseHeader()方法只能拿到7个基本字段(简单响应首部):Cache-ControlContent-LengthContent-LanguageContent-TypeExpiresLast-ModifiedPragma。如果想拿到其他字段,就必须在Access-Control-Expose-Headers里面指定。
    例如:
    我们设置了自定义头Other-Header,值为other-header

    const koa = require('koa');
    const Router = require('koa-router');
    
    let server = new koa();
    let router = new Router();
    
    server.listen(8080);
    server.use(router.routes());
    
    router.get('/api/user', async (ctx, next) => {
        ctx.set("Access-Control-Allow-Origin", "*");
        // 我们设置了自定义头Other-Header,值为other-header
        ctx.set("Other-Header", "other-header");
        ctx.body = {username: 'daryl', age: 18};
    });
    

    此时通过fetchapi去获取响应头,是拿不到Other-Header

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
    </head>
    <body>
    <button id="btn_get">get</button>
    <script>
        // 直接fetch请求
        let btn_get = document.getElementById('btn_get');
        btn_get.addEventListener("click", () => {
            fetch('http://localhost:8080/api/user',
            )
                .then(response => {
                    for (let [key, value] of response.headers) { 
                        console.log(`${key} : ${value}`);  
                    }
                    return response.json();
                })
                // 请求的数据
                .then(data => {
                    console.log(data);
                }).catch(err => console.log(err));
        });
    </script>
    </body>
    </html>
    

    但如果我们加上ctx.set("Access-Control-Expose-Headers", "Other-Header");此时就可以拿到了

    router.get('/api/user', async (ctx, next) => {
        ctx.set("Access-Control-Allow-Origin", "*");
        ctx.set("Other-Header", "other-header");
        ctx.set("Access-Control-Expose-Headers", "Other-Header");
        ctx.body = {username: 'daryl', age: 18};
    });
    

非简单请求

非简单请求是那种对服务器有特殊要求的请求,比如请求方法是PUTDELETE,或者Content-Type字段的类型是application/json

非简单请求的CORS请求,会在正式通信之前,增加一次HTTP查询请求,称为”预检”请求(preflight)。

浏览器先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些HTTP动词和头信息字段。只有得到肯定答复,浏览器才会发出正式的XMLHttpRequest请求,否则就报错。

我们来模拟一个非简单请求,在请求头中加入一个自定义字段Authorization

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
</head>
<body>
<button id="btn_get">get</button>
<script>
    // 直接fetch请求
    let btn_get = document.getElementById('btn_get');
    btn_get.addEventListener("click", () => {
        fetch('http://localhost:8080/api/user',
        {
            headers: {
                'Authorization': 'some token'
            }
        }
        )
            .then(response => {
                return response.json();
            })
            // 请求的数据
            .then(data => {
                console.log(data);
            }).catch(err => console.log(err));
    });
</script>
</body>
</html>

预检请求用的请求方法是OPTIONS,表示这个请求是用来询问的。头信息里面,关键字段是Origin,表示请求来自哪个源。

除了Origin字段,”预检”请求的头信息包括两个特殊字段。

  • Access-Control-Request-Method

    该字段是必须的,用来列出浏览器的CORS请求会用到哪些HTTP方法,上例是GET

  • Access-Control-Request-Headers

    该字段是一个逗号分隔的字符串,指定浏览器CORS请求会额外发送的头信息字段,上例是authorization

预检请求也是一个真实的请求,我们后端需要对其进行处理,否则就会出现404错误(如上图),对于预检请求的响应,关键的是Access-Control-Allow-Origin字段,表示哪些源可以请求数据,如果在预检请求里面没有指定这个资源,而仅在需要的请求中指定,则也会发生跨域问题;例如:

const koa = require('koa');
const Router = require('koa-router');

let server = new koa();
let router = new Router();

server.listen(8080);
server.use(router.routes());

router.get('/api/user', async (ctx, next) => {
    ctx.set("Access-Control-Allow-Origin", "*");
    ctx.body = {username: 'daryl', age: 18};
});

router.options('*', async (ctx, next) => {
    // 如果预检请求这里没有指定Access-Control-Allow-Origin,而仅在上面设置了,也会发生跨域问题
    ctx.set("Access-Control-Allow-Origin", "*");
    ctx.set("Access-Control-Allow-Methods", "OPTIONS, GET, PUT, POST, DELETE");
    ctx.set("Access-Control-Allow-Headers", "authorization");
    ctx.set("Access-Control-Max-Age", "1728000");
    ctx.body = 'ok';
})

还有一些其他的相关字段:

  • Access-Control-Allow-Methods

    该字段必需,它的值是逗号分隔的一个字符串,表明服务器支持的所有跨域请求的方法。注意,返回的是所有支持的方法,而不单是浏览器请求的那个方法。这是为了避免多次”预检”请求。

  • Access-Control-Allow-Headers

    如果浏览器请求包括Access-Control-Request-Headers字段,则Access-Control-Allow-Headers字段是必需的。它也是一个逗号分隔的字符串,表明服务器支持的所有头信息字段,不限于浏览器在”预检”中请求的字段。

  • Access-Control-Max-Age

    该字段可选,用来指定本次预检请求的有效期,单位为秒。上面结果中,有效期是20天(1728000秒),即允许缓存该条回应1728000秒(即20天),在此期间,不用发出另一条预检请求。

  • Access-Control-Allow-Credentials

    该字段与简单请求时的含义相同

vue跨域配置

  • vue3.0

    首先声明我们真实的接口地址是http://localhost:8080/api/user

    vite.config.js

      import { defineConfig } from 'vite'
      import vue from '@vitejs/plugin-vue'
      import path from 'path'
    
      // https://vitejs.dev/config/
      export default defineConfig({
          plugins: [vue()],
          server:{
              port: 3000,
              proxy: {
                  // /api 属性声明axios中url以/api开头的请求都适用于该规则,例如axios.get('/api/xxx'),对应下面按钮1的请求
                  '/api' : {
                      target: 'http://localhost:8080',
                      changeOrigin: true,
                  },
    
                  // /local_api 属性声明axios中url以/local_api开头的请求都适用于该规则,例如axios.get('/local_api/api/xxx'),对应下面按钮2的请求
                  // 但是由于我们真实的接口地址是http://localhost:8080/api/user,而此时请求的是http://localhost:8080/local_api/api/user,因此需要将多余的路径进行重写,需要rewrite一下,将local_api/去掉,使其成为http://localhost:8080/api/user
                  '/local_api' : {
                      target: 'http://localhost:8080',
                      changeOrigin: true,
                      rewrite: (path) => {console.log(path) ;return path.replace(/^\/local_api/, '')}
                  }
              }
          }
      })
    
      <template>
      <div>
          <button @click="getData">按钮1</button>
          <button @click="getData2">按钮2</button>
      </div>
      </template>
    
      <script>
      import axios from 'axios'
      export default {
          methods: {
              getData() {
                  axios.get('/api/user').then(res => {
                      console.log(res.data)
                  })
              },
              getData2() {
                  axios.get('/local_api/api/user').then(res => {
                      console.log(res.data)
                  })
              },
          }
      }
      </script>
    


    两个请求都成功了,请求中的地址是本地的地址,正是因为我们做了代理

  • vue2.0

    vue2.0的配置原理是相同的,只不过写法有一点差异而已,下面是相关配置

    vue.config.js

      const path = require('path')
    
      module.exports = {
          devServer: {
              proxy: {
                  '^/api': {
                      // 注意是以/api开头,即:axios.get('/api/xxx'),不能带接口的域名及端口
                      target: 'http://localhost:8080', // 代理目标
                      ws: true, // 启用websocket
                      changeOrigin: true
                  },
                  '^/local_api': {
                      // 注意是以/local_api开头,即:axios.get('/local_api/api/xxx'),不能带接口的域名及端口
                      target: 'http://localhost:8080', // 代理目标
                      changeOrigin: true,
                      ws: true, // 启用websocket
                      // 匹配到的地址重写, /local_api/api/xxx  =>  https://www.baihuzi.com/api/xxx
                      pathRewrite: { '^/local_api': '' }
                  }
              }
          },
      }
    

    注意和vite的配置的写法不一样

react跨域配置

react跨域配置需要使用第三方库http-proxy-middleware来解决,之前在package.json文件中配置proxy的方式已经不适用了,可能是希望将跨域配置放到后端来做,这样既省事也能保证安全。

首先需要安装http-proxy-middleware

yarn add http-proxy-middleware

然后在src目录下新建文件setupProxy.js文件,项目启动的时候,会自动去找这个文件,我们不需要我们在其他地方引入

const {createProxyMiddleware} = require('http-proxy-middleware')

module.exports = function (app) {
    // proxy第一个参数为要代理的路由
    // 第二参数中target为代理后的请求网址,changeOrigin是否改变请求头,其他参数请看官网
    app.use(createProxyMiddleware('/api', {
        target: 'http://localhost:8080',
        changeOrigin: true,
    }))
    app.use(createProxyMiddleware('/local_api', {
        target: 'http://localhost:8080',
        changeOrigin: true,
        pathRewrite: { '^/local_api': '' }
    }))
}

测试

import React from 'react'
import axios from 'axios'

const GetData = () => {
    const getData = () => {
        axios.get('/api/user').then(res => {
            console.log(res.data);
        })
    }
    const getData2 = () => {
        axios.get('/local_api/api/user').then(res => {
            console.log(res.data);
        })
    }
    return <>
        <button onClick={getData}>按钮1</button>
        <button onClick={getData2}>按钮2</button>
    </>
}

export default GetData


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 289211569@qq.com