基础 03 谈谈前端跨源问题及解决方法

所谓跨源,是指一个域下的文档或脚本试图去请求另一个域下的资源。

广义的跨源:

  • 资源跳转:a 标签、重定向、表单提交;

  • 资源嵌入:<link><script><img><frame> 等标签,background:url()@font-face() 等文件外链;

  • 脚本请求:Js 发起的 Ajax 请求、DomJs 对象的跨源操作(如iframe)等;

狭义的跨源:也就是我们常说的跨源问题,是指由浏览器同源策略限制的一类请求场景。典型的是 Ajax 跨源请求。

同源和同站

域名级数是指一个域名由多少级组成,域名的各个级别被 . 分开,简而言之,有多少个点就是几级域名。

一般来说就是比如 www.baidu.com

  • 顶级域名: .com;

  • 一级域名(也有称顶级域名): baidu .com;

  • 二级域名: www.baidu .com、aaa.baidu .com、bbb.baidu .com 二级域名;

同源的定义

两个 URL 的协议、域名、端口都相同的话,则这两个 URL 是同源。

同源的例子:

http://example.com/app1/index.html http://example.com/app2/index.html

同源。协议、域名都相同

http://example.com:80 http://example.com

同源。协议、域名都相同,服务器默认的http端口是80

不同源的例子:

http://example.com/app1 https://example.com/app2

不同源。协议不同

http://example.com http://www.example.com http://myapp.example.com

不同源。域名不同

http://example.com http://example.com:8080

不同源。端口不同

注意: IE 浏览器中两个相互之间高度互信的域名,如公司域名,不受同源策略限制。

注意: IE 浏览器未将端口号纳入到同源策略的检查中。因此,同协议、同域名,但是端口不同的链接不受同源策略限制。如:

https://company.com:80/index.html
https://company.com:81/index.html

同站的定义

同站的判断宽松:只要两个 URL 的 eTLD+1 相同即可,不需要考虑协议和端口

其中,eTLD 表示有效顶级域名,注册于 Mozilla 维护的公共后缀列表(Public Suffix List)中,例如,.com、.co、.uk、.github.io 等。而 eTLD+1 则表示,有效顶级域名+二级域名,例如 taobao.com 等。

比如:www.taobao.comwww.baidu.com 是跨站,www.a.taobao.comwww.b.taobao.com 是同站,a.github.iob.github.io 是跨站。

同源策略作为浏览器的安全基石,其同源判断是比较严格的,而相对而言,Cookie 中的同站判断就比较宽松。

什么是同源策略?

**同源策略(same-origin policy)**是一个重要的安全策略,它用于限制一个源的文档或者它加载的脚本如何能与另一个源的资源进行交互。它的目的,是为了保证用户信息的安全,防止恶意的网站窃取数据。如:CSRF(跨站请求伪造,Cross-site request forgery)攻击。

1995年,同源政策由 Netscape 公司引入浏览器。目前,所有浏览器都实行这个政策。随着互联网的发展,同源政策越来越严格。

如果非同源,共有三种行为受到限制:

  • Cookie、LocalStorage 和 IndexDB 无法读取;

  • DOM 无法获得;

  • Ajax 请求不能发送。

但是以下标签通常是允许跨源加载资源的:

  • <img src=''>

  • <video src=''>

  • <audio src=''>

  • <link href=''>

  • <script src=''>

  • <iframe src=''>

另外,还有一些注意点:

  • <script src='' /> 标签嵌入跨源脚本,语法错误信息无法被当前页面脚本捕捉。

  • @font-face 引入的字体,在不同浏览器有不同的限制。

  • JavaScript API 访问跨源 window 对象时,Window、Location 对象的方法和属性受限制。具体如下:

    • Window 可跨源访问:window.blur、window.close、window.focus、window.postMessage

    • Window 只读属性:window.closed、window.frames、window.length、window.opener、window.parent、window.self、window.top、window.window

    • Window 读/写属性:window.location

    • Location 可跨源访问:location.replace、location.href = ''(可写,不可读)

常见的跨源场景

Cookie 是服务器写入浏览器的一小段信息,只有同站的网页才能共享。

注意:**浏览器本身是允许 cookie 同站通信的。**即,只要两个 URL 的 eTLD+1 相同即可,不需要考虑协议和端口

两个网页一级域名相同,只是二级域名不同,浏览器允许通过设置 document.domain 共享 Cookie。

