跨域机制与解决方案

本文的内容部分参考自阮一峰博客的 一篇博文MDN,关跨域访问的其他解决方案可以参考阮一峰的 这篇博文,有关 CORS 的兼容性,可以在 CanIUse 上看到。

跨域请求的默认行为

跨域是什么意思

对于两个 url 而言,只有它们的协议相同、主机名相同、端口相同,它们才是同域(或者叫同源、同站)的。
以上三者有任一项不同,则这两个 url 便是跨域(或者叫跨源、跨站)的。

例如:

http://paperplane.cc
https://paperplane.cc
file:///example.html

这几个 url 的协议不同,互相均不同源。


又例如:

http://paperplane.cc
http://paperplane.com
http://xxxx.paperplane.cc
http://123.123.123.123

这几个 url 也互相均不同源。
只有主机名整个部分完全相同才是同源,前面带子域名或者是用 IP 都不是同源。


又例如:

http://paperplane.cc
http://paperplane.cc:8080

这两个 url 端口不同,分别是 80、8080,因此也不是同源。

跨域的限制

出于安全考虑,浏览器会限制跨域资源的访问或权限。
如果讲的更精细一点,可以这样理解:跨域的 “写” 操作一般来说是不太会限制的,浏览器主要限制跨域 “读” 操作,因为读取操作很可能会导致信息泄露。

请求和资源方面的限制:

  • 普通的 XHR 或 fetch 无法获得预期的结果,可以发出请求但是结果会被拦截;
  • LocalStorage、IndexDB、WebSQL 跨域无法互通;
  • CSS 跨域加载字体;
  • Cookie、DOM 在跨域情况下访问受到限制。

不受跨域访问限制的资源和场景:

  • 加载 JS 和 CSS 文件;但是从 https 源站试图加载 http 地址的 JS 和 CSS 则会被浏览器阻止;
  • 加载 <img> 标签的图片文件,但是如果返回的类型不匹配或返回错误的状态码,则浏览器会隐藏响应中的大部分数据,这是 CORB 机制,此处不再赘述;
  • canvas 的 drawImage() 可以跨域加载图片,但不能获取图片的数据以及转为 Base64;
  • 使用 <form> 向跨域的站点提交表单。

SVG 图片中如果用到了外部字体,此时出于安全考虑,浏览器不会下载这些字体。
这是浏览器的安全策略,和跨域无关。

可以发现跨域只能加载 JS、CSS、图片等资源,所以使用 CDN 或 OSS 加速网站时,把这些文件上传到第三方域名是没有问题的。虽然可能存在 CDN 劫持等情况,不过也是有 CSP 内容安全策略方案的,本文暂不赘述。

而常常提到的 JSONP 跨域方案,实际上就是将 XML、JSON 或者是其他数据转化为 JS,利用 JS 可以跨域加载的原理来进行跨域访问的


需要注意的是,现代浏览器也会限制在 https 的页面中加载 http 协议的 JS 和 CSS。
最早是不限制的,后来会弹出警告,现在是完全不允许加载了。

可以实测一下,在浏览器中按 F12 打开控制台,输入以下内容运行:

const scriptTag = document.createElement('script')
scriptTag.src = "http://paperplane.cc/js/jquery-3.4.1.min.js?"
document.body.appendChild(scriptTag)

马上就会像下图一样报错:

把代码中地址的 http 改成 https 即可避免报错。
一种解决方法是在 url 中不指定协议,此时协议将自动跟随当前页面,例如将 src 属性改为 //paperplane.cc/js/jquery-3.4.1.min.js? 这种两个斜杠开头的形式。

如果是几年前的浏览器版本,可能不会报错,但是会弹出如下图所示的确认警告:


CSS 的加载,也会被浏览器阻止。可以尝试运行以下代码:

const styleTag = document.createElement('link')
styleTag.href = 'http://paperplane.cc/css/style.css?'
styleTag.rel = 'stylesheet'
document.head.appendChild(styleTag)

马上就会看到报错。

使用 CORS 来跨域访问资源

前面说到过浏览器的跨域主要是避免信息的 “读” 操作,对于 “写” 操作则是比较放开的。
实际上 HTTP 跨域请求也是这样:跨域请求可以成功发出,只是浏览通过判断返回的相应的报文头信息来判断是否拦截响应的,如果返回的响应头不满足特定的条件,那么浏览器就会拦截掉该响应。

