你真的了解跨域吗

15,083 阅读32分钟

前言

相信每个前端对于跨域这两个字都不会陌生,在实际项目中应用也很多,但跨域方法的多种多样让人目不暇接,前段时间公司同事出现了跨域问题,又一时找不到问题所在,所以在此总结下跨域知识,一篇由浅入深的万字Web基操文

其实很早就开始写了,只不过刚开始写的时候理解不够深刻,后来慢慢就写其他觉得较高大尚较内涵的了,然后就又是觉得不够完美不够深刻又写一半,就此陷入强迫症患者明知不可为而为的死循环,SO,产出少,周期长(不过大家能看到的文章都是准备良久又反复斟酌后自认为还不错的)。。。

总之又是一篇由于各种原因半途而废的积压文,这里终于收尾了,长出一口气,哎,还是太年轻,吐槽结束,进入正文

文章收录地址: isboyjc/blog 传送门

什么是跨域

简单来说跨域是指一个域下的文档或脚本想要去去请求另一个域下的资源

其实一些像A链接、重定向、表单提交的资源跳转,像 <link>、<script>、<img>、<frame> 等dom标签,还有样式中 background:url()、@font-face() 等嵌入的文件外链,又比如一些像 js 发起的ajax请求、dom 和 js 对象的跨域操作等等都是跨域

我们通常所说的跨域,大多是由浏览器同源策略限制引起的一类请求场景,这里你可能注意到了同源策略,那么浏览器的同源策略是什么呢?

浏览器同源策略

同源策略/SOP(Same origin policy)是一种约定,由 Netscape 公司1995年引入浏览器,它是浏览器最核心也最基本的安全功能,如果缺少了同源策略,浏览器很容易受到 XSS、CSFR 等攻击

同源同源,什么是源呢?源指的是 协议、域名、端口 ,那么同源即三者相同,即便是不同的域名指向同一个ip地址,也不同源