如:A 网页是 http://w1.example.com/a.html,B 网页是 http://w2.example.com/b.html,那么只要设置相同的 document.domain,两个网页就可以共享 Cookie。

document.domain = 'example.com'

另外,服务器在设置 Cookie 的时候,指定 Cookie 的所属域名为一级域名+二级域名。这样的话,其子域名不用做任何设置,都可以读取这个 Cookie。

Set-Cookie: key=value; domain=.example.com; path=/

iframe跨源

iframe 是在当前页面嵌入其他页面,可以是同源,也可以是非同源页面。每个 iframe 有自己的窗口,即有自己的 window 对象。iframe 的脚本可以获得父窗口和子窗口。但是,只有在同源的情况下,父窗口和子窗口才能通信;如果跨源,就无法拿到对方的 DOM。

比如,父窗口运行下面的命令,如果 iframe 内嵌的页面非同源,就会报错:

document.getElementById("myIFrame").contentWindow.document
// Uncaught DOMException: Blocked a frame from accessing a cross-origin frame.

反之亦然,子窗口获取主窗口的 DOM 也会报错。

window.parent.document.body
// Uncaught DOMException: Blocked a frame with origin "http://xxx.xxx.x.xxx:xxxx" from accessing a cross-origin frame.

这种情况不仅出现在 iframe 窗口,还出现在 window.open 方法打开的窗口,只要跨源,父窗口与子窗口之间就无法通信。

如果两个窗口一级域名相同,只是二级域名不同,那么可以上述修改 document.domain 的方法规避同源政策。

LocalStorage跨源

LocalStorage 和 IndexedDB 都是受同源政策限制的,无法跨源访问。

AJAX跨源

浏览器的同源政策规定,AJAX 请求只能发给同源的网址,否则就报错。

1.CORS(跨源资源共享)

CORS,跨源资源共享(Cross-origin resource sharing), 是一个 W3C 标准。它允许浏览器向非同源的服务器,发出 XMLHttpRequest 请求,从而克服了 Ajax 只能同源使用的限制。

这是解决跨源 Ajax 请求的根本方法。

CORS 需要浏览器和服务器同时支持。目前,所有浏览器都支持该功能,IE 浏览器不能低于IE10。

整个 CORS 通信过程都是浏览器自动完成,不需要用户参与。对于开发者来说,CORS 通信与同源的 Ajax 通信没有差别,代码完全一样。浏览器一旦发现 Ajax 请求跨源,就会自动添加一些附加的头信息,有时还会多出一次附加的请求,但用户不会有感觉。

因此,实现 CORS 通信的关键是服务器。只要服务器实现了 CORS 接口,就可以跨源通信。

CORS 允许在下列场景中使用跨源请求:

  • 由 XMLHttpRequest 或 Fetch 发起的跨源请求;

  • @font-face 引用的跨源字体资源;

  • WebGL 贴图;

  • 使用 drawImage 将 Images/video 资源绘制到 canvas。

CORS 实现

客户端无需处理,浏览器会自动在请求头中增加一些字段。 如果要发送跨源 Cookie,请求头要设置 withCredentials 属性。

服务端响应头要设置 Access-Control-Allow-OriginAccess-Control-Allow-HeadersAccess-Control-Allow-Credentials属性。

请求头字段

当浏览器发起跨源请求时,会自动添加如下请求头字段。请注意,这些字段无须开发者手动添加。

  • Origin:表明预检请求或实际请求的源站,值为源站 URI。它不包含任何路径信息,只是服务器名称。

  • Access-Control-Request-Method:用于预检请求。其作用是,将实际请求所使用的 HTTP 方法告诉服务器。

    Access-Control-Request-Method: <method>
  • Access-Control-Request-Headers:用于预检请求。其作用是,将实际请求所携带的首部字段告诉服务器。

    Access-Control-Request-Headers: <field-name>[, <field-name>]*

响应头字段

  • Access-Control-Allow-Origin:origin | *,设置允许跨源的源,origin 指定了允许访问该资源的外域 URI。对于不需要携带身份凭证的请求,服务器可以指定该字段的值为通配符 *,表示允许来自所有域的请求。

  • Access-Control-Expose-Headers:让服务器把允许浏览器请求头中的字段放入白名单。

    在跨源访问时,XMLHttpRequest 对象的 getResponseHeader() 方法只能拿到一些最基本的响应头字段,Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma,如果要访问其他字段,则需要服务器设置本响应头。

  • Access-Control-Allow-Credentials:true | false:指定了当浏览器的 credentials 设置为 true 时是否允许浏览器读取 response 的内容。当用在对 preflight 预检测请求的响应中时,它指定了实际的请求是否可以使用 credentials

    请注意:简单 GET 请求不会被预检;如果对此类请求的响应中不包含该字段,这个响应将被忽略掉,并且浏览器也不会将相应内容返回给网页。

