likes
comments
collection
share

详谈跨域

作者站长头像
站长
· 阅读数 3

这两天一直被面试官问到跨域,但是自己从来没有系统性地总结过,本期文章就来把跨域这个问题给聊明白来,也希望对春招的各位有所帮助

同源策略-跨域

跨域就是浏览器的同源策略生效的时候

后端返回给浏览器的数据会被浏览器的同源策略给拦截下来

就拿百度官网为🌰,https://www.baidu.com,其实这个地址是被处理了,理应是这样https//192.168.31.45:8080/user,正常来讲一个url地址是由四个部分组成,也就是协议号https,域名192.168.31.45,端口号8080以及路径user

我们可以正常写一个前端,假设那个地址就是百度的后端地址,我们可以从那里拿到百度的数据,我们现在想想,这个情景科学吗?显然不科学,数据怎么能被不认识的人随便拿!因此浏览器针对这个问题,里面有个同源策略,也就是协议号-域名-端口号三部分必须是一样的,浏览器才认为你们是一家公司的,但凡三者有一个不同,那么浏览器就会把这个请求拦截下来,此时就是跨域

比如这样的地址就是符合同源https//192.168.31.45:8080/userhttps//192.168.31.45:8080/list,只有最后的路径不同

也就可以这样理解,字节的前端朝着字节后端发接口请求,字节就会响应回去,然后百度前端也朝着字节后端发接口请求,字节后端会响应会去,后端是不负责判断谁来请求的,是个人请求都会返回,但是这个过程中,浏览器的同源就发现了不对,因此把后端返回的响应给拦截了下来

详谈跨域

因此跨域发生在后端响应阶段

同源策略的目的就是一个安全性,怎么可能是个人都可以拿自己的数据

接下来我们需要解决跨域

回答这个之前我们先要明白为什么要解决跨域

为什么要解决跨域

假设我们在公司写项目,前端用vue写的,项目跑在http://192.168.31.1:8080,后端用go写的,跑在http://192.168.31.2:8080,尽管连接着一个wifi在同一个局域网内,其ip地址最后的数字还是不同的,两台设备的ip地址是不可能一样的,并且有时候,其端口号也是不同的

这是开发阶段,前后端需要联调,前端发现这个接口怎么都调用不了,问题就来了

所以为何要解决跨域,解决跨域方便程序员进行开发,开发阶段好调试

同源策略安全的同时,给程序员上了一层颈箍咒

解决跨域

解决跨域就是让同源策略发挥不了作用,后端响应回数据时可以正常作用

解决跨域有很多种方法,但是常用的就只有四种,这四种一定得掌握

JSONP

先简单实现下,前后端交互

// 目录
client
	index.html
server // npm init -y
	node_modules
	app.js
	package-lock.json
	package.json

先把app.js写成这样,完全可以接受吧~(引用的koa

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

前端的话,简单写个页面,点击按钮获取后端返回的数据

index.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>
    <button id="btn">获取数据</button>

    <script>
		let btn = document.getElementById('btn')
		btn.addEventListener('click', () => {
			// fetch发请求
			fetch('http://localhost:3000')
			.then(response => {
				return response.json() // fetch需要我们格式化数据
			})
			.then(res => {
				console.log(res)
			})
		})
    </script>
</body>
</html>

这样就实现了从后端拿数据,好,我们现在用liver server跑一下,点击按钮,出现报错!

详谈跨域

has been blocked by CORS就是出现了跨域

当我们自己写全栈项目的时候,因为跨域导致自己的前端都无法调用自己的后端,感觉很气!

这个代码我改巴改巴,我把fetch请求注释掉,用scriptsrc来请求,你会发现,不再同源了

<!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发请求
			// fetch('http://localhost:3000')
			// .then(response => {
			// 	return response.json() // fetch需要我们格式化数据
			// })
			// .then(res => {
			// 	console.log(res)
			// })
		})
    </script>
</body>
</html>

详谈跨域

嚯~居然不报错!

聪明的你这个时候就发现了,这不就是我们引入第三方源码的手段嘛,就那个CDN引入,引入那个资源也没有报错!

当我们使用ajax发接口请求的时候一定是会受同源的影响,但是我们通过scriptsrc去请求数据,并没有受到同源的影响

其实不法分子解决跨域就是通过这个手段

如果这个手段也受同源的影响,前端代码就写不了了,根本无法引入第三方的库

好,现在我们就钻这个空子

自己封装一个函数jsonp用于发接口请求,在函数里面自己生成一个script标签,这个函数可以接收urlcb参数,并且给script挂上一个src属性,放上urlcb。然后通过appendChildscript放到body里面去,这个时候就已经保证了这个函数可以利用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>

        function jsonp (url, cb) {
            return new Promise((resolve, reject) => {
                const script = document.createElement('script') // 可以创建h5的任意标签
                script.src = `${url}?cb=${cb}` // http://localhost:3000?cb='callback' 前端写死一个字符串传给后端 
                document.body.appendChild(script) // 把script添加到body中去,浏览器会自动发请求了
            })
        }

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

