说一说跨域和如何解决

4,048 阅读15分钟

前言

跨域问题是我们在面试过程中经常容易被询问到的,今天我们来聊聊什么是跨域以及如何解决跨域。

我们先来聊聊跨域是什么?

比如说我们去访问百度的首页https://www.baidu.com/,那么浏览器就会朝这个地址去发送一次网络请求。我们来看看这个地址是由哪几个部分组成的:

首先百度是对这段地址的域名去做了一个域名优化的,原来的地址是有端口号的,这才是一个正常的http地址,这里我们就假设为:

https://192.168.31.45:8080/user

这段地址分为四个部分:

协议号:域名:端口号 / 路径

现在我们来思考一个问题,如果该地址是百度的后端服务器的ip地址,只要我们朝该地址发请求,我们是否可以拿到数据?

如果真的可以随便拿到的话,那是不科学的。那么这些大公司的数据将毫无秘密可言。那么为了防止这个问题,所有浏览器都打造了一个同源策略

同源策略

协议号-域名-端口号 都相同的地址,浏览器才认为是同源

  • https://192.168.31.45:8080/user
  • https://192.168.31.45:8080/list

我们来看看这两个地址,它们是同源地址吗?

是的,它们是同源地址,它们协议号-域名-端口号都相同,只是路径不一样。

公司的ip都是公网ip,所以公司之间的ip是绝对不一样的,192.168.31.45这一段数字不可能相同。所以说,百度的程序员不可能去请求到腾讯的后端数据。

如果它们的协议号-域名-端口号有任何一个不相同,那么浏览器就会将返回的数据拦截下来,这就是跨域。

跨域

后端返回给浏览器的数据会被浏览器的同源策略给拦截下来,这就是跨域

假设百度的前端和字节的前端都去访问字节的后端,首先后端只要有请求都会响应,所以会出现很尴尬的情况。字节的后端同样会返还东西给百度的前端,浏览器就做了同源策略。如图所示,假设它们的地址为这样,由于百度的前端和字节的后端的协议-域名-端口号三者不是完全相同,那么浏览器则将后端返回回来的响应拦截下来,并不会发给前端,这就是跨域

image.png

这里需要注意一下,大公司的ip地址是不会一样的,这是它们在万维网申请的,是全球唯一的。

同源策略的目的是数据安全被浏览认为不是同一个源的请求,就拿不到响应

解决跨域

为什么要解决跨域呢?

假设我在我们公司是干前端的,我们公司有个哥们是干后端的。我们两个负责搭配完成一个全栈项目。假设我把我的前端项目跑在http://192.168.31.1:8000,后端那个哥们把后端跑在http://192.168.31.2:8000

虽然我们都连的是公司的局域网,但是域名最后一个数字还是会不一样的。

前端需要朝后端发送接口请求,那这样能请求到数据吗?答案是不能的,因为发生了跨域!所以就需要我们解决跨域,让我们在开发阶段好调试。

常用的解决跨域的办法有四种,我们需要掌握。

我们用代码来给大家演示一下:

我们来看看后端代码:

const Koa = require('koa');
const app = new Koa()

const main = (ctx, next) => {
  ctx.body = {
      data: 'hello world'
  }
}

app.use(main)

app.listen(3000, () => {
  console.log('listening on port 3000');
})

后端跑在localhost:3000上面。返回数据hello world

我们再来看看前端代码:

首先我们来看看前端的代码:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <button id="btn">获取数据</button>

    <!-- <script src="http://localhost:3000"></script> -->

    <script>
        let btn = document.getElementById('btn');
        btn.addEventListener('click', () => {
            // 发请求
            fetch('http://localhost:3000')
            .then(res => res.json())
            .then(res => {
                console.log(res);
            })
        });
    </script>
</body>
</html>

我们想要实现当我们点击按钮时,前端朝后端发请求,拿到数据并打印。

我们点击一下按钮试一试:

image.png

瞧,报错了,因为受到了浏览器的同源政策保护,跨域了,后端返回的响应被浏览器劫持了。

1. JSONP

首先我们要明白一点, ajax请求受同源策略的影响,但是script上的src属性不受同源策略的影响,且该属性也会导致浏览器发送一个请求。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <button id="btn">获取数据</button>

    <script src="http://localhost:3000"></script>


    <script>
        let btn = document.getElementById('btn');
        btn.addEventListener('click', () => {
           
        });
    </script>
</body>
</html>

我们通过在script中添加src属性也会发送一段请求,它是不受同源政策影响的:

image.png

这样,是不会发生跨域的。可能有些小伙伴们就想到了,我们有时候会通过CDN引入第三方源码,我们这样引入也没有报错, 比如直接引入vue:<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>