我们来看一个域名组成,我们以 http://www.hahaha.com/abc/a.js 为例

  • http:// --> 协议
  • www --> 子域名
  • hahaha.com --> 主域名
  • 80 --> 端口(http:// 默认端口是80)
  • abc/a.js --> 请求资源路径

那么我们以这个域名的源为例,来与下面这些做下对比

URL 结果 原因
http://www.hahaha.com/abc/b.js 同源 只有路径不同
http://www.hahaha.com/def/b.js 同源 只有路径不同
https://www.hahaha.com/abc/a.js 不同源 协议不同
http://www.hahaha.com:8081/abc/a.js 不同源 端口不同
http://aaa.hahaha.com/abc/a.js 不同源 主机不同

而在不同源的情况下,同源策略限制了我们

  • Cookie、LocalStorage、IndexedDB 等存储性内容无法读取
  • DOM 节点和 Js对象无法获得
  • AJAX 请求发送后,结果被浏览器拦截(注意是 请求发送出去了,也拿到结果了,只是被浏览器截胡了

到了这里,相信你对跨域已经有所了解了,那么我们如何有效的规避跨域呢,应该说如何解决跨域问题,因为我们在开发过程中免不了要跨域,针对不同的类型,解决跨域的方式也有很多

不同类型的跨域解决方案

No.1 document.domain+iframe跨域

简介

document.domain 的方式实现跨域,适用场景仅在 主域名相同,子级域名不同 的情况下

例如,下面这两个页面

http://aaa.hahaha.com/a.html
http://bbb.hahaha.com/b.html

那么它可以做到什么呢

  • 两个页面设置相同的 document.domain ,共享Cookie
  • 两个页面设置相同的 document.domain ,通过 iframe 实现两个页面的数据互通

示例

共享Cookie

首先,两个页面都设置相同的 document.domain

document.domain = 'hahaha.com';

页面 a 通过脚本设置一个 Cookie

document.cookie = "test=a";

网页 b 读这个 Cookie

let cookieA = document.cookie;
console.log(cookieA)

服务器也可以在设置Cookie的时候,指定Cookie的所属域名为一级域名,比如.hahaha.com

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

这样的话,二级域名和三级域名不用做任何设置,都可以读取这个Cookie

共享数据
<!--a页面-->
<iframe src="http://bbb.hahaha.com/b.html" onload="load()" id="frame"></iframe>
<script>
  document.domain = 'hahaha.com';
  let a = "this is a";
  
  // 获取b页面数据
  function load(){
    let frame = document.getElementById("frame")
    console.log(frame.contentWindow.b) // this is b
  }
</script>
<!--b页面-->
<script>
  document.domain = 'hahaha.com';
  let b = "this is b"
  
  // 获取a页面数据
  console.log(window.parent.a); // this is a
</script>

局限

  • 首先,仅在主域名相同,子级域名不同的情况下
  • 只适用于 Cookie 和 iframe 窗口,LocalStorage 和 IndexDB 数据无法通过这种方法共享

No.2 location.hash + iframe跨域

简介

两个页面不同源,是无法拿到对方DOM的,典型的例子就是 iframe 窗口和 window.open 方法打开的窗口,它们与父窗口是无法通信的

比如,不同源的页面a和页面b,如果我们直接获取对方数据

页面a:http://www.hahaha0.com/a.html

<iframe src="http://www.hahaha1.com/b.html" onload="load()" id="frame"></iframe>
<script>
  let a = "this is a"
  
  // 获取b页面数据
  function load(){
    console.log(document.getElementById("frame").contentWindow.b) 
    // Uncaught DOMException: Blocked a frame from accessing a cross-origin frame.
  }
</script>

页面b:http://www.hahaha1.com/b.html

<!--b-->
<script>
  let b = "this is b"
  
  // 获取a页面数据
  console.log(window.parent.a); // 报错
</script>

显而易见,都是获取不到的,因为都跨域了,上面我们讲到的 document.domain,只能在同主域名的情况下使用才能规避同源政策,而在主域名不相同的情况下是没有办法做到的

我们来了解另一种办法 window.location.hash,它拿到的是 URL 的#号后面的部分,它叫片段标识符(fragment identifier)

比如 http://hahaha.com/a.html#fragment#fragment ,如果只是改变片段标识符,页面是不会重新刷新的,就像大名鼎鼎的Vue中的hash路由就是用的这种方式

通过 location.hash + iframe 我们可以做到在不同主域下也可以拿到对方的数据

示例

首先,我们要实现页面a和页面b的跨域相互通信,因为不同域所以利用 iframe 加上 location.hash 传值,但是这个传值是单向的,只能由一方向另一方传值,不同域时子页面并不能获取到父页面,也就不能相互通信,所以我们需要一个中间人页面c来帮忙

不同域之间利用 iframelocation.hash 传值,相同域之间直接 JS 访问来通信

那么我们的逻辑就变成了下面这样

a 与 b 不同域只能通过hash值单向通信,b 与 c 也不同域也只能单向通信,但 c 与 a 同域,所以 c 可通过parent.parent 访问 a 页面所有对象

页面a:http://www.hahaha0.com/a.html

<!--a中通过iframe引入了b-->
<iframe id="frame" src="http://www.hahaha1.com/b.html"></iframe>
<script>
  let frame = document.getElementById('frame');

  // 向b传hash值
  frame.src = frame.src + '#a=我是a';

  // 给同域c使用的回调方法
  function cb(data) {
    console.log(data) // 打印 我是a+b
  }
</script>

页面b:http://www.hahaha1.com/b.html

<!--b中通过iframe引入了中间人c-->
<iframe id="frame" src="http://www.hahaha0.com/c.html"></iframe>
<script>
  let frame = document.getElementById('frame');

  // 监听a传来的hash值,传给c.html
  window.onhashchange = function () {
    frame.src = frame.src + location.hash + '+b';
  };
</script>

页面c:http://www.hahaha0.com/c.html

<script>
  // 监听 b 的hash值变化
  window.onhashchange = function () {
    // c调用父亲的父亲,来操作同域a的js回调,将结果传回
    window.parent.parent.cb(location.hash.replace('#a=', ''));
  };
</script>

No.3 window.name + iframe跨域

简介

window 对象有一个 name 属性,该属性有一个特征,即在一个窗口的生命周期内,窗口载入所有的页面都是共享一个 window.name 的,每一个页面对 window.name 都有读写的权限

window.name 是持久的存在于一个窗口载入的所有页面中的,并不会因为新的页面的载入而被重置,比如下例

页面a

<script>
  window.name = '我是a';
  setInterval(function(){
    window.location = 'b.html'; // 两秒后把一个新页面b.html载入到当前的window中
  },2000) 
</script>

页面b

<script>
  console.log(window.name); // 我是a
</script>

通过上面这个例子,我们可以很直观的看到,a 页面载入2s后,跳转到 b 页面,b 会在控制台输出 我是a

不过 window.name 的值只能是字符串的形式,最大允许2M左右,具体取决于不同的浏览器,但是一般是够用了

那么我们就可以利用它这一特性来实现跨域,看标题就知道是使用 window.nameiframe ,那么你能想到要如何投机取巧,哦不,是巧妙的规避跨域而不留痕迹吗?

经历过上文的摧残我们知道,不同域情况下的 a 页面和 b 页面,使用 iframe 嵌入一个页面,数据也是互通不了的,因为会跨域,这里我们要使用 window.name + iframe 来实现跨域数据互通,显然我们不能直接在 a 页面中通过改变 window.location 来载入b 页面,因为我们现在需要实现的是 a 页面不跳转,但是也能够获取到 b 中的数据

究竟要怎么实现呢?其实还是要靠一个中间人页面 c

首先中间人 c 要和 a 是同域

a 页面中通过 iframe 加载了 b ,在 b 页面中把数据留在了当前 iframe 窗口的 window.name 属性里

这个时候 a 是读取不了 iframe 的,因为不同域,但是我们可以在 a 中动态的把 iframesrc 改为 c

中间人 c 什么都不用写,因为它直接继承了 b 留下的 window.name

因为c 和 a因为是同域,所以 a 可以正常拿到子页面 c 中的 window.name 属性值

不得不说,这种做法还真挺让人叹为观止的,致敬前辈们

示例

页面a:http://www.hahaha1.com/abc/a.html

<iframe src="http://www.hahaha2.com/abc/b.html" id="frame" onload="load()"></iframe>
<script>
	let flag = true
  // onload事件会触发2次
  // 第1次onload跨域页b成功后,留下数据window.name,后切换到同域代理页面
  // 第2次onload同域页c成功后,读取同域window.name中数据
  function load() {
    if(flag){
      // 第1次
      let frame = document.getElementById('frame')
      frame.src = 'http://www.hahaha1.com/abc/c.html'
      flag = false
    }else{
      // 第二次
      console.log(frame.contentWindow.name) // 我是b
    }
  }
</script>

页面b:http://www.hahaha2.com/abc/b.html

<script>
  window.name = '我是b'  
</script>

No.4 window.postMessage跨域

简介

我们上面说的几种窗口跨域做法是可以适用相应场景且安全可靠的,但是它们都是属于投机取巧,不对,是另辟捷径,但是HTML5 XMLHttpRequest Level 2中为了解决这个问题,引入了一个全新的API:跨文档通信 API(Cross-document messaging)

这个API为 window 对象新增了一个 window.postMessage 方法,可以允许来自不同源的脚本采用异步方式进行有限的通信,可以实现跨文本档、多窗口、跨域消息传递

主流浏览器的兼容情况也非常可观

我们来看下它的使用,先来看看它怎么发送数据

otherWindow.postMessage(message, targetOrigin, [transfer]);
  • otherWindow
    • 窗口的一个引用,比如 iframecontentWindow 属性,执行 window.open 返回的窗口对象,或者是命名过的或数值索引的 window.frames
  • message
    • 要发送到其他窗口的数据,它将会被 结构化克隆算法 序列化,这意味着你可以不受什么限制的将数据对象安全的传送给目标窗口而无需自己序列化
  • targetOrigin
    • 通过窗口的 origin 属性来指定哪些窗口能接收到消息事件,指定后只有对应 origin 下的窗口才可以接收到消息,设置为通配符 * 表示可以发送到任何窗口,但通常处于安全性考虑不建议这么做,如果想要发送到与当前窗口同源的窗口,可设置为 /
  • transfer | 可选属性
    • 是一串和 message 同时传递的 Transferable 对象,这些对象的所有权将被转移给消息的接收方,而发送一方将不再保有所有权

它也可以监听 message 事件的发生来接收数据

window.addEventListener("message", receiveMessage, false)
function receiveMessage(event) {
  let origin= event.origin
  console.log(event)
}

接下来我们实战下跨域情况下,通过 window.postMessage 来互通数据

示例

还是以不同域的页面 a 和 b 为例子

页面a:http://www.hahaha1.com/abc/a.html,创建跨域 iframe 并发送信息

<iframe src="http://www.hahaha2.com/abc/b.html" id="frame" onload="load()"></iframe>
<script>
  function load() {
    let frame = document.getElementById('frame')
    // 发送
    frame.contentWindow.postMessage('哈喽,我是a', 'http://www.hahaha2.com/abc/b.html')
    
    // 接收
    window.onmessage = function(e) {
      console.log(e.data) // 你好,我是b
    }
  }
</script>

页面b:http://www.hahaha2.com/abc/b.html,接收数据并返回信息

<script>
  // 接收
  window.onmessage = function(e) {
    console.log(e.data) // 哈喽,我是a
    // 返回数据
    e.source.postMessage('你好,我是b', e.origin)
  }
</script>

No.5 JSONP跨域

写在前面

对于 JSONP 这块,虽然不常用,我们好好的提一下,因为遇到过一些初学者,把 AJAXJSONP 混为一谈了,提起 JSONP ,会说很 easy,就是在 AJAX 请求里设置一下字段就行了,可能你用过 JQuery 封装后的 JSONP 跨域方式,确实只是在请求里加个字段,但是,那是 JQ 封装好的一种使用方式而已,可不能被表象迷惑,你真的懂它的原理吗(JQ:我可不背锅!!!)

AJAX工作原理

Ajax 的原理简单来说通过浏览器的 javascript 对象 XMLHttpRequest (Ajax引擎)对象向服务器发送异步请求并接收服务器的响应数据,然后用 javascript 来操作 DOM 而更新页面

这其中最关键的一步就是从服务器获得请求数据,即用户的请求间接通过 Ajax 引擎发出而不是通过浏览器直接发出,同时 Ajax 引擎也接收服务器返回响应的数据,所以不会导致浏览器上的页面全部刷新

使用方式也很简单

一:创建XMLHttpRequest对象,也就是创建一个异步调用对象

二:创建一个新的HTTP请求,并指定该HTTP请求的方法、URL及验证信息

三:设置响应HTTP请求状态变化的函数

四:发送HTTP请求

五:获取异步调用返回的数据

JSONP,JSON?

JSON(JavaScript Object Notation) 大家应该是很了解,就是一种轻量级的数据交换格式,不了解的同学可以去json.org 上了解下,分分钟搞定

JSONP(JSON with Padding) ,它是一个 非官方 的协议,它允许在服务器端集成 Script tags 返回至客户端,通过 javascript callback 的形式实现跨域访问,这就是简单的JSONP实现形式,这么说可能不太明白,那我们来看下它到底是怎么个原理

JSONP工作原理

先来看个小例子,还是不同域的 a 和 b 两页面

页面a:http://www.hahaha1.com/abc/a.html

<html>
<head>
    <title>test</title>
    <script type="text/javascript" src="http://www.hahaha2.com/abc/b.html"></script>
</head>
<body>
  <script>
  	console.log(b) // 我是b
  </script>
</body>
</html>

页面b:http://www.hahaha2.com/abc/b.js

var b = "我是b"

可以看到,虽然不同域,但是 a 页面中还是可以访问到并打印出了 b 页面中的变量

这个小例子我们可以很直观的看到 <script> 标签的 src 属性并不被同源策略所约束,所以可以获取任何服务器上脚本并执行它,这就是 JSONP 最核心的原理了,至于它如何传递数据,我们来简单实现一个

JSONP的CallBack实现

刚才的例子说了跨域的原理,而且我们之前有讲到 javascript callback 的形式实现跨域访问,那我们就来修改下代码,如何实现 JSONPjavascript callback 的形式

页面a:http://www.hahaha1.com/abc/a.html

<script type="text/javascript">
  //回调函数
  function cb(res) {
      console.log(res.data.b) // 我是b
  }
</script>
<script type="text/javascript" src="http://www.hahaha2.com/abc/b.js"></script>

页面b:http://www.hahaha2.com/abc/b.js

var b = "我是b"

// 调用cb函数,并以json数据形式作为参数传递
cb({
  code:200, 
  msg:"success",
  data:{
    b: b
  }
})

创建一个回调函数,然后在远程服务上调用这个函数并且将JSON 数据形式作为参数传递,完成回调,就是 JSONP 的简单实现模式,或者说是 JSONP 的原型,是不是很简单呢

JSON 数据填充进回调函数,现在懂为什么 JSONPJSON with Padding 了吧

上面这种实现很简单,通常情况下,我们希望这个 script 标签能够动态的调用,而不是像上面因为固定在 HTML 里面加载时直接执行了,很不灵活,我们可以通过 javascript 动态的创建 script 标签,这样我们就可以灵活调用远程服务了,那么我们简单改造下页面 a 如下

<script type="text/javascript">
  function cb(res) {
    console.log(res.data.b)  // 我是b
  }
  
  // 动态添加 <script> 标签方法
  function addScriptTag(src){
    let script = document.createElement('script')
    script.setAttribute("type","text/javascript")
    script.src = src
    document.body.appendChild(script)
  }

  window.onload = function(){
    addScriptTag("http://www.hahaha2.com/abc/b.js")
  }
</script>

如上所示,只是些基础操作,就不解释了,现在我们就可以优雅的控制执行了,再想调用一个远程服务的话,只要添加 addScriptTag 方法,传入远程服务的 src 值就可以

接下来我们就可以愉快的进行一次真正意义上的 JSONP 服务调取了

我们使用 jsonplaceholdertodos 接口作为示例,接口地址如下

https://jsonplaceholder.typicode.com/todos?callback=?

callback=? 这个拼在接口后面表示回调函数的名称,也就是将你自己在客户端定义的回调函数的函数名传送给服务端,服务端则会返回以你定义的回调函数名的方法,将获取的 JSON 数据传入这个方法完成回调,我们的回调函数名字叫 cb,那么完整的接口地址就如下

https://jsonplaceholder.typicode.com/todos?callback=cb

那么话不多说,我们来试下

<script type="text/javascript">
  function cb(res) {
    console.log(res)
  }
  
  function addScriptTag(src){
    let script = document.createElement('script')
    script.setAttribute("type","text/javascript")
    script.src = src
    document.body.appendChild(script)
  }

  window.onload = function(){
    addScriptTag("https://jsonplaceholder.typicode.com/todos?callback=cb")
  }
</script>

可以看到,页面在加载完成后,输出了接口返回的数据,这个时候我们再来看 JQ 中的 JSONP 实现

JSONP的JQuery实现

还是用上面的接口,我们来看 JQ 怎么拿数据

$.ajax({
  url:"https://jsonplaceholder.typicode.com/todos?callback=?",   
  dataType:"jsonp",
  jsonpCallback:"cb",
  success: function(res){
    console.log(res)
  }
});

可以看到,为了让 JQ 按照 JSONP 的方式访问,dataType 字段设置为 jsonpjsonpCallback 属性的作用就是自定义我们的回调方法名,其实内部和我们上面写的差不多

JSONP和AJAX对比

  • 调用方式上

    • AJAXJSONP 很像,都是请求url,然后把服务器返回的数据进行处理
    • 所以类 JQuery 的库只是把 JSONP 作为 AJAX 请求的一种形式进行封装,不要搞混
  • 核心原理上

    • AJAX 的核心是通过 xmlHttpRequest 获取非本页内容
    • JSONP的核心是动态添加 script 标签调用服务器提供的 JS 脚本,后缀 .json
  • 两者区别上,

    • AJAX 不同域会报跨域错误,不过也可以通过服务端代理、CORS 等方式跨域,而 JSONP 没有这个限制,同域不同域都可以
    • JSONP 是一种方式或者说非强制性的协议,AJAX 也不一定非要用 json 格式来传递数据 
    • JSONP 只支持 GET 请求,AJAX 支持 GETPOST

最后,JSONP是很老的一种跨域方式了,现在基本没什么人用,所以,我们了解懂它即可

一般情况下,我们希望这个script标签能够动态的调用,而不是像上面因为固定在html里面所以没等页面显示就执行了,很不灵活。我们可以通过javascript动态的创建script标签,这样我们就可以灵活调用远程服务了

No.6 CORS跨域资源共享

什么是CORS?

在出现 CORS 之前,我们都是使用 JSONP 的方式实现跨域,但是这种方式仅限于 GET 请求,而 CORS 的出现,为我们很好的解决了这个问题,这也是它成为一个趋势的原因

CORS 是一个W3C标准,全称是 跨域资源共享(Cross-origin resource sharing)

它允许浏览器向跨源服务器,发出 XMLHttpRequest 请求,从而克服了 AJAX 只能同源使用的限制

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

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

因此,实现 CORS 通信的关键是服务器,只要服务器设置了允许的 CORS 接口,就可以进行跨源通信,要了解怎么实现 CORS 跨域通信,我们还要先了解浏览器对每个请求都做了什么

浏览器会将 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

只要同时满足这两个条件,那么这个请求就是一个简单请求

对于简单请求来说,浏览器会直接发出CORS请求,就是在这个请求的头信息中,自动添加一个 Origin 字段来说明本次请求的来源(协议 + 域名 + 端口),而后服务器会根据这个值,决定是否同意这次请求

非简单请求

知道了简单请求的定义,非简单请求就比较简单了,因为只要不是简单请求,它就是非简单请求

浏览器应对非简单请求,会在正式通信之前,做一次查询请求,叫预检请求(preflight),也叫 OPTIONS 请求,因为它使用的请求方式是 OPTIONS ,这个请求是用来询问的

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

在这个预检请求里,头信息除了有表明来源的 Origin 字段外,还会有一个 Access-Control-Request-Method 字段和 Access-Control-Request-Headers 字段,它们分别表明了该浏览器 CORS 请求用到的 HTTP 请求方法和指定浏览器 CORS 请求会额外发送的头信息字段,如果你看的云里雾里,不要着急,我们看个例子

如下为一个 AJAX 请求示例

let url = 'http://www.hahaha.com/abc'
let xhr = new XMLHttpRequest()
xhr.open('POST', url, true)
xhr.setRequestHeader('X-Token', 'YGJHJHGJAHSGJDHGSJGJHDGSJHS')
xhr.setRequestHeader('X-Test', 'YGJHJHGJAHSGJDHGSJGJHDGSJHS')
xhr.send()

这个例子中,我们发送了一个POST请求,并在它的请求头中添加了一个自定义的 X-TokenX-Test 字段,因为添加了自定义请求头字段,所以它是一个非简单请求

那么这个非简单请求在预检请求头信息中就会携带以下信息

// 来源
Origin: http://www.hahaha.com
// 该CORS请求的请求方法
Access-Control-Request-Method: POST
// 额外发出的头信息字段
Access-Control-Request-Headers: X-Token, X-Test

withCredentials属性

CORS 请求默认不发送 Cookie 和 HTTP 认证信息

如果要把 Cookie 发到服务端,首先要服务端同意,指定Access-Control-Allow-Credentials 字段

Access-Control-Allow-Credentials: true

其次,客户端必须在发起的请求中打开 withCredentials 属性

xhr = new XMLHttpRequest()
xhr.withCredentials = true

不然的话,服务端和客户端有一个没设置,就不会发送或处理Cookie

虽说浏览器默认不发送 Cookie 和 HTTP 认证信息,但是有的浏览器,还是会一起发送Cookie,这时你也可以显式关闭 withCredentials

xhr.withCredentials = false

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

服务端CORS跨域配置

上面的东西只是为了让我们理解CORS,但是要解决它还是需要服务端配置的,不同语言的配置项语法上可能有差异,但是内容肯定都是一样的

配置允许跨域的来源

Access-Control-Allow-Origin: *

CORS 跨域请求中,最关键的就是 Access-Control-Allow-Origin 字段,是必需项,它表示服务端允许跨域访问的地址来源,你可以写入需要跨域的域名,也可以设为星号,表示同意任意跨源请求

注意,将此字段设置为 * 是很不安全的,建议指定来源,并且设置为 * 号后,游览器将不会发送 Cookie,即使你的 XHR 设置了 withCredentials,也不会发送 Cookie

配置允许跨域请求的方法

Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT...

该字段也是必需项,它的值是逗号分隔的一个字符串,表明服务器支持的所有跨域请求的方法

配置允许的请求头字段

Access-Control-Allow-Headers: x-requested-with,content-type...

如果你的请求中有自定义的请求头字段,那么此项也是必须的,它也是一个逗号分隔的字符串,表明服务器支持的所有头信息字段,不限于浏览器在预检中请求的字段

配置是否允许发送Cookie

Access-Control-Allow-Credentials: true

该字段可选,它的值是一个布尔值,表示是否允许发送Cookie,默认情况下,Cookie不包括在CORS请求之中

设为true,即表示服务器明确许可,Cookie可以包含在请求中,一起发给服务器

该字段只能设为true,如果服务器不要浏览器发送Cookie,删除该字段即可

配置本次预检请求的有效期

Access-Control-Max-Age: 1728000

该字段可选,用来指定本次预检请求的有效期,单位为秒,上面结果中,有效期是20天(1728000秒),即允许缓存该条回应20天,在此期间如果你再次发出了这个接口请求,就不用发预检请求了,节省服务端资源

常见的跨域预检请求抛错

对于我们开发时,在跨域中最容易碰钉子的地方就是预检请求,所以列举几个预检请求错误的原因,知道哪错了可以直接找后端同学理论,关于预检请求,最终目的只有一个,客户端发送预检,服务端允许并返回200即可

OPTIONS 404

No 'Access-Control-Allow-Origin' header is present on the requested resource
且 The response had HTTP status code 404

服务端没有设置允许 OPTIONS 请求,那么在发起该预检请求时响应状态码会是404,因为无法找到对应接口地址

那么你可能需要找到后端,优雅的告诉他,请允许下 OPTIONS 请求

OPTIONS 405

No 'Access-Control-Allow-Origin' header is present on the requested resource
且 The response had HTTP status code 405

服务端已经允许了 OPTIONS 请求,但是一些配置文件中(如安全配置)阻止了 OPTIONS 请求

那么你可能需要找到后端,优雅的告诉他,请关闭对应的安全配置

OPTIONS 200

No 'Access-Control-Allow-Origin' header is present on the requested resource
且 OPTIONS 请求 status 为 200

服务器端允许了 OPTIONS 请求,配置文件中也没有阻止,但是头部匹配时出现不匹配现象

所谓头部匹配,就比如 Origin 头部检查不匹配,或者少了一些头部的支持(如 X-Requested-With 等),然后服务端就会将 Response 返回给前端,前端检测到这个后就触发 XHR.onerror ,从而导致报错

那么你可能需要找到后端,优雅的告诉他,请增加对应的头部支持

OPTIONS 500

这个就更简单了,服务端针对 OPTIONS 请求的代码出了问题,或者没有响应

那么你可能需要找到后端,将 Network 中的错误信息截一图发给他,优雅的告诉他,检测到预检请求时,请把它搞成200

No.7 Nginx代理跨域

iconfont跨域解决

浏览器跨域访问 js/css/img 等常规静态资源时被同源策略许可的,但 iconfont 字体文件比如 eot|otf|ttf|woff|svg 例外,此时可在 Nginx 的静态资源服务器中加入以下配置来解决

location / {
  add_header Access-Control-Allow-Origin *;
}

反向代理接口跨域

我们知道同源策略只是 浏览器 的安全策略,不是 HTTP 协议的一部分, 服务器端调用 HTTP 接口只是使用 HTTP 协议,不会执行 JS 脚本,不需要同源策略,也就不存在跨越问题

通俗点说就是客户端浏览器发起一个请求会存在跨域问题,但是服务端向另一个服务端发起请求并无跨域,因为跨域问题归根结底源于同源策略,而同源策略只存在于浏览器

那么我们是不是可以通过 Nginx 配置一个代理服务器,反向代理访问跨域的接口,并且我们还可以修改 Cookiedomain 信息,方便当前域 Cookie 写入

Nginx 其实就是各种配置,简单易学,就算没接触过,也很好理解,我们来看示例

首先假如我们的页面 a 在 http://www.hahaha.com 域下,但是我们的接口却在 http://www.hahaha1.com:9999 域下

接着我们在页面 a 发起一个 AJAX 请求时,就会跨域,那么我们就可以通过 Nginx 配置一个代理服务器,域名和页面 a 相同,都是 http://www.hahaha.com ,用它来充当一个跳板的角色,反向代理访问 http://www.hahaha1.com 接口

# Nginx代理服务器
server {
  listen       80;
  server_name  www.hahaha.com;

  location / {
    # 反向代理地址
    proxy_pass   http://www.hahaha1.com:9999;  
    # 修改Cookie中域名
    proxy_cookie_domain www.hahaha1.com www.hahaha.com; 
    index  index.html index.htm;
		
    # 前端跨域携带了Cookie,所以Allow-Origin配置不可为*
    add_header Access-Control-Allow-Origin http://www.hahaha.com;  
    add_header Access-Control-Allow-Credentials true;
  }
}

没错,这个代理配置相信没接触过 Nginx 也能看明白,大部分都是我们上文提到过的,是不是很简单呢

No.8 Node代理跨域

Node 实现跨域代理,与 Nginx 道理相同,都是启一个代理服务器,就像我们常用的 Vue-CLI 配置跨域,其实也是 Node 启了一个代理服务,接下来我们来看看是如何做的

Vue-CLI中代理的多种配置

Vue-CLI 是基于 webpack 的,通过 webpack-dev-server 在本地启动脚手架,也就是在本地启动了一个 Node 服务,来实时监听和打包编译静态资源,由于都是封装好的,只需要配置即可,我们在 vue.config.js 中配置代理如下,写法很多,列几个常见的自行选择

使用一

module.exports = {
  //...
  devServer: {
    proxy: {
      '/api': 'http://www.hahaha.com'
    }
  }
}

如上所示时,当你请求 /api/abc 接口时就会被代理到 http://www.hahaha.com/api/abc

使用二

当然,你可能想将多个路径代理到同一个 target 下,那你可以使用下面这种方式

module.exports = {
  //...
  devServer: {
    proxy: [{
      context: ['/api1', '/api2', '/api3'],
      target: 'http://www.hahaha.com',
    }]
  }
}

使用三

正如我们第一种使用方式代理时,代理了 /api ,最终的代理结果是 http://www.hahaha.com/api/abc ,但是有时我们并不想代理时传递 /api,那么就可以使用下面这种方式,通过 pathRewrite 属性来进行路径重写

module.exports = {
  //...
  devServer: {
    proxy: {
      '/api': {
        target: 'http://www.hahaha.com',
        pathRewrite: {'^/api' : ''}
      }
    }
  }
}

这个时候,/api/abc 接口就会被代理到 http://www.hahaha.com/abc

使用四

默认情况下,我们代理是不接受运行在 HTTPS 上,且使用了无效证书的后端服务器的

如果你想要接受,需要设置 secure: false ,如下

module.exports = {
  //...
  devServer: {
    proxy: {
      '/api': {
        target: 'https://www.hahaha.com',
        secure: false
      }
    }
  }
}

使用五

配置一个字段 changeOrigin ,当它为 true 时,本地就会虚拟一个服务器接收你的请求并且代你发送该请求,所以如果你要代理跨域,这个字段是必选项

module.exports = {
  // ...
  devServer: {
    proxy: {
      "/api": {
        target: 'http://www.hahaha.com',
        changeOrigin: true,
      }
    }
  }
}

使用六

如果你想配置多个不同的代理,也简单,如下所示,可以在任意代理中设置对应的代理规则

module.exports = {
  // ...
  devServer: {
    proxy: {
      "/api1": {
        target: 'http://www.hahaha1.com',
        changeOrigin: true
      },
      "/api2": {
        target: 'http://www.hahaha2.com',
        pathRewrite: {'^/api2' : ''}
      },
      "/api3": {
        target: 'http://www.hahaha3.com',
        changeOrigin: true,
        pathRewrite: {'^/api3' : ''}
      }
      // ...
    }
  }
}

注意,在本地配置代理跨域,只是解决开发时的跨域问题,当你的项目上线时,前端静态文件和后端在一个域下没有问题,如果并不在一个域下,依然会报跨域错误,这个时候还得需要后端配置跨域

Node实现代理服务器

这里我们使用 express + http-proxy-middleware 来搭建一个代理服务器,使用 http-proxy-middleware 这个中间件没有别的意思,只是因为 webpack-dev-server 里就是使用的它

let express = require('express')
let proxy = require('http-proxy-middleware')
let app = express()

app.use('/', proxy({
    // 代理跨域目标接口
    target: 'http://www.hahaha1.com:9999',
    changeOrigin: true,

    // 修改响应头信息,实现跨域并允许带cookie
    onProxyRes: function(proxyRes, req, res) {
        res.header('Access-Control-Allow-Origin', 'http://www.hahaha.com')
        res.header('Access-Control-Allow-Credentials', 'true')
    },

    // 修改响应信息中的cookie域名,为false时,表示不修改
    cookieDomainRewrite: 'www.hahaha.com'
}))

app.listen(3000)

No.9 WebSocket跨域

WebSocket简介

WebSocket 是一种在单个 TCP 连接上进行全双工通信的协议,2008年诞生,2011年被 IETF 定为标准 RFC 6455,并由 RFC7936 补充规范,WebSocket API 也被 W3C 定为标准

WebSocket 使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据, 在 WebSocket API 中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输,同时,它也是跨域的一种解决方案

WebSocket特点

  • 建立在 TCP 协议之上,服务器端的实现比较容易

  • 与 HTTP 协议有着良好的兼容性,默认端口也是 80 和 443,并且握手阶段采用 HTTP 协议,因此握手时不容易屏蔽,能通过各种 HTTP 代理服务器

  • 数据格式比较轻量,性能开销小,通信高效

  • 可以发送文本,也可以发送二进制数据

  • 没有同源限制,客户端可以与任意服务器通信

  • 协议标识符是 ws(如果加密,则为 wss ),服务器网址就是 URL

如下

ws://www.hahaha.com:80/abc/def

示例

每个服务端语言对 websocket 有相应的支持,写法不同罢了,这里我们使用 Node 做示例

在客户端我们可以直接使用 HTML5 的 websocket API ,服务端也可以使用 nodejs-websocket 实现 websocket server ,但是不建议这样做,因为原生 WebSocket API 使用起有些复杂,在浏览器的兼容性上还不够理想,所以我们使用 Socket.io,它很好地封装了 webSocket 接口,提供了更简单、灵活的接口,也对不支持 webSocket 的浏览器提供了向下兼容,使用 Socket.io 库实现 websocket,在发送数据时可以直接发送可序列化的对象,也可以自定义消息,利用事件字符串来区分不同消息,整个开发过程会舒服很多

想要了解更多看官网即可 Socket.io - 传送门 ,我们来看示例

客户端:http://www.hahaha.com/a.html

<script src="/socket.io/socket.io.js"></script>
<script>
  let socket = io.connect('http://www.hahaha1.com:3000')
  
  socket.on('my event', (data) => {
    console.log(data) // { hello: 'world' }
    
    socket.emit('my other event', { my: 'data' })
  })
</script>

服务端:http://www.hahaha1.com:3000

const app = require('express').createServer()
const io = require('socket.io')(app)

app.listen(3000)

io.on('connection', (socket) => {
  socket.emit('my event', { hello: 'world' })
  
  socket.on('my other event', (data) => {
    console.log(data) // { my: 'data' }
  })
})

如上所示,使用了 Socket.io 之后的 websocket 连接是不是超级简单呢,跟着文档自己动手试试吧

最后

欢迎大家关注公众号「不正经的前端」,时不时发一篇文章,也没有花里胡哨的推广和广告,希望可以让大家随意点开一篇文章,都可以看到满满的干货,也可以直接加机器人好友备注「前端 | 后端 | 全栈」自动通过,加交流群,闲聊、吐槽、解决问题、交朋友都可以,当然技术为主

按照时间线贴下了总结的比较全的几个帖子,还有其他的琐碎的文章,比较多就不贴了,这些文章都写的差不多,可能之间有互相抄袭,有互相借鉴,这些都是避免不了的,此文写的时候也借鉴了这些文章,只不过我手敲了一遍例子,又用我自己的理解码下来了,为此花了1周的业余时间,内容上与下面作者写的有些许雷同,那实属无奈,可以说是知识点就那么多,大家的总结稍有不同的地方就剩表达的语法,我也难受,还特意找了工具鉴别了下相似度,以免被误会,毕竟我也特别反感搬运工,嗯,又是一个深夜,终于收工了,睡觉喽,哦对了,码字不易,望点赞,如有错误,望指出,谢谢支持

参考文章

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

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

前端跨域整理 - 思否 damonare - 2016.10

前端常见跨域解决方案(全)- 思否 安静de沉淀 - 2017.07

正确面对跨域,别慌 - 掘金 Neal_yang - 2017.12

九种跨域方式实现原理(完整版)- 掘金 浪里行舟 - 2019.01

9种常见的前端跨域解决方案(详解)- 掘金 小铭子 - 2019.07