点击按钮,确实有个网络请求

详谈跨域

既然前端发送了请求,那么此时后端一定收到了请求,并且里面把前端传进来的callback字符串传了过来

我们可以在后端的main方法中,打印下ctx.query

详谈跨域

好,现在对后端代码改巴改巴,我把前端传给我的callback再带上自己数据返回给前端,如下,用的字符串模板拼接~

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

const main = (ctx, next) => {
    console.log(ctx.query);

    const data = '给前端的数据'
    const cb = ctx.query.cb // callback字符串
    const str = `${cb}('${data}')` // callback('给前端的数据')字符串
    
    ctx.body = str
}

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

这个时候前端收到数据是报错的,因为cb还没有定义呢~

详谈跨域

好,现在来到前端,我在全局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>

        function jsonp (url, cb) {
            return new Promise((resolve, reject) => {
                const script = document.createElement('script') // 可以创建h5的任意标签
                script.src = `${url}?cb=${cb}` // http://localhost:3000?cb='callback' 前端写死一个字符串传给后端 
                document.body.appendChild(script) // 把script添加到body中去,浏览器会自动发请求了

                window[cb] = (data) => { // 把callback挂到window上去,然后值是一个函数体
                    console.log(data);
                }
            })
        }

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

我们打印在这个参数data,就是后端往cb括号中放入的数据,也就是那句话

详谈跨域

既然有打印,说名这个cb函数被触发了,可是前端并没有触发它,那就只能是后端触发的,并且传了参数进来!

我现在将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>

        function jsonp (url, cb) {
            return new Promise((resolve, reject) => {
                const script = document.createElement('script') // 可以创建h5的任意标签
                script.src = `${url}?cb=${cb}` // http://localhost:3000?cb='callback' 前端写死一个字符串传给后端 
                document.body.appendChild(script) // 把script添加到body中去,浏览器会自动发请求了

                window[cb] = (data) => { // 把callback挂到window上去,然后值是一个函数体
                    resolve(data);
                }
            })
        }

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

详谈跨域

我们没有跨域,但是也拿到了后端返回的数据~

解释下整个过程:

  1. 借助scriptsrc属性给后端发一个请求,且携带一个参数callback
  2. 前端在window上添加了一个callback函数;
  3. 后端接收到这个参数callback后,将要返回给前端的数据data和这个参数callback进行拼接,成callback(data),并返回给前端;
  4. 因为window上已经有一个callback函数,后端又返回了一个形入callback(data),浏览器会将字符串执行成callback的调用

详谈跨域

因此JSONP核心理念就是借助script标签上的src属性不受同源策略的影响这一机制,来实现跨域

总结

  1. ajax请求受到同源策略的影响,但是script上的src属性不受同源策略的影响,并且该属性也会导致浏览器发送一个请求
  2. 缺点:1. 必须后端配合(拿到参数再拼接回去);2. 只能用于get请求,浏览器执行scriptsrc请求默认就是get方式(正常开发很多接口都是post);

Cors

Cors(Cross-Origin Resource Sharing)

http协议中,每个请求都可以拆分成两部分,一个请求头,一个请求体

  • 请求头比较小,里面包含了这个请求的基本信息,从哪去哪儿
  • 请求体就放的是参数,数据包

后端返回的就是响应头和响应体,这个时候我们对这个响应头写入一些参数,告诉浏览器我后端的数据所有的前端请求都可以拿走

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')
            .then(res => res.json())
            .then(res => {
                console.log(res);
            })
        })
    </script>
</body>
</html>

详谈跨域

好了,这样就实现了解决跨域,但是目前的写法比较偷懒,肯定不能写*,实际开发不可能允许所有的接口请求

比如我现在前端写在本地,就是localhost,所以把*换成http://127.0.0.1:5501

多个ip需要接口,就多配几个白名单

总结

后端通过设置响应头来告诉浏览器不要拒绝接收后端的响应

这个方法明显比JSONP好用多了,前端不需要任何操作,只需要后端简单配置下cors即可

node代理

假设我现在写了个前端,希望拿到网易云的数据,我可以从前端向网易云的后端拿,我还可以选择自己写个后端,自己的后端去往网易云的后端拿数据,这个过程中不经过浏览器,就不会跨域,后面就是自己的前端从自己的后端拿数据cor下就好,这就是node代理

vite这个构建工具就是用node写的,我们写vue项目的时候,就可以用node代理的形式去解决跨域问题

好,我现在用vite模仿下,App.vue我让其首页挂载完毕就朝着后端发请求

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

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

这里用的axios发请求,axios需要自己安装npm i axios

然后自己的后端如下,没有使用cors,一定会发生跨域

const http = require('http')

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

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

详谈跨域

配置vite.config.js

如何解决呢?我们直接来到vite.config.js文件中配置server,如下

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

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue()], // vite源码是node写的 
  server: { // 和网络请求相关的配置
    proxy: {
      '/api': { // 只要前端是向/api发请求,都是发到target,比如axios.get('/api')
        target: 'http://localhost:3000',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, '') // 后端路径本身就有/api就把它去掉
      }
    }
  }
})

