浏览器系列 -- 跨域

238 阅读11分钟

需要跨域的原因

同源策略

访问时规定需要

协议 + 域名 + 端口 三者 均相同 才可以请求访问资源

限制

限制了很多资源共享:

  1. Cookie、LocalStorage 和 IndexDB 无法读取
  2. DOM 和 Js对象无法获得
  3. AJAX 请求不能发送,

跨域的定义

当一个请求url的协议、域名(子域名+主域名)、端口三者之间有任意一个与当前页面url不同即是跨域

举例

http://www.test.com/ 与:
    https://www.test.com/            https 和 http:【协议】不同,属于跨域
    http://www.baidu.com/            test 和 baidu:【主域名】不同,属于跨域
    http://blog.test.com/            test 和 blog.test:【子域名】不同,属于跨域
    http://www.test.com/index.html   协议 + 域名 + 端口 三者相同,不属于跨域,只是子文件而已
http://www.test.com:8080/ 与:
    http://www.test.com:7001/  :8080和:7001:【端口】不同,属于跨域

解决方案

1. 适用于主域相同,子域不同

例如:www.test.coma.test.comb.test.comc.test.com 等等都属于同主域不同子域;

现在有个需求就是:在网站 www.test.com 登录后,在 a.test.comb.test.comc.test.com等网站都不用重复登录,也就是说可以实现 cookie 共享,怎么弄?

对 cookie 设置 domain 属性

举例

打开控制台可以看到,掘金网站对 cookie 的 domain 属性设置为 .juejin.cn,则在以 .juejin.cn 结尾的网站(也就是主域名为 juejin的其他子域名下的网站)都可以维持我的一个登录态

image.png

2. 跨文档数据传递

跨文档通信 APIwindow.postMessage(content,url) // 跨文档发送数据
window.addEventListener(message,function(e){}) // 接收数据

举例

// 父窗口向子窗口发消息
openWindow.postMessage('Nice to meet you!', 'http://test2.com');
// 子窗口监听 message 消息
window.addEventListener('message', function (e) {
  console.log(e.source); // e.source 发送消息的窗口
  console.log(e.origin); // e.origin 消息发向的网址
  console.log(e.data);   // e.data   发送的消息
},false);

3. 通过jsonp跨域

原理

借鉴了 <script> <img> <iframe> 标签本身可以跨域访问的特点

实现流程

这种跨域方式需要 前端 和 服务端 配合:

  • 前端:向API接口拼接一个回调发送给服务端
  • 服务端:拿到这个回调后,执行并将需要的数据放进回调的参数里,最后再将该函数返回给前端

具体代码

  • 前端工作:定义一个回调函数叫 callback,然后将该回调的名字拼接到 API 后面发给服务端
// index.html

<script src="http://localhost:3000?callback=callback"></script>
// 用<script>作引入打通callback函数,向服务器test.com发出请求

// 处理服务器返回回调函数的数据
<script type="text/javascript">
    function callback(res){
        console.log(res.data)  // 处理获得的数据
    }
</script>
  • 服务端工作:拿到发过来的回调名字,然后执行并且将需要的数据放到回调参数上返回给客户端
// server.js
const express = require('express')
const app = express()
app.get('/', (req,res) => {
    let callback = req.query.callback
    res.send(`${callback}(${JSON.stringify({
        success:0,
        data:{
            name:"yuefengsu"
        }
    })})`)
})
app.listen(3000, () => { console.log('开启了') })

结果

在开发中可能会遇到多个 JSONP 请求的回调函数名是相同的,这时候就需要自己封装一个 JSONP 函数:

  • 前端向 服务端 1 和服务端 2 分别发出跨域请求
// http://localhost:4000/index.html
function jsonp({ url, params, callback }) {
    return new Promise((resolve, reject) => {
        let script = document.createElement('script')
        window[callback] = function(data) {
            resolve(data)
            document.body.removeChild(script)
        }
        params = { ...params, callback } // wd=b&callback=show
        let arrs = []
        for (let key in params) {
            arrs.push(`${key}=${params[key]}`)
        }
        script.src = `${url}?${arrs.join('&')}`
        // 拼接得到 url:http://localhost:3000/say?wd=Iloveyou&callback=show
        document.body.appendChild(script)
    })
}
jsonp({
    url: 'http://localhost:3000/say',
    params: { wd: 'I love you' },
    callback: 'show'
}).then(data => {
    console.log(data)
})
jsonp({
    url: 'http://localhost:8080/exp',
    params: { wd: 'I miss you so much' },
    callback: 'show'
}).then(data => {
    console.log(data)
})
  • 服务端 1
