面试被问到跨域还不会答?来看看这篇文章

260 阅读5分钟

引言

当你的团队在写一个项目时,你写前端,你的同事写后端,他给了你一个接口,说这是首页的地址朝这里发请求能拿到数据,如果你直接用你的前端朝那个后端接口发请求,是拿不到数据的,因为浏览器存在一个同源策略,想要解决这个问题必须使用跨域的解决方案

同源策略

同源策略是浏览器实施的一种安全策略,用于限制一个源上的项目与另一个源上的项目进行资源的交互,保护后端资源安全。这里的“同源”是指协议、域名和端口号三部分都一样。

前端和后端运行在不同的端口上,它们是不同源的。当前端向后端请求数据时,浏览器能把这个请求发送出去,但是会拒收后端发送回来的响应

跨域的实现方案

此时我有一份前端代码,它在页面上什么都不展示,只朝 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>
    <script>
        const xhr = new XMLHttpRequest()
        xhr.open('GET', 'https://localhost:3000')
        xhr.send()
        xhr.onreadystatechange = function() {
            if (xhr.readyState === 4 && xhr.status === 200) {
                console.log(xhr.responseText)
            }
        }
    </script>
</body>
</html>

还有一份后端代码,监听 3000 端口,结束响应时发送 ‘hello,world’给客户端

const http = require('http')

http.createServer((req, res)  => {
    res.end('hello,world')
}).listen(3000)

事实上仅仅如此浏览器是收不到后端返回的响应的,为了解决跨域问题,我们可以采用以下几种解决方案——

JSONP