如果通过script去访问资源时也受同源策略影响,那么我们就没有办法引入第三方的库了。

现在我们就来通过这个script去拿到数据:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <button id="btn">获取数据</button>

    <!-- <script src="http://localhost:3000"></script> -->

    <script>
        

        function jsonp(url, cb){
            return new Promise((resolve, reject) => {
                const script = document.createElement('script');
                script.src = `${url}?cb=${cb}` // http://localhost:3000?cb=`callback`
                document.body.appendChild(script);
            })
        }
        let btn = document.getElementById('btn');
        btn.addEventListener('click', () => {
            // 发请求
            jsonp('http://localhost:3000', 'callback')
            .then(res => {
                console.log('后端返回的结果' + res);
            })
        });
    </script>
</body>
</html>

我们自己封装一个jsonp函数来用于发接口请求,这个jsonp函数可以接受urlcb作为参数。首先生成一个<script>标签,并且给script添加一个src属性,值为拼接后的urlcb,然后将该script标签放到body当中去,这样我们就确保了该jsonp函数可以利用script标签去发生请求。

前端代码就先写到这,我们点击按钮来测试一下:

image.png

我们成功的发送了一个请求。既然前端发送了一个请求给后端,那么后端就一定去接收到了请求。现在我们来到后端,后端应该成功接收到前端传过来的cb字符串。我们到后端中打印这个参数来看一下:

// const Koa = require('koa');
// const app = new Koa()

// const main = (ctx, next) => {
//   console.log(ctx.query); // { cb: 'callback' }
//   const cb = ctx.query.cb

//   const data = '给前端的数据'

//   const str = `${cb}('${data}')`;   // 'callback("给前端的数据")'

//   ctx.body = str
// }

// app.use(main)

// app.listen(3000, () => {
//   console.log('listening on port 3000');
// })

const Koa = require('koa');
const app = new Koa()

const main = (ctx, next) => {
    console.log(ctx.query);
    
    ctx.body = {
        data: 'hello world'
    }
}

app.use(main)

app.listen(3000, () => {
    console.log('listening on port 3000');
})

image.png

const Koa = require('koa');
const app = new Koa()

const main = (ctx, next) => {
    console.log(ctx.query);  // { cb: 'callback' }
    const cb = ctx.query.cb

    const data = '给前端的数据'

    const str = `${cb}('${data}')`;  // 'callback("给前端的数据")'

    ctx.body = str

}

app.use(main)

app.listen(3000, () => {
    console.log('listening on port 3000');
})

前端传了一个单词给我们后端,然后我们后端将给我的这个单词拼接为一个字符串再返回给前端。这里使用的是es6的模板字符串。大家可以看看代码上的注释,更好理解。我们返回给前端的str像不像一个函数的调用呢?

我们再来看前端:

我们在全局的window对象上挂上这个参数cb,属性为该参数值,值为一个函数体。注意,我们这里只是声明,并没有调用。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <button id="btn">获取数据</button>

    <!-- <script src="http://localhost:3000"></script> -->

    <script>
        

        function jsonp(url, cb){
            return new Promise((resolve, reject) => {
                const script = document.createElement('script');
                script.src = `${url}?cb=${cb}` // http://localhost:3000?cb=`callback`
                document.body.appendChild(script);

                window[cb] = (data) => {
                    console.log(data)
                }
                // callback()
                // {
                //     "callback": () => {}
                // }

            })
        }
        let btn = document.getElementById('btn');
        btn.addEventListener('click', () => {
            // 发请求
            jsonp('http://localhost:3000', 'callback')
            .then(res => {
                console.log('后端返回的结果' + res);
            })
        });
    </script>
</body>
</html>

到这里,我们就已经可以看到效果了,我们点击按钮来看看打印:

image.png

我们的前端成功的拿到了后端的数据,这里有打印是因为我们挂载在window上的cb函数触发了。但是我们前端并没有去触发它,那么就是后端来触发的,并且将我们想要的数据作为参数来传给函数,这样才能打印出来数据。

我们来看看这个函数是怎么触发的:

const main = (ctx, next) => {
    console.log(ctx.query);  // { cb: 'callback' }
    const cb = ctx.query.cb

    const data = '给前端的数据'

    const str = `${cb}('${data}')`;  // 'callback("给前端的数据")'

    ctx.body = str

}

前端将cb挂在window上,并且将cb传给后端,后端就收到cb,然后使用字符串模板进行拼接,使其变成一个函数的调用,将给前端的数据作为此函数的参数:'callback("给前端的数据")'。然后将这个字符串传给前端,浏览器会将字符串执行成callback的调用。

我们来看看后端的响应:

image.png