因此,如果想实现跨域访问,后端需要配置额外的响应头内容来允许网站做跨域访问,这样一来便保证了信息安全。

CORS 用法

对于跨域请求,浏览器依据请求的类型和头信息将其分为两种:简单请求,非简单请求

满足以下条件的请求为简单请求

  • 类型为 GETHEADPOST
  • 请求头的内容不能超出以下几个:AcceptAccept-LanguageContent-LanguageLast-Event-IDContent-Type
  • 如果有 Content-Type 请求头,那么它只能是以下几个值:applicationx-www-form-urlencodedmultipart/form-datatext/plain

只要以上任一条件不满足,该请求就是非简单请求

简单请求的 CORS 流程

发起简单请求时,浏览器会在请求头中添加一项 Origin,该项的值为发起请求时的域名,这一项用于告知服务器跨域请求的来源。

如果服务器支持并同意该跨域请求,则浏览器的响应头会额外带有以下内容:

  • 必须有 Access-Control-Allow-Origin 项,它的值必须为 * 或是和请求头中的 Origin 相同的值;如果你的跨域请求还需要携带 Cookie 那就必须和请求头中的 Origin 相同,不能设为 *
  • 可能带有 Access-Control-Credentials 项,它的值也只能为 true,表示服务器接受客户端的 Cookie;
  • 可能带有 Access-Control-Expose-Headers 项,它是由多个字符串组成的,表示 http 请求能额外获取的响应头有哪些。如果服务器不给出该项,那么 http 请求默认只能获取以下六项响应头:Cache-ControlContent-LanguageContent-TypeExpiresLast-Modified 以及 Progma

如果服务器不支持或是不同意该次跨域请求,那么返回的响应头不包含 Access-Control-Allow-Origin 即可,这次请求会被当做失败,也无法读取到响应的内容。

非简单请求的 CORS 流程

只要有任一条不符合上面的简单请求的条件,那么该请求就是非简单请求。
浏览器在进行非简单请求前,会额外发送一条预检请求,预检请求的响应如果不符合规则,则原本的请求也不会正常发出。你可以理解为,先测试一下对方是否支持跨域,避免直接发送请求造成信息泄露。

预检请求的特点:

  • 它的请求方法是 OPTIONS
  • 这次请求有 Origin 请求头,其值为发起请求的页面域名;
  • 这次请求有 Access-Control-Request-Method 请求头,表示非简单请求的类型,例如 PUT
  • 这次请求可能会有 Access-Control-Request-Headers 请求头,它表示本次请求会额外携带的请求头,用逗号分隔。

如果服务器同意该次预检请求,将返回一个响应,告知浏览器预检请求的结果。
该响应具有以下特点:

  • 必须带有 Access-Control-Allow-Origin 响应头,值要么和原请求的 Origin 相同,要么是 *
  • 必须带有 Access-Control-Allow-Methods 响应头,它的值表示支持的请求方法,例如 PUT,用逗号分隔;
  • 可能带有 Access-Control-Allow-Headers 响应头,表示本次响应会额外携带的的响应头,用逗号分隔;
  • 可能带有 Access-Control-Allow-Credentials 响应头,值只能为 true,表示接受浏览器发出的 Cookie 信息;
  • 可能带有 Access-Control-Max-Age 响应头,表示允许浏览器缓存该预检请求结果的时长。

可以看到只要带有前两个响应头,并且请求类型包含在 Access-Control-Allow-Methods 内,那么该次预检请求就是成功的。
浏览器在预检请求成功的时候,才会进一步发出实际的请求。实际的请求流程和简单请求类似,这里不再赘述。

改造后端以支持 CORS

此处以 Node.js 的 koa 来举例。

只考虑简单请求

如果只考虑简单请求,后端可以进行以下改造:

  • 如果不要求浏览器提供 Cookie,那么在响应头上增加 Access-Control-Allow-Origin 项,值为 * 即可;
  • 如果要求浏览器提供 Cookie,那么 Access-Control-Allow-Origin 项的值必须和 Origin 请求头的值相同,不能是星号,还需要提供一个 Access-Control-Allow-Credentials 响应头,值设置为 true
  • 如果浏览器需要从响应头中获取额外的信息,后端还需要配置 Access-Control-Expose-Headers 项,值为需要对浏览器暴露的响应头的名称,多个的话用逗号 , 分隔。