不难发现,我们通过<img src="""> <video src=""> <script src=""> <link rel="stylesheet" href="http://XXXX.css"> 这些标签发送的请求都能成功接收到服务端返回的资源,因为这些标签的 srchref 属性不受同源策略影响

为了可以成功朝 3000 端口发送请求再收到响应,并能对响应的数据进行处理,总体来说进行以下步骤:

  1. 借助 script 标签的 src 属性来发送请求
  2. 给后端携带一个参数 callback 并在前端定义 callback 的函数体
  3. 后端返回 callback 字符串形式的函数调用,并将要响应的数据作为 callback 的实参
  4. 当浏览器接收到响应后,就会触发全局的 callback 函数,从而让 callback 以参数形式接收到后端响应的数据

代码实现如下:

<!-- 前端 -->
<body>
    <script>
        function jsonp(url, cb) {
            return new Promise((resolve, reject) => {
                // 创建一个 script 标签
                const script = document.createElement('script')
                
                // 把url交给script 的 src
                script.src = `${url}?cb=${cb}`
                
                // 全局创建一个函数体cb
                window[cb] = function(data) {
                    resolve(data)
                }
                
                // 插入到 HTML 中,否则无法发送请求
                document.body.appendChild(script)
            })
        }

        jsonp('http://localhost:3000', 'callback').then(res => {
            console.log(res);     // res就是我们所需的数据
        })
    </script>
</body>  
// 后端
const http = require('http');

http.createServer(function(req, res) {

    // 从请求的url中获取参数
    const query = new URL(req.url, `http://${req.headers.host}`).searchParams

    // 如果查询参数中存在 cb
    if(query.get('cb')) {
        const cb = query.get('cb')
        const data = "hello world"
        const result = `${cb}("${data}")`  // 字符串形式的函数调用 'cb("hello world")'
        res.end(result)
    }
}).listen(3000)

注意!当执行到以下两行代码时,能凭空创建一个 script 标签,并给 src 赋值,但是这个 script 只有被添加到 html 中的时候才会去请求资源

const script = document.createElement('script')
script.src = url

而如果是创建了一个 Image,不管这个 Image 是被添加到了 html 中还是只是在 js 中,只要它的 src 有值,都会去请求图片资源

const image = new Image()
image.src = url

JSONP 方法用的也不是很多,因为有两个缺陷

  1. 需要后端配合 —— 后端不能直接返回数据,而是要返回一个字符串形式的函数调用,把数据塞进这个函数作为参数
  2. 只能发送GET请求——但凡后端的接口是以POST方式接收请求,就用不了这种方法

cors (Cross-Origin Resource Sharing)

同源策略是不允许接收响应而不是不允许发送请求,所以可以通过在响应头中设置某些字段来允许满足条件的请求跨域,比如设置 Access-Control-Allow-Origin 字段允许来自某个源的请求跨域,比如设置 Access-Control-Allow-Methods 字段允许'GET'或者'POST'方式的请求跨域

例如,以下后端允许来自本机 5500 端口的 POST 请求可以跨域:

// 后端
const http = require('http')

const server = http.createServer((req, res)  => {
    // 设置响应头
    res.writeHead(200, {
        'Access-Control-Allow-Origin': 'http://127.0.0.1:5500', 
        'Access-Control-Allow-Methods': 'POST'
    })
    
    res.end('hello,world')
}).listen(3000)

代理 和 nginx

通过设置一个 node 后端作为中间层,前端发送的请求首先到达这个中间层,然后再由中间层将请求转发到目标服务器。响应过程也是如此,服务器先响应给中间层,中间层再将响应数据发送回前端。

这个中间层就起到了一个代理的作用。这样,浏览器看到的是同源请求,从而绕过了CORS限制。

假设前端现在要将请求发送给 http://192.168.1.63:3000 这个后端,就可以先由本机的 3001 端口作一个代理

<!-- 前端 -->
<body>
    <script>
        const xhr = new XMLHttpRequest()
        xhr.open('GET', 'https://localhost:3001')
        xhr.send()
        xhr.onreadystatechange = function() {
            if (xhr.readyState === 4 && xhr.status === 200) {
                console.log(xhr.responseText)
            }
        }
    </script>
</body>
// 后端
const http = require('http')

// 监听本机3001端口,有新请求时调用回调函数
http.createServer((req, res)  => {

    // 设置响应头,以允许前端应用访问响应内容
    res.writeHead(200, {
        'Access-Control-Allow-Origin': '*'
    })

    // 转发请求到目标服务器,并处理响应
    http.request({
        host: '192.168.1.63',
        port: 3000,
        path: '/',
        method: 'GET',
        headers: {}
    }, proxyRes => {
        proxyRes.on('data', chunk => {
            res.end(chunk.toString())
        })
    }).end()
    
}).listen(3001)

现在企业开发中如果要使用代理的方法解决跨域的问题,大多采用 nginx 代理服务器。这个代理不是用 node 写的,它被封装成了一个软件,在服务器装上后写入一点配置,同样实现上述最基本的 node 代理效果,并且更强大更灵活。

nginx.png (图片来源网络)

WebSocket

WebSocket 是一种在单个 TCP 连接上进行全双工通信的协议。与 HTTP 不同,WebSocket 提供了一种持久的、低延迟的双向通信机制,适用于实时应用,如在线聊天、实时游戏、股票行情等。

特点:

  • 能跨域:WebSocket 协议天生不受同源策略的限制
  • 全双工通信:WebSocket 允许客户端和服务器之间同时发送和接收数据,而不需要像 HTTP 那样每次请求都需要重新建立连接。
  • 持久连接:一旦 WebSocket 连接建立,它会保持打开状态,直到显式关闭或网络中断。

利用 WebSocket 协议不受同源策略限制的特点,我们可以用它来发送跨域的请求、接收到响应并能对响应回的数据做处理

<!-- 前端 -->
<body>
    <script>
        function myWebSocket(url, params = {}) {
            return new Promise((resolve, reject) => {
            
                // 创建一个 WebSocket 对象
                const socket = new WebSocket(url)

                // 当 WebSocket 连接成功打开时触发回调函数
                socket.onopen = () => {
                
                    // 给后端传参数,只能是字符串
                    socket.send(JSON.stringify(params))
                }

                // 监听后端消息,接收到后端消息时触发回调函数
                socket.onmessage = function(e)  {
                    resolve(e.data)
                }
            })
        }

        myWebSocket('ws://localhost:3000', {age: 18}).then(res => {
            console.log(res);  // 响应回的数据
        })
    </script>
</body>

先初始化一个后端项目;由于node 中不自带WebSocket库,所以要 npm 安装 ws 模块

npm init -y
npm i ws
const WebSocket = require('ws')

// 创建一个 WebSocket 服务器,监听 3000 端口
const ws = new WebSocket.Server({port: 3000});

// 监听连接,当有新的连接时触发
ws.on('connection', (obj) => {

    // 接收到前端消息时触发
    obj.on('message', (data) => {
    
        // 向前端发送 'hello socket' 消息
        obj.send('hello socket')
    })
})

postMessage

postMessage 是 HTML5 引入的一个 API,允许来自不同源的脚本采用异步方式进行有限的通信

现有首页和详情页两个页面,通过在首页放置<iframe src=""></iframe>标签来内嵌详情页,形成了一种父级窗口和子级窗口的关系。

将父窗口跑在本机5500端口,子窗口跑在本机8080端口,如果两个窗口要直接通信,本来是会被同源策略限制的,但是使用 postMessage 方法可以跨域

父窗口

  • 获取 iframe 的 Window对象
  • 通过 postMessage API 向iframe发送数据
  • 通过 window.onmessage 监听并接收消息
<body>
    <h2>首页</h2>
    <!-- 内嵌网站 -->
    <iframe id="frame" src="http://127.0.0.1:8080" width="100%" height="500px" frameborder="0"></iframe>
    
    <script>
        // 创建要传递的消息对象
        let obj = {name: '今天一定晴', age: 18}

        // iframe 加载完之后向其发送消息
        document.getElementById('frame').onload = function() {
        
            // 获取iframe DOM的 window 对象,向其发送消息
            this.contentWindow.postMessage(obj, 'http://127.0.0.1:8080')

            // 接收其他窗口传递过来的消息
            window.onmessage = function(e) {
                console.log(e.data)
            }
        }

    </script>
</body>

子窗口

  • 通过 window.onmessage 监听并接收消息
  • 结构出数据,插入到DOM结点中
  • 通过postMessage给父窗口传递消息
<body>
    <h3>详情页 -- <span id="title"></span> </h3>

    <script>
        let title = document.getElementById('title')
    
        window.onmessage = function(e) {
            
            let {data: {name, age}, origin} = e
            title.innerText = name + age

            // 给父窗口传递消息
            e.source.postMessage(`今天一定晴 ${++age} 岁`, origin)
        }
    </script>
</body>

image.png

document.domain

当两个页面通过 iframe 进行嵌套,且两个页面的二级域名一致时,能通过设置相同 document.domain 值的方法来进行跨域

  • 什么是二级域名?

一个完整的域名可以被分为多个部分,每个部分由点(.)分隔。从右到左,这些部分依次是顶级域名、二级域名、三级域名等。每个根域名左边的部分就是它的子域名 image.png

父窗口

<body>
    <h2>首页</h2>
    <!-- 内嵌网站 -->
    <iframe id="frame" src="http://127.0.0.1:5500/postMessage/detail.html" width="100%" height="500px" frameborder="0"></iframe>
   
    <script>
        // 设置当前页面的 document.domain 为 '127.0.0.1'
        document.domain = '127.0.0.1' 

        // iframe 加载完之后执行函数
        document.getElementById('frame').onload = function() {
            console.log(this.contentWindow.data);
        }
    </script>
</body>

子窗口

<body>
    <h3>详情页 </h3>

    <script>
        document.domain = '127.0.0.1'
        var data = '小晴'
    </script>
</body>

当两个页面属于同一个根域名下的不同子域名时,可以通过设置 document.domain 来实现一定程度的跨域资源共享。此处首页和详情页都设置了document.domain为相同的127.0.0.1,绕开了同源策略。

总结

  • JSONP:是一种利用了 <script> 标签不受同源策略限制的特点来请求数据的技术。前端定义 callback 的函数体并将此作为参数传给后端,后端返回 callback 字符串形式的函数调用,并将要响应的数据作为 callback 的实参,前端会触发全局的 callback 函数,从而让 callback函数 以参数形式接收到后端响应的数据。缺点是只能支持 GET 请求,并且存在一定的安全风险。

  • cors: 服务器通过设置响应头 Access-Control-Allow-Origin和  Access-Control-Allow-Methods来指定允许访问的源和方法。需要服务器端的支持和配置

  • 代理和nginx:客户端在同源的服务器上设置一个代理,该代理负责转发请求到目标服务器并转发目标服务器的响应,客户端只与同源的代理服务器通信,因此不会触发同源策略。需要额外的服务器资源来运行代理服务

  • WebSocket:WebSocket 提供了一个全双工的通信通道,一旦建立连接,就不再受同源策略限制。通过 WebSocket 对象和服务器它们自带的 sendonMessageon 等方法进行消息的发送和接收。需要服务器端支持 WebSocket 协议。

  • postMessage:通常用于 iframe 和父窗口之间的通信,父窗口获取 iframe 的 Window对象,通过 postMessage API 向iframe发送数据,而iframe 通过 onmessage 监听或接收消息。反之 iframe 也可以通过 postMessage 向父窗口发送消息。

  • document.domain:当两个页面的根域名相同时,可以通过设置 document.domain 为共同的父域名来实现跨子域的通信,因为这两个页面会被视为同源