withCredentials 属性

CORS 请求默认不包含 Cookie 信息(以及 HTTP 认证信息等)。如果需要包含 Cookie 信息,客户端和服务器都要加配置:

  • 服务端响应头设置 Access-Control-Allow-Credentials: true

  • 客户端请求头设置 withCredentials = true

否则,即使服务器同意发送 Cookie,浏览器也不会发送。或者,服务器要求设置 Cookie,浏览器也不会处理但是,如果省略withCredentials 设置,有的浏览器还是会一起发送 Cookie。这时,可以指定 withCredentials = false

需要注意的是:**如果要发送 Cookie,Access-Control-Allow-Origin 就不能设为星号,必须指定明确的、与请求网页一致的域名。**同时,Cookie 依然遵循同源政策,只有用服务器域名设置的 Cookie 才会上传,其他域名的 Cookie 并不会上传,且(跨源)原网页代码中的 document.cookie 也无法读取服务器域名下的 Cookie。

两种请求

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

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

  • 请求方法是这三种方法之一:HEAD、GET、POST

  • HTTP 的头信息不超出以下几种字段。

    • Accept

    • Accept-Language

    • Content-Language

    • Last-Event-ID

    • Content-Type:只限于三个值application/x-www-form-urlencodedmultipart/form-datatext/plain

凡是不同时满足上面两个条件,就属于非简单请求。一句话,简单请求就是简单的 HTTP 方法与简单的 HTTP 头信息的结合。

这样划分的原因是,表单在历史上一直可以跨源发出请求。简单请求就是表单请求,浏览器沿袭了传统的处理方式,不把行为复杂化,否则开发者可能转而使用表单,规避 CORS 的限制。对于非简单请求,浏览器会采用新的处理方式。

简单请求的跨源流程

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

如果 Origin 指定的源,不在许可范围内,服务器会返回一个正常的 HTTP 回应。浏览器发现这个回应的头信息没有包含 Access-Control-Allow-Origin 字段,就知道出错了,从而抛出一个错误,被 XMLHttpRequestonerror 回调函数捕获。注意,这种错误无法通过状态码识别,因为 HTTP 回应的状态码有可能是 200。

非简单请求的跨源流程

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

非简单请求的 CORS 请求,会在正式通信之前,增加一次 HTTP 查询请求,称为“预检”请求(preflight)。浏览器先询问服务器,当前网页所在的域名是否在服务器的许可名单之中,以及可以使用哪些 HTTP 动词和头信息字段。只有得到肯定答复,浏览器才会发出正式的 XMLHttpRequest 请求,否则就报错。这是为了防止这些新增的请求,对传统的没有 CORS 支持的服务器形成压力,给服务器一个提前拒绝的机会,这样可以防止服务器大量收到 DELETEPUT 请求,这些传统的表单不可能跨源发出的请求。

预检请求的回应:

上述讲到的“预检”请求,用的请求方法是 OPTIONS,表示这个请求是用来询问的。头信息里面包含:

  • Origin,表示请求来自哪个源。

  • Access-Control-Request-Method,列出浏览器的 CORS 请求会用到哪些 HTTP 方法,上例是PUT

  • Access-Control-Request-Headers,该字段是一个逗号分隔的字符串,指定浏览器 CORS 请求会额外发送的头信息字段。

服务器收到“预检”请求以后,检查了 OriginAccess-Control-Request-MethodAccess-Control-Request-Headers 字段以后,确认允许跨源请求,就可以做出回应。

如果服务器否定了“预检”请求,会返回一个正常的 HTTP 回应,但是没有任何 CORS 相关的头信息字段,或者明确表示请求不符合条件。

一旦服务器通过了"预检"请求,以后每次浏览器正常的CORS请求,就都跟简单请求一样,会有一个 Origin 头信息字段。服务器的回应,也都会有一个 Access-Control-Allow-Origin 头信息字段。

2.Nginx反向代理

使用 Nginx 反向代理实现跨源,是最简单的跨源方式。只需要修改 Nginx 的配置即可解决跨源问题,支持所有浏览器,支持 session,不需要修改任何代码,并且不会影响服务器性能。