可以做一下 CORS 简单请求的测试,找一个空文件夹打开系统终端,执行以下命令:

# 初始化 npm 项目,一路回车即可
yarn init

# 安装 koa
yarn add koa @koa/router

# 创建 index.js
echo "" >> index.js

在这个 index.js 中输入以下代码:

const Koa = require('koa')
const Router = require('@koa/router')

const app = new Koa()
const router = new Router()

app.use(router.routes()).use(router.allowedMethods())

router.get('/', async (ctx, _next) => {
  ctx.body = { data: 'Hello' }
})

app.listen(6200)

然后在终端中运行 node index.js 开启服务;
然后在现在这个页面按 F12 打开浏览器的调试菜单,转到控制台栏,输入以下代码运行:

fetch('http://localhost:6200').then(res => res.json()).then(console.log)

因为请求的域名和当前页面的域名不同,所以一运行代码就可以看到,控制台中马上打印了跨域的错误:

而我们回去修改 index.js 中的代码,将其中的片段修改为:

router.get('/', async (ctx, _next) => {
  // 注意下面这一行是新增的
  ctx.set('Access-Control-Allow-Origin', '*')
  ctx.body = { data: 'Hello' }
})

修改并保存后,按下 Ctrl + C 中断 Node.js 进程,重新运行 node index.js 开启服务,此时再在网页控制台中输入上面的请求代码,便可以看到这个请求不再报错了,还能正确打印出响应的内容:{ data: 'Hello' }

非简单请求

与简单请求不同的是,非简单请求在发送之前浏览器先会发一个预检请求,它是一个 OPTIONS 请求。
后端需要有逻辑来处理这个预检请求。

修改 index.js 代码,将其中的 router.get 改为 router.put,保存后按下 Ctrl + C 中断 Node.js 进程,重新运行 node index.js 开启服务;
然后构造一个非简单请求来试一试。在浏览器控制台中输入以下内容并回车:

fetch('http://localhost:6200', { method: 'PUT' })
  .then(res => res.json())
  .then(console.log)

运行后可以看到控制台马上报错:

因为 PUT 请求是非简单请求,在跨域请求时浏览器会先发一个预检请求;如果我们不处理预检请求,那么这次请求会直接失败。

接下来尝试正确处理预检请求。编辑 index.js,在最后一行的上面添加以下内容:

router.options('/', async (ctx, _next) => {
  ctx.set('Access-Control-Allow-Origin', '*')
  ctx.set('Access-Control-Allow-Methods', 'PUT')
  ctx.body = ''
})

此时再次在浏览器控制台中运行上面的请求代码,可以看到请求成功完成并打印了结果。

曾经的跨域请求方案 JSONP

JSONP 意思是 JSON with Padding,这个不需要记。在以前不支持 CORS 的时代,通常会使用 JSONP 来处理跨域请求。

前面说到过,浏览器跨域加载 JS 是不受限制的,因此我们可以让后端把跨域请求的结果改为 JS 格式,当做一个 JS 文件返回给前端,这样就可以实现跨域响应了。

尝试使用 Node.js 作为后端来编写一个 JSONP 处理程序。修改 index.js 文件:

router.get('/', async (ctx, _next) => {
  const responseData = { data: 'Hello' }
  const callbackFnName = ctx.query.callbackFnName

  ctx.body = `${callbackFnName}(${JSON.stringify(responseData)})`
})

修改并保存后,按下 Ctrl + C 中断 Node.js 进程,重新运行 node index.js 开启服务;
然后在浏览器控制台里尝试前端封装一个 JSONP 请求方法:

async function jsonp(url) {
  return new Promise((resolve, reject) => {
    const callbackFnName = 'jsonp_' + new Date().valueOf()
    const scriptTag = document.createElement('script')

    window[callbackFnName] = resolve
    scriptTag.onerror = reject
    scriptTag.src = `${url}?callbackFnName=${callbackFnName}`

    document.body.appendChild(scriptTag)
  })
}