vite解决跨域:开发服务器选项 | Vite 官方中文文档 (vitejs.dev)

这个配置的意思是,只要前端朝/api发请求,那么就会转发到target中,也就是localhots:3000,然后改变源,如果后端路径本身就有/api,那么就重写为空

好了,所以现在只需要把前端的请求地址改成/api即可

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

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

像是修改完配置文件,都需要项目重新启动下

好了,成功解决跨域,从后端拿到数据

详谈跨域

好,问题来了,vite只是我们开发阶段使用的构建工具而已,项目最后是要打包上线的,因此到了生产阶段,vite打包后的这个配置信息是会消失的,不对,是整个vite的源码都会被剔除掉

因此这个方法只适用于开发环境,上线的跨域只能用别的方法

总结

vite帮我们启动了一个node服务,且帮我们朝着http://locahost:3000发请求,因为后端之间没有同源策略,所以,vite中的node服务能直接请求到数据,再提供给前端使用

但是给到前端依旧会跨域,只是因为里面已经自带cors了,看不出来

缺点:只能在开发环境中生效

截至目前,前端是没有一个优雅的手段可以阶段跨域的,JSONP很麻烦,而且只能get,然后Cors是后端干的,然后node只能开发阶段生效,其实这个三个方法通常都是用在开发环境下

nginx代理

这个机制和Cors差不多,做白名单的配置,都是配置请求头,需要后端在服务器上安装nginx,实现所有的请求可以实现nginx代理,nginxlinux的语法,而非js

这个方案可以解决生产环境下的跨域,也就是可以项目上线且不跨域,公司项目一般就是用这种方法解决跨域

nginxcors机制差不多,为何不用nginx呢?

如何你写了三个后端项目,那么三个后端项目都需要配置Corsnginx可以一起配置掉

具体实现以后再出期文章详聊,涉及到项目上线

其实这四种跨域方案足够你项目的开发了,不过面试官可能会问你还有吗,那些就是些不常用的方法

面试官:还有吗?

domain

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

iframe的作用就是一个html可以嵌套另一个html

比如我这里,两个页面,父级页面中通过iframe嵌套了个子页面,父级页面定义个参数,子级页面打印这个参数,当然得实现非同源的时候打印才能证明实现跨域,用live-server是同源运行的

live-server安装:npm i -g live-server

index.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="http://127.0.0.1:5501/%E9%9D%A2%E8%AF%95%E9%A2%98/%E7%99%BE%E5%BA%A6%E9%9D%A2%E8%AF%95%E9%A2%98/domain/child.html" frameborder="0"></iframe>

    <script>
        document.domain = '127.0.0.1'

        var user = 'admin'
    </script>
</body>
</html>

child.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>子页面</h4>

    <script>
        document.domain = '127.0.0.1'

        console.log(window.parent.user);
    </script>
</body>
</html>

比如当你希望在www.example.com中使用api.example.comapi,为防止跨域,就可以两个页面设置相同的document.domain,也就是基础域,这样就不会跨域

postMessage

实现一个深拷贝可以借助管道通信,也就是postMessage,还有个structured clone,这是js自带的

postMessage主要作用就是用来做管道通信的,既然涉及到通信,那就会遇到跨域的问题

按道理两个页面交互需要一个点击事件,这里我们不用点击事件,ab页面交互信息如下,不发生跨域

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:5501/%E9%9D%A2%E8%AF%95%E9%A2%98/%E7%99%BE%E5%BA%A6%E9%9D%A2%E8%AF%95%E9%A2%98/postMessage/b.html" frameborder="0" id="iframe"></iframe>

    <script>
        // 给b发送数据
        let iframe = document.getElementById('iframe')
        iframe.onload = function() {
            let data = {
                name: 'Dolphin'
            }
            iframe.contentWindow.postMessage(JSON.stringify(data), 'http://127.0.0.1:5501')  // a向b发送这个data数据
        }
        // 监听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</h4>

    <script>
        window.addEventListener('message', function(e) {
            console.log(JSON.parse(e.data));  // 可以拿到a的数据

            if (e.data) { // 回应a,拿到数据
                setTimeout(function() {
                    window.parent.postMessage('我接受到了', 'http://127.0.0.1:5501')
                }, 2000)
            }
        })
    </script>
</body>
</html>

详谈跨域

还有个websocket来解决跨域,以后再来单独详聊

最后

正常来讲,JSONPCorsnode代理nginx足够解决跨域了,如果面试官问你,还能答出个几个冷门方法就再合适不过了

如果你对春招感兴趣,可以加我的个人微信:Dolphin_Fung,我和我的小伙伴们有个面试群,可以进群讨论你面试过程中遇到的问题,我们一起解决

另外有不懂之处欢迎在评论区留言,如果觉得文章对你学习有所帮助,还请”点赞+评论+收藏“一键三连,感谢支持!