实现思路: 通过 Nginx 配置一个代理服务器(与发送请求的页面同源)做跳板机,将请求反向代理到真正的 URL,并且可以修改一些头部信息,如:Cookie。

安装: 下载安装包或者 npm i nginx,运行 nginx -v 查看是否安装成功。

配置: 修改 nginx.conf 文件(Mac:/usr/local/etc/nginx/nginx.conf):

# 将 localhost:9700 的请求转发到 localhost:9800
http {
    server {
        listen       9700;
        server_name  localhost;
        location /api {
            rewrite ^/b/(.*)$ /$1 break; # 去除本地接口/api前缀, 否则会出现404
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_pass http://localhost:9800;
        }
    }
}

更新配置: 执行 nginx -s reload 重新载入配置。

了解更多 Nginx,可查看 Nginx基础使用

3.Node中间件代理

实现原理:同源策略是浏览器需要遵循的标准,而如果是其他服务器(如:node)向服务器请求就无需遵循同源策略。

代理服务器需要做如下事情:

  • 接受客户端请求 ;

  • 将请求转发给服务器;

  • 拿到服务器响应数据;

  • 将响应转发给客户端。

具体实现:http://127.0.0.1:5500/index.html 通过代理服务器提供的 http://localhost:3000 接口,向目标服务器http://localhost:4000 请求数据。

// http://127.0.0.1:5500/index.html
var xhr = new XMLHttpRequest()
xhr.open('post', 'http://localhost:3000/', true)
xhr.withCredentials = true
xhr.send(null)
// 代理服务器(node1.js),http://localhost:3000
const http = require('http')
const server = http.createServer((request, response) => {
    // 代理服务器设置 CORS 的响应头字段
    response.writeHead(200, {
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Methods': '*',
        'Access-Control-Allow-Headers': 'Content-Type'
    })
    // 第二步:将请求转发给服务器
    const proxyRequest = http.request(
        {
            host: '127.0.0.1',
            port: 4000,
            url: '/',
            method: request.method,
            headers: request.headers
        },
        serverResponse => {
            // 第三步:收到服务器的响应
            var body = ''
            serverResponse.on('data', chunk => {
                body += chunk
            })
            serverResponse.on('end', () => {
                console.log('The data is ' + body)
                // 第四步:将响应结果转发给浏览器
                response.end(body)
            })
        }
    ).end()
})
server.listen(3000, () => {
    console.log('代理服务器:http://localhost:3000')
})
// 目标服务器(node2.js),http://localhost:4000
const http = require('http')
const data = { title: 'frontend', password: '123456' }
const server = http.createServer((request, response) => {
  if (request.url === '/') {
    response.end(JSON.stringify(data))
  }
})
server.listen(4000, () => {
  console.log('目标服务器:http://localhost:4000')
})

4.webpack-dev-server

Webpack 内置了 webpack-dev-server 插件,webpack-dev-server 使用 http-proxy-middleware 实现跨源代理。

webpack.config.js 配置:

module.exports = {
    //...
    devServer: {
        proxy: {
            '/api': {
                target: 'http://www.baidu.com/',
                pathRewrite: {'^/api' : ''}, // /api/users -> /users
                changeOrigin: true, // target是域名的话,需要这个参数,
                secure: false, // 设置支持https协议的代理
            },
            '/api2': {
                .....
            }
        }
    }
}
module.exports = {
    //...
    devServer: {
        proxy: [{
            context: ['/auth', '/api'],
            target: 'http://localhost:3000',
        }]
    }
};

5.WebSocket

原理: 同源政策是浏览器对 http/https 协议的限制,WebSocket 是一种使用 ws://(非加密)和wss://(加密)作为前缀的通信协议,浏览器不对该协议实行同源政策

客户端发送 Websocket 请求:

let socket = new WebSocket('ws://localhost:3000')
socket.onopen = function () {
    socket.send('Hello WebSockets!')
}
socket.onmessage = function (e) {
    console.log(`Received Message: ${e.data}`)
}

Node.js 服务器:

const WebSocket = require("ws");
let wss = new WebSocket.Server({ port: 3000 })

// 监听连接
wss.on("connection", function(ws) {
    ws.on("message", function(data) {
        console.log(data)
        ws.send("Hello Client!")
    })
})

了解更多 WebSocket ,可查看 前端进阶篇:WebSocket

6.JSONP

JSONP 是一种非官方的跨源数据交互协议。它的实现主要依赖于两点:

  • <script> 标签不受同源策略限制,可以从不同域加载资源;

  • <script> 请求回来的是一段 Js 代码,并且会立即在当前页面执行。