// server.js
let express = require('express')
let app = express()
app.get('/say', function(req, res) {
  let { wd, callback } = req.query
  console.log(wd) // I love you
  console.log(callback) // show
  res.end(`${callback}('I love you,too')`)
})
app.listen(3000)
  • 服务端 2
// server.js
let express = require('express')
let app = express()
app.get('/exp', function(req, res) {
  let { wd, callback } = req.query
  console.log(wd) // I miss you so much
  console.log(callback) // show
  res.end(`${callback}('I miss you,too')`)
})
app.listen(3000)

上面代码表示http://localhost:4000/index.html分别向http://localhost:3000/say?wd=I love you&callback=showhttp://localhost:8080/say?wd=I miss you so much&callback=show两个地址请求数据,然后两个后台分别返回show('I love you,too')show('I miss you,too'),最后都会运行 show() 这个函数,分别打印出 'I love you,too'、'I miss you,too'

优缺点

  • 优点:简单、兼容性好,可用于解决主流浏览器的跨域数据访问的问题
  • 缺点:仅支持 GET 方法,具有局限性;另外,不安全可能还会遭受 XSS 攻击

JSONP 和 AJAX 关系 / 区别

  • 相同点:都是客户端向服务器端发送请求,从服务器端获取数据的方式,都是异步请求
  • 不同点:
    • AJAX属于同源策略
    • JSONP属于非同源策略(跨域请求)

4. 跨域资源共享(CORS)

分两种请求:简单请求 和 非简单请求

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

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

这是为了不限制住表单本身的跨域效果(form),因为历史上表单一直可以发出跨域请求。AJAX 的跨域设计就是,只要表单可以发,AJAX 就可以直接发。

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

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

▲ 简单请求

对于简单请求,浏览器直接发出CORS请求。具体来说,就是在头信息之中,增加一个Origin字段

下面是一个例子,浏览器发现这次跨源AJAX请求是简单请求,就自动在头信息之中,添加一个Origin字段

GET /cors HTTP/1.1
Origin: http://api.bob.com
Host: api.alice.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...

上面的头信息中,Origin 字段用来说明,本次请求来自哪个源(协议 + 域名 + 端口)。服务器根据这个值,决定是否同意这次请求。

如果 Origin 指定的源,不在许可范围内,服务器会返回一个正常的 HTTP 回应。浏览器发现,这个回应的头信息没有包含 Access-Control-Allow-Origin 字段(详见下文),就知道出错了,从而抛出一个错误,被XMLHttpRequest 的 onerror 回调函数捕获。

注意,这种错误无法通过状态码识别,因为 HTTP 回应的状态码有可能是 200。

Access-Control-Allow-Origin: http://api.bob.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: FooBar
Content-Type: text/html; charset=utf-8

如果 Origin 指定的域名 在许可范围内,服务器返回的响应,会多出几个头信息字段:

Access-Control-Allow-Origin: http://api.bob.com
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: FooBar
Content-Type: text/html; charset=utf-8

上面的头信息之中,有三个与 CORS 请求相关的字段:

(1) Access-Control-Allow-Origin

该字段是必须的。它的值要么是请求时 Origin 字段的值,要么是一个 *,表示接受任意域名的请求

(2) Access-Control-Allow-Credentials

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

(3) Access-Control-Expose-Headers

该字段可选。CORS请求时,XMLHttpRequest 对象的 getResponseHeader() 方法只能拿到 6个基本字段:Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma。如果想拿到其他字段,就必须在 Access-Control-Expose-Headers 里面指定。上面的例子指定,getResponseHeader('FooBar') 可以返回 FooBar 字段的值

▲ 非简单请求

1. 预检请求

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

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

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

下面是一段浏览器的JavaScript脚本:

var url = 'http://api.alice.com/cors';
var xhr = new XMLHttpRequest();
xhr.open('PUT', url, true);
xhr.setRequestHeader('X-Custom-Header', 'value');
xhr.send();

上面代码中,HTTP 请求的方法是 PUT,并且发送一个自定义头信息 X-Custom-Header

浏览器发现,这是一个非简单请求,就自动发出一个"预检"请求,询问服务器是否可以这样请求,这是完整请求头信息:

OPTIONS /cors HTTP/1.1
Origin: http:/ /api.bob.com
Access-Control-Request-Method: PUT // 指出浏览器的 CORS 请求的请求方式
Access-Control-Request-Headers: X-Custom-Header // 指定浏览器CORS请求会额外发送的头信息字段
Host: api.alice.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...

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

(1) Access-Control-Request-Method

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

(2) Access-Control-Request-Headers

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

2. 预检请求的回应

服务器收到"预检"请求以后,会检查 Origin、Access-Control-Request-Method 和 Access-Control-Request-Headers 三个字段

  • 若允许跨源请求
HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2.0.61 (Unix)
Access-Control-Allow-Origin: http://api.bob.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Content-Type: text/html; charset=utf-8
Content-Encoding: gzip
Content-Length: 0
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plain

(1)Access-Control-Allow-Methods

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

(2)Access-Control-Allow-Headers

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

(3)Access-Control-Allow-Credentials

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

(4)Access-Control-Max-Age

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

  • 若不允许跨源请求 如果服务器否定了"预检"请求,会返回一个正常的 HTTP 回应,但是没有任何CORS相关的头信息字段
3. 浏览器的正常请求和回应

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

下面是"预检"请求之后,浏览器的正常CORS请求

PUT /cors HTTP/1.1
Origin: http://api.bob.com
Host: api.alice.com
X-Custom-Header: value
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...

上面头信息的Origin字段是浏览器自动添加的。

下面是服务器正常的回应

Access-Control-Allow-Origin: http://api.bob.com
Content-Type: text/html; charset=utf-8

上面头信息中,Access-Control-Allow-Origin字段是每次回应都必定包含的

总结

跨域资源共享需要前端和后端的配合:(两者通过请求头和响应头互相通信)

  • 简单请求则按照 AJAX 请求照常即可,浏览器会 自动 添加Origin字段;非简单请求则需 手动 添加 X-Custom-Header 字段;
  • 服务器检验Origin字段看是否属于 白名单 内,若允许跨域请求,则Access-Control-Allow-Origin字段的值同请求头 / 或是 *,并携带其他相关的 CORS 字段;否则不返回任何与 CORS 相关的头信息字段
  • 一旦服务器通过了"预检"请求,以后每次浏览器正常的CORS请求,就都跟简单请求一样

展示一个利用 CORS 完成跨域请求的简单例子:

  • 前端:发起请求
// index.html
<script>
    let xhr = new XMLHttpRequest()
    xhr.withCredentials = true // 前端设置是否带cookie
    xhr.open('GET','http://localhost:3000/',true)
    xhr.onreadystatechange = function(){
        if((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
            console.log(xhr.responseText)
        }
    }
    xhr.send(null)
</script>

服务端:设置【白名单】

// server.js
const express = require('express')
const app = express()

app.all('*', function (req, res, next) {
    // 设置哪个源可以访问我
    res.header("Access-Control-Allow-Origin", "*")
    // 允许哪个方法访问我
    res.header("Access-Control-Allow-Methods", "PUT,POST,GET,DELETE,OPTIONS")
    // 允许携带cookie
    res.header('Access-Control-Allow-Credentials', true)
    res.header("Content-Type", "application/json;charset=utf-8")
    next()
})

app.get('/', (req,res) => {
    res.json({
        success:0,
        data:{
            name:"yuefengsu"
        }
    })
})
app.listen(3000, () => { console.log('开启了') })

5. 正向代理服务器(作中转站,利用服务器之间不需要遵循同源政策)

简单理解就是配置一个代理服务器,代理服务器的关键作用在于:

作为一个“中间人”,站在浏览器和服务器之间,浏览器之间跨域请求服务器行不同,但如果浏览器向代理服务器请求,告诉这个中间人要请求哪个服务器的内容,由于服务器与服务器之间不需要遵循同源策略,所以代理服务器可以代替浏览器帮忙请求它想要的资源然后返回给它

正向代理和反向代理的区别

以上方案是比较常见的方案,其余方案详细见 九种跨域方式实现原理(完整版)

总结

  • JSONP只支持 GET 请求,JSONP的优势在于支持老式浏览器,以及可以向不支持CORS的网站请求数据
  • CORS 支持所有类型的 HTTP 请求,是跨域 HTTP 请求的根本解决方案
  • 不管是 Node 中间件代理还是 Nginx 反向代理,主要是通过同源策略对服务器不加限制
  • 日常工作中,用得比较多的跨域方案是 CORS 和 Nginx 反向代理

参考文章