这样一个简易版的 JSONP 前后端模型便实现好了。可以测试一下,在浏览器控制台中输入:

jsonp('http://localhost:6200').then(console.log)

可以看到正确的打印出了结果。

JSONP 的用法有很多,这里只举例出最常用的一种方式,通过代码也能很容易看出原理:
前端声明一个全局函数,名称可以随便取但是要避免重名,所以用时间戳,这一步会污染全局对象;
<script> 来尝试加载一个 “JS 文件”,并在 url 中通过 query 传这个全局函数的名称,后端生成一段 “JS 代码”,内容是把要返回的数据作为参数,传给这个全局函数。


对比 JSONP 和 CORS,可以发现它们具有以下区别:

  • 因为 JSONP 的原理是利用 <script> 去加载 JS 文件,这个过程一定是 GET 请求,也很难获取响应的 Header 头等;而 CORS 支持各种类型的请求;
  • JSONP 需要使用较为复杂的处理流程,前端需要插入 <script> 标签、污染全局对象,后端也需要拼接生成 JS;而 CORS 只需要后端做配置,添加几个响应头即可;
  • 兼容性方面,CORS 并不差,除非是特别特别老的浏览器可能只能用 JSONP,不然都是支持 CORS 的,查看 CORS 的兼容性

其他资源的跨域访问

如果两个 url 的一级域名相同、二级域名不同,比如 a.paperplane.ccb.paperplane.cc,那么它们之间可以互通 Cookie:
将 Cookie 的 domain 属性设置为 .paperplane.cc,这样两个站点都可以访问到这个 Cookie。

还有一种情况,用二级域名的页面可以访问没有二级域名的页面的 Cookie,例如 a.paperplane.cc 可以访问 paperplane.cc 的 Cookie,因为它可以修改自己的 document.domain 改为 paperplane.cc,但是反过来是不行的,修改 document.domian 操作会无效且报错。

跨域窗口通讯

在本页按下 F12 打开浏览器控制台,输入以下代码:

// 会打开百度首页,不要关闭网页,切换 Tab 回到本页
const baiduPage1 = window.open('https://www.baidu.com')

// 成功,可以关闭百度页面,关闭操作不受跨域限制
baiduPage1.close()

const baiduPage2 = window.open('https://www.baidu.com')

// 会失败,跨域的情况无法获取页面内容,同域时可以成功执行
console.log(baiduPage2.document)

跨域时可以使用 Cross-Document Message 的 API 来通信,它的 兼容性还不错
在用 <iframe> 跨域加载页面或者 windows.open( ) 跨域打开页面的时候,可以获取新页面的 window 对象,跨域情况下这个对象的操作是受限制的,几乎无法获取任何数据;但在该对象上,调用 .postMessage(data, origin) 方法可以发出消息:

  • data 参数是要发出的消息;
  • origin 注意这个是必填的参数,它是一个字符串表示接收窗口的 uri,目标窗口必须协议、主机名、端口号都匹配才会发出该消息;可以设置为 * 表示无限制。

作为接收方,想接收来自其他文档发出的跨域消息则是使用 window.addEventListener('message', callback) 来接收:

  • callback 是一个回调,它具备一个事件参数 e,其中几个比较重要的属性:
    • e.data 是消息的内容;
    • e.source 是来源文档的引用,可以用它的 .postMessage( ) 来发送消息;因为来源文档和本页也是跨域的,所以大部分操作都是受限的,很多属性也访问不了;
    • e.origin 是来源文档的 url。

可以试验一下,在本页面按下 F12 打开浏览器控制台,输入以下代码:

// 现在在 paperplane.cc 页面
const baiduPage = window.open('https://www.baidu.com')

然后在新打开的百度页面,按下 F12 打开浏览器控制台,输入以下代码:

// 现在在 www.baidu.com 页面
window.addEventListener('message', console.log)

此时便可以通过 Message API 在本页面和百度首页之间传递信息。切换到本页面,输入:

// 现在在 paperplane.cc 页面
baiduPage.postMessage({ data: 'Hello' }, '*')

便可以看到百度首页那边的控制台打印出了这个消息事件,其 .data 属性便是消息内容。