JSONP 的理念:与服务端约定好一个回调函数名,服务端接收到请求后,将返回一段 Javascript,在这段 Javascript 代码中调用了约定好的回调函数,并且将数据作为参数进行传递。当网页接收到这段 Javascript 代码后,就会执行这个回调函数,这时数据已经成功传输到客户端了。

首先,网页动态插入 <script> 元素,由它向跨源网址发出请求。

function loadScriptFile (src) {
    const script = document.createElement('script')
    script.setAttribute("type","text/javascript")
    script.src = src
    document.body.appendChild(script)
}

window.onload = function () {
    loadScriptFile('http://example.com/ip?callback=foo')
}

function foo(data) {
    console.log('接收: ' + data.ip)
}

注意:该请求的查询字符串有一个 callback 参数,用来指定回调函数的名字,这对于 JSONP 是必需的。

服务器收到这个请求以后,会将数据放在回调函数的参数位置返回。

foo({
    "ip": "8.8.8.8"
})

由于 <script> 元素请求的脚本,直接作为代码运行。这时,只要浏览器定义了 foo 函数,该函数就会立即调用。作为参数的 JSON 数据被视为 JavaScript 对象,而不是字符串,因此避免了使用 JSON.parse 的步骤。

**JSONP 的优点是:**简单适用,兼容性更好,在更加古老的浏览器中都可以运行。虽然绝大多数现代的浏览器都已经支持 CORS,但一些比较老的浏览器只支持 JSONP。

**JSONP 的缺点是:**它只支持 GET 请求,而不支持 POST 请求等其他类型的 HTTP 请求。

7.window.postMessage

window.postMessage() 方法提供了一种受控机制来规避此限制,只要正确的使用,就可以安全地实现跨源通信。

从广义上讲,一个窗口可以获得对另一个窗口的 window 对象(如:targetWindow = window.opener),然后在窗口上调用 targetWindow.postMessage() 方法发送消息,该消息可以被目标窗口的全局事件 message 接收。

targetWindow.postMessage(message, targetOrigin, [transfer])
  • targetWindow: 其他窗口的一个引用。通过以下方法获取:

    • 调用 window.open() 返回的窗口对象;

    • 页面上的 iframe 对象:比如 iframe 的 contentWindow 属性、、或者是命名过或数值索引的window.frames

  • **message:**将要发送到其他 window 的数据。

  • targetOrigin: 指定哪些窗口能接收该消息,其值可以是字符串 * ,表示无限制,也可以是一个 URI。如果目标窗口的协议、域名、端口这三者的任意一项不匹配 targetOrigin 提供的值,那么消息就不会被发送。

  • **transfer ** 可选,是一串和 message 同时传递的 Transferable 对象。

window.open 为例:

// 父页面:23-commumication(A).html
var targetWindow = window.open('23-commumication(B).html?type=5')
window.addEventListener('message', (e) => {
    console.log(`[postMessage]接收:${e.data}`)
})
// 子页面:23-commumication(B).html
window.addEventListener('message', (e) => {
    console.log(`[postMessage]接收:${e.data}`)
})

父页面向子页面发送消息:

targetWindow.postMessage('Hello, This is Page A!')

子页面向父页面发送消息:

window.opener.postMessage('Hello, This is Page B!')

注意: 如果不是当前页面调用 window.open() 打开或者 iframe 内嵌的页面,则跟当前页面没有任何关系,即取不到页面窗口的 window 对象,也就无法使用 postMessage 方法发送信息。

注意: 如果 window.open() 打开或者 iframe 内嵌的页面是同源页面,则 postMessage 方法中的第二个参数是不传;如果是非同源页面,则第二个参数必传(可以传 * 或 URI),否则会报错,如:

Failed to execute 'postMessage' on 'DOMWindow': The target origin provided ('http://localhost:5200') does not match the recipient window's origin ('http://localhost:5300').

postMessage+window.open

实现思路: 在页面 A (父窗口)调用 window.open() 打开多个子页面 B、C...(即多个子窗口),并将子页面的 window 对象存在一个数组中。如果页面 A 需要广播消息,只需遍历数组,调用数组中 window 对象的 postMessage 方法发送消息即可;如果子页面 B 需要广播消息,则调用 window.opener.postMessage 方法向页面 A 发送消息,页面 A 再在 message 事件中遍历数组,调用数组中 window 对象的 postMessage 方法发送消息。