我们将console.log换成resolve,这样后面的.then即可拿到后端的数据:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <button id="btn">获取数据</button>

    <!-- <script src="http://localhost:3000"></script> -->

    <script>
        

        function jsonp(url, cb){
            return new Promise((resolve, reject) => {
                const script = document.createElement('script');
                script.src = `${url}?cb=${cb}` // http://localhost:3000?cb=`callback`
                document.body.appendChild(script);

                window[cb] = (data) => {
                    resolve(data)
                }
                // callback()
                // {
                //     "callback": () => {}
                // }

            })
        }
        let btn = document.getElementById('btn');
        btn.addEventListener('click', () => {
            // 发请求
            jsonp('http://localhost:3000', 'callback')
            .then(res => {
                console.log('后端返回的结果:' + res);
            })
        });
    </script>
</body>
</html>

image.png

我给大家画张图来看一下:

image.png

  1. 借助script的src属性给后端发送一个请求,且携带一个参数('callback')

  2. 前端在window对象上添加了一个 callback 函数

  3. 后端接收到了这个参数'callback'后,将要返回前端的数据data和这个参数 'callback' 进行拼接成'callback(data)',并返回

  4. 因为window上已经有一个callback 函数,后端又返回了一个形如'callback(data)',浏览器会将该字符串执行成``callback 的调用`

总结

  1. ajax请求受同源策略的影响,但是<script>上的src属性不受同源策略的影响,且该属性也会导致 浏览器发送一个请求

缺点

  1. 必须要后端配合
  2. 只能用于get请求

jsonp是第一种解决跨域的常见手段,接下来我们来看看第二种:

Cors (Cross-Origin Resource Sharing)

我们上面提到,跨域是因为浏览器的同源政策,导致浏览器不接受(或者说拦截)后端的响应,那么Cors就是让浏览器不得不接受响应。

http协议中,任何一个http请求都由两部分组成,一个是请求头,一个是请求体。请求头放着关于此次http请求的描述信息,请求体中装着传递的参数及数据包。

后端返回的是响应头和响应体,我们在响应头中添加一个字段'Access-Control-Allow-Origin':'*'

这相当于设置一个白名单,告诉浏览器不要拒绝接受后端的响应,让浏览器认为是一个同源。

const http = require('http');

const server = http.createServer((req, res) => {
  // 跨域是浏览器不接受后端的响应
  // 想个办法,让浏览器不得接受
  res.writeHead(200, {
    'Access-Control-Allow-Origin': '*'  // 白名单
  })

  let data = {
    msg: "hello cors"
  }
  res.end(JSON.stringify(data)) // 向前端返回数据
})

server.listen(3000, () => {
  console.log('listening on port 3000');
})

再来看看前端的代码,前端的代码还是没有变化:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <button id="btn">获取数据</button>

    <script>
        let btn = document.getElementById('btn')
        btn.addEventListener('click', () => {
            fetch('http://localhost:3000', 'GET')
            .then(res => json())
            .then(res => {
                console.log(res)
            })
        })
    </script>
</body>
</html>

当我们点击按钮:

image.png

成功的拿到后端返回的数据。

那小伙伴们可能又会有疑问了,这样设置白名单的话,是不是所有的前端都可以访问我们的后端了?

这是因为我们实际在开发自己项目中,我们会偷懒,'Access-Control-Allow-Origin': '*',设置为 * 号,相当于全部地址设置为白名单。

但是我们应该写成自己前端的ip地址,例如我们这里写成'Access-Control-Allow-Origin': 'http://127.0.0.1:5500'。这样的话,我们的前端就能正常的访问后端,同样可以限制别人的前端来访问我们的后端。

总结

Cors (Cross-Origin Resource Sharing) --- 后端通过设置响应头来告诉浏览器不要拒绝接受后端的响应

node代理

node代理也是我们常用的一种解决跨域的手段。

我们就拿vue来举例一下,在我们写vue的项目时,可以使用一个node代理来解决跨域问题。我们用vite来创建一个后端项目


<template>
  
</template>

<script setup>
import axios from 'axios'
import { onMounted } from 'vue'

onMounted(() => {
  axios.get('http://localhost:3000')
  .then((res) => {
    console.log(res);
  })
})
</script>


<style scoped>

</style>

我们实现一个当一进去页面时,就朝后端发接口请求拿到数据。这里请求我们写在挂载阶段onMounted当中,当组件挂载完毕后就会触发回调函数,发送请求。

这里我们使用的是axios,我们需要安装一下依赖npm i axios

后端代码:

const http = require('http');

const server = http.createServer((req, res) => {
  let data = {
    msg: "hello cors"
  }
  res.end(JSON.stringify(data)) // 向前端返回数据
})

server.listen(3000, () => {
  console.log('listening on port 3000');
})

现在还是跨域的,因为我们前端和后端的地址不是同源的,它们的端口号不一样。

image.png

接下来我们就来到vite的配置项来解决跨域问题

vite.config.js

使用vite创建的项目是有一个vite.config.js的文件的:

image.png

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue()],
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:3000',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, '')
      }
    }
  }
})

来给大家解释一下这个vite的配置项是什么样的:

首选vite的源码是用node来写的,server是和网络请求相关的配置,然后我们进行一个代理proxy,只要我们朝/api这个路径发送请求时,例如:axios.get('/api'),那么就会将它转发到target这个路径下来。如果后端本就有/api的路径的话,那么就帮我们去掉。

我们改动一下前端代码:


<template>

</template>

<script setup>
import axios from 'axios'
import { onMounted } from 'vue'

onMounted(() => {
  axios.get('/api')
  .then((res) => {
    console.log(res);
  })
})
</script>


<style scoped>

</style>

再来页面输出看看:

image.png

看,我们成功拿到了数据。

我来给大家解释一下,同源策略只在浏览器上有,后端上是没有的。假设我们想要拿到气象局的天气预报数据,那么我们前端朝气象局的后端发送请求,那么一定是会跨域的。如果我们用node写一个后端,在后端中朝着气象局的后端去发送请求,这个过程中不会经过浏览器,所以不会发生跨域。那么我只需要在后端中再使用一下Cors,前端就能到自己的后端中拿到气象局的数据,这就是node代理

我们上面的vite就是这么干的,vite帮我们启动了一个node服务,且帮我们朝着localhost:3000发起请求,因为后端没有同源策略,所以,vite中的node服务能直接请求到数据,再提供给前端使用。

注意,此时的vite还帮创建出来的后端Cors了一下的。

但是,vite只适合我们在开发阶段使用,因为vite只是一个在开发阶段使用的构建工具,我们所写的项目最后是要打包上线的,最后vite的整个源码都会被清除掉。

总结

因为后端没有同源政策,vite创建一个node服务,所以node可以直接请求到后端的数据,再拿给前端。

但是vite只能在开发环境生效。

nginx代理

nginx代理Cors的机制差不多,都是通过配置请求头中的字段来实现的,这需要在后端服务器上安装ngnix,然后进行一个配置,只有源和配置的相等后端才进行响应。ngnix不是js语法,而是Linux语法,属于操作系统的。

绝大多公司都是通过nginx代理去解决跨域,我现在没有很好的办法来给大家演示,它主要用于项目上线时区去解决跨域,如果以后我写有关于项目部署的文章,再来跟大家好好聊聊。大家只要了解nginx就行了,它的机制跟Cors差不多。

以上四种就是我们常见的解决跨域的方法,它足够我们进行任何开发。不过面试官可能问你你还知道别的手段吗?那就是一些不常用的跨域方法,我们来简单介绍一下:

不常用的解决跨域的手段

domain

首先我们要知道iframe的作用,它允许我们在一个html中可以去嵌套另一个html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <h2>父级页面</h2>
    
    <iframe src="./child.html" frameborder="0"></iframe>
</body>
</html>

childr.html为我们的子级页面,它嵌套在当前html当中。

在iframe中,当父级页面和子级页面的子域不同时,通过设置document.domain='xx'来将xx定为基础域,从而实现跨域。

postMessage

当两个页面不再同一个域时,我们可以通过postMessage来实现数据传输。(在iframe中

a.html代码:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <h2>a.html</h2>

    <iframe src="http://127.0.0.1:5500/postMessage/b.html" frameborder="0" id="iframe"></iframe>

    <script>
        // 给b发送数据
        let iframe = document.getElementById('iframe')
        iframe.onload = function(){
            let data = {
                name: 'Tom'
            }
            iframe.contentWindow.postMessage(JSON.stringify(data), 'http://127.0.0.1:5500')
        }

        // 监听b传来的数据
        window.addEventListener('message', function(e){
            console.log(e.data);
        })
    </script>
</body>
</html>

b.html代码:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <h4>b.html</h2>
    
    <script>
        window.addEventListener('message', function(e){
            console.log(JSON.parse(e.data));
            
            if(e.data){
                window.parent.postMessage('我接受到', 'http://127.0.0.1:5500')
            }
        })
    </script>
</body>
</html>

大家可以试着打印一下,看看是否拿到了数据。

总结

JSONP、Cors、node代理、nginx代理这四种方法是我们常见的去解决跨域的手段,而这四种方法已经可以满足我们大部分的开发需求了。在面试过程中,大家主要去跟面试官说这四种方法,剩下的方法小伙伴们如果想到了也可以跟面试官讲。

写文章不易,如果帮助到了小伙伴们,可以给本文点赞收藏评论三连呀。有不懂的地方欢迎到评论区留言,我会及时回复。