首先,在页面 A 把 window.open() 方法打开的页面的 window 对象储存到数组,然后定义 message 事件的监听函数:

var targetWindows = []
function fnOpenWindow(url) {
    let tWindow = window.open(url)
    targetWindows.push(tWindow)
}

window.addEventListener('message', (e) => {
    if (e.data.from === location.href) {
        return
    }
    targetWindows = targetWindows.filter(w => !w.closed)
    targetWindows.forEach(w => w.postMessage(e.data, '*'))
    console.log('[window.open]接收信息:', `msg-${e.data.msg},from-${e.data.from}`)
    document.querySelector('.box_05').querySelector('.content').innerHTML = e.data.msg
})

然后,在各子页面定义 message 事件的监听函数:

window.addEventListener('message', (e) => {
    if (e.data.from === location.href) {
        return
    }
    console.log('[window.open]接收信息:', `msg-${e.data.msg},from-${e.data.from}`)
})

最后,在子页面(如 B)中调用 postMessage 方法,实现广播消息:

window.opener.postMessage({
    msg: "Hello, This is Page B!",
    from: location.href
}, '*')

window.open DEMO Page A

注意: 子页面的 window 对象上的 opener 属性,指向的是父页面的 window 对象,因此,子页面获得了父页面的控制权。出于安全考虑,可以限制子页面的 window.opener 属性。如果是 a 标签跳转,可以加 rel=noopener 属性或者 rel=noreferrer,如果是 Js 调用 window.open() 方法,可以:

let childWindow = window.open(url)
childWindow.opener = null;

注意: 该方案使用有限制,被打开页面必需要有 window.opener 属性,并且指向打开该页面的页面 window 对象。即,如果子页面不是通过在另一个页面内的 window.open 打开的(如直接在地址栏输入链接或者从其他网站链接过来),则两者之间没有联系,无法通信。

postMessage+iframe

该方案与 postMessage+window.open 类似,区别在于:适用于在当前页面内嵌 iframe 子页面的场景。父页面调用类似 window.frames[0].postMessage() 方法向子页面发送信息,子页面在 message 事件中调用 window.parent.postMessage() 或者 e.source.postMessage() 向父页面通信。

实现思路: 非同源页面 A 和 B ,A 内嵌一个 iframe 页面 C1,B 内嵌一个 iframe 页面 C2,C1、C2 指向同一 URL(或同源下的不同 URL 也可以)。当页面 A 需要向 页面 B 通信时,先将消息发给其内嵌的 iframe C1,由于 C1、C2 同源,它们之间可以使用上述讲到的任意方法通信,即 C2 可以接收 C1 发送的消息,然后再由 C2 将发送信息给页面 B。

我们以 BroadCast Channel 为例。

首先,在中间页面创建一个 BroadCast Channel 实例,并为实例的 message 事件上定义函数,接收来自 BroadCast Channel 的消息,再定义一个全局的 message 事件定义函数,接收来自父页面的消息。

const BC = new BroadcastChannel('zhao')
// 接收到 BroadCast Channel 广播消息,发送给父页面
BC.onmessage = function (e) {
    window.parent.postMessage(e.data, '*')
    console.log('[BroadCast Channel]接收:', `msg-${e.data.msg},from-${e.data.from}`)
}

// 接收来自父页面的消息,调用 BroadCast Channel 广播消息
window.addEventListener('message', function (e) {
    BC.postMessage(e.data)
    console.log('[父页面]接收:', `msg-${e.data.msg},from-${e.data.from}`)
})

然后,在页面 A、B 中定义全局的 message 事件函数,接收来自 iframe 的消息:

window.addEventListener('message', (e) => {
    console.log('[iframe]接收:', `msg-${e.data.msg},from-${e.data.from}`)
})

最后,在页面 A 或者 B 发出消息:

window.frames[0].postMessage({
    msg: "Hello, This is Page A!",
    from: location.href
}, '*')

iframe DEMO Page A

iframe DEMO Page B

注意: 主页面调用 iframe 的 postMessage 方法,需要等 iframe 加载完。否则报错:

Failed to execute 'postMessage' on 'DOMWindow': The target origin provided ('http://xxx.xxx.x.xxx:xxxx') does not match the recipient window's origin

8.document.domain

满足某些限制条件的情况下(两个源的一级级域和二级域名相同,只是次级域名不同),页面是可以修改它的源。Js 可以将 document.domain 的值设置为其当前域或其当前域的父域,并且这个修改后的域将用于后续源检查

注意:document.domain只能设置为当前域和其父域,否则,浏览器会拒绝修改。

**注意:**端口是由浏览器另行检查的。任何对 document.domain 的赋值操作,包括 document.domain = document.domain 都会导致端口被重写为 null

注意: 修改 document.domain 的方式只适用于解决 Cookie 和 iframe 的跨源问题。

9.window.name+iframe

浏览器的 window.name 属性有个特性:无论是否同源,只要在同一个浏览器标签或者同一个 iframe 框架打开过的页面,后一个页面可以读取前一个页面设置的 window.name 值(页面刷新后,该值还是存在)

注意: window.name 的值大小可达 2MB。

根据这个特性,我们可以在主页面内嵌 iframe,先将 iframe 指向一个非同源的页面,然后将 iframe 指向一个与主页面同源的中间页面,再主页面访问同源是中间页面的 window.name 值,间接实现非同源页面的通信。

比如:http://localhost:5200/23-commumication(A).html 要获取非同源页面 http://localhost:5300/23-commumication(B).html 的数据:

首先,在 23-commumication(B).html 页面,将数据赋值给 window.name

<!-- http://localhost:5300/23-commumication(B).html?type=7 -->
<script>
    window.name = "Hello, This is Page B!"
</script>

其次,在 23-commumication(A).html 内嵌一个指向 23-commumication(B).htmliframe 标签,在 23-commumication(B).html文件加载完成后,会执行文件中的 window.name 赋值语句。

<!-- http://localhost:5200/23-commumication(A).html?type=7 -->
<iframe src="http://localhost:5300/23-commumication(B).html?type=7"></iframe>
<script>
    function getCrossOriginData() {
        const frame = document.querySelector("#windowOpenIframe")
        frame.onload = function () {
            // console.log(frame.contentWindow.name)
        }
    }
    getCrossOriginData()
</script>

注意: 由于 23-commumication(A).html23-commumication(B).html 是非同源页面,无法通过 frame.contentWindow.name 直接获取数据。如果执行上面注释的 console.log(frame.contentWindow.name),会报错:

Uncaught DOMException: Blocked a frame with origin "http://localhost:5200" from accessing a cross-origin frame.

然后,创建一个中间页面 http://localhost:5200/proxy.html也可以是不存在的页面,会报 404 错误,但不影响功能),然后将 23-commumication(A).htmliframe 标签的 src 指向这个中间页面。

由于 proxy.html23-commumication(B).html 是在同一个 iframe 中打开的,它们共享 window.name。而 proxy.html23-commumication(A).html 是同源的,23-commumication(A).html 可以通过 frame.contentWindow.name 获取 proxy.htmlwindow.name 值,也就是 23-commumication(B).htmlwindow.name 值,如此,间接实现了的 23-commumication(A).html23-commumication(B).html 的非同源通信。

修改 getCrossOriginData 方法如下:

function getCrossOriginData() {
    const frame = document.querySelector("#windowOpenIframe")
    let isFirstLoad = true
    frame.onload = function () {
        // iframe 加载完 23-commumication(B).html 后,再去加载 proxy.html,两者共享 window.name 值。
        if (isFirstLoad === true) {
            isFirstLoad = false
            frame.src = 'http://localhost:5200/proxy.html'
        } else {
            // 获取 proxy.html、23-commumication(B).html 共享的 window.name 值
            console.log(frame.contentWindow.name)
            document.querySelector('.box_07').querySelector('.content').innerHTML = frame.contentWindow.name
        }
    }
}
getCrossOriginData()

总结: 该方案实现的关键在于 proxy.html文件,它和 23-commumication(B).html 是在同一个 iframe 访问的,并且它和 23-commumication(A).html 是同源的。

同理,该方法也可以实现服务端跨源数据请求:服务端需要提供一个页面地址,并将需要返回的数据赋值给 window.name 属性。客户端调用如下方法获取数据:

function getCrossOriginData(targetUrl, proxyUrl, callback) {
    const iframe = document.createElement('iframe')
    iframe.style.display = 'none'
    iframe.src = targetUrl

    let isFirstLoad = true
    iframe.onload = function () {
        if (isFirstLoad) {
            isFirstLoad = false
            iframe.contentWindow.location = proxyUrl
        } else {
            callback(iframe)
            iframe.contentWindow.document.write('')
            iframe.contentWindow.close()
            document.body.removeChild(iframe)
        }
    }
    document.body.appendChild(iframe)
}
getCrossOriginData(
    'http://localhost:5300/23-commumication(B).html?type=7',
    'http://localhost:5200/proxy.html.html',
    (iframe) => {
        console.log(iframe.contentWindow.name)
        document.querySelector('.box_07').querySelector('.content').innerHTML = iframe.contentWindow.name
    }
)

查看 window.name DEMO

10.location.hash+iframe

hash 指的是 URL 的 # 号后面的部分。URL 的 hash 变化,浏览器不会刷新页面。

在父窗口中,通过 iframe 内嵌子窗口。父窗口将数据写入子窗口 URL 的 hash 中,子窗口可通过监听 hashchange事件获取:

// 父窗口
function sendLocationHashToFrame () {
    const frame = document.querySelector('#locationHashIframe')
    frame.src = frame.src + `#Hello, This is Page A`
}
window.onhashchange = () => {
    console.log(window.location.hash)
}

同理,子窗口也可以向父窗口的 hash 写入数据,被父窗口的 hashchange 事件获取:

// 子窗口
function sendLocationHashToParent (e) {
    window.parent.location.href = 'http://localhost:5200/23-commumication(A).html?type=8#Hello, This is Page B!'
}
window.onhashchange = () => {
    console.log(window.location.hash)
}

查看 location.hash DEMO

注意: 只有当 hash 值有变化才能触发 message 事件,即如果设置的 hash 值与当前链接相同,不会触发 message 事件。

注意: window.parent.location.href 是唯一的可以跨源访问的 Location 对象属性,且该属性只能跨源赋值,不能跨源取值

常见问题

HTTP请求跨源错误原因

  • 服务端设置的 Access-Control-Allow-Origin 不匹配;

  • 服务端禁用了 OPTIONS 方法;

  • HTTP 请求头部扩展了字段,服务端的响应头部没有添加允许;

  • HTTP 请求被服务端重定向到一个不允许跨源的地址;

  • 浏览器禁止跨源请求数据。如:IE 浏览器可以在 工具 -> Internet 选项 -> 安全 -> 自定义级别 -> 其他,设置是否启用 【通过域访问数据源】。

  • 客户端没有设置 withCredentials = true

  • 服务端响应头没有 Access-Control-Allow-Credentials: true,或者 Access-Control-Allow-Origin设置为通用符 \*。跨源发送 Cookie,Access-Control-Allow-Origin 不能设为通用符,必须指定明确的、与请求网页一致的域名。

  • Cookie 的 sameSite 属性没有设置为 none

  • 浏览器禁止了跨源发送 Cookie。

    • chrome:打开 chrome://flags/SameSite by default cookiesCookies without SameSite must be secure两项设置成 Disable;

    • IE:``设置 -> 隐私和安全性 -> cookie -> 不阻止cookie`。

  • 使用了 Mock 等第三方包。

Canvas绘制图片的跨源问题

Canvas调用 getImageData()toDataURL() 对图片操作的时候,会出现跨源问题,而且跨源问题还不止一层。

方法一: 图片储服务器设置允许跨源,客户端将 img 标签的 crossOrigin 属性设置为 anonymous

// 储服务器设置允许跨源,img 标签加 crossOrigin 属性。
var canvas = document.createElement('canvas')
var context = canvas.getContext('2d')

var img = new Image()
img.crossOrigin = 'anonymous'
img.onload = function () {
    context.drawImage(this, 0, 0)
    context.getImageData(0, 0, this.width, this.height)
}
img.src = 'https://example.com/img/img_2323232.jpg'

方法二: 改用 Ajax 加载图片数据。即由服务端向图片存储服务器获取图片数据,转换成 base64 返回客户端。如果是请求频繁的图片,可以考虑由服务端转储到本地允许跨源的服务器上,返回转存后的图片链接到客户端。

// Ajax 加载图片数据
var xhr = new XMLHttpRequest()
xhr.onload = function () {
    var url = URL.createObjectURL(this.response)
    var img = new Image()
    img.onload = function () {
        URL.revokeObjectURL(url)
    }
    img.src = url
}
xhr.open('GET', url, true)
xhr.responseType = 'blob'
xhr.send()

json及jsonp的区别

JSON 是一种数据交换格式,而 JSONP 是一种依靠开发人员的聪明才智创造出的一种非官方跨源数据交互协议

参考链接

MDN 浏览器的同源策略

MDN CORS

阮一峰 同源政策

阮一峰 跨域资源共享 CORS 详解

阮一峰 浏览器同源政策及其规避方法

九种 “姿势” 让你彻底解决跨域问题

最后更新于