前端跨域方法总结

4,798 阅读13分钟

为什么需要跨域

浏览器出于安全的考虑,引入了同源策略。这种策略会对我们页面上执行的js访问资源的时候进行限制,比如我们不能直接通过js访问不同源之下的页面DOM结构,同时在对不同源发送请求时也无法获取到服务器响应内容(服务器会正常处理请求并返回响应内容,但是返回的内容被浏览器拦截掉了)。这里还牵扯到“源”这个概念,如果我们访问的目标url和当前页面所在的url两者的协议、域名、端口只要有一个不相同,那么就认为是属于两个不同的源。明白了源的定义之后,我们再来看看在同源策略的作用下,我们可以在页面上做的以及不能做的都有哪些操作。

先说能够做的,比如通过js重定向我们的页面(修改location.href),表单提交,这些都是可以的。还有就是通过嵌入一些HTML标签来加载我们需要的资源,比如script标签引入一段脚本、img标签插入一张图片、link标签加载样式文件、iframe嵌入不同源的页面等等也都是可以的。这些也是我们日常开发过程中再正常不过的操作了。

但是,同源策略对js访问一些敏感资源则进行了限制。除了开头提到的那两点之外,还有就是js中无法访问不属于同个源的cookie、LocalStorage中存储的内容。具体来说,cookie和LocalStorage在控制哪些源可以访问的问题上还是细微的差别,父域在设置cookie的时候可以设定允许子域访问这段cookie,同时Cookie只和域名以及路径关联,如果是同个域名不同端口的源依然是共享同个域名下的Cookie的,而LocalStorage则是以源为单位进行管理,相互独立,不同源之间无法相互访问LocalStorage中的内容。

常用的跨域方法

客户端与服务端通信

客户端与不同源的服务器的通信问题是平常开发过程中需要解决的很常见的问题,主要有以下几种方式:

CORS

这种方法应该是用得比较多的一种。CORS全称是Cross-Origin Resource Sharing,翻译过来就是跨域资源共享。基本思想就是引入一些自定义的HTTP Header来完成客户端与服务端的通信。

对于一些简单请求,浏览器在发送请求时会带上Origin请求头,指示当前的源,服务器端在处理请求时不会去检查当前请求来源是否合法,依然会正常处理请求并响应,最终浏览器在拿到响应之后会检查服务端响应的Access-Control-Allow-Origin列表中是否存在当前页面所在的源,如果不存在会直接block掉当前请求。

在浏览器看来,同时满足以下条件的请求都认为是简单请求:

请求方法为GET或者POST; 只包含Accept、Accept-Language、Content-Language或者Content-Type(取值为application/x-www-form-urlencoded,multipart/form-data, 或者text/plain),其余情况的Header则属于非简单Header;

对于非简单请求,浏览器会先向服务器发送一个Preflight请求,该请求使用Option方法,并包含以下Header:

  1. Origin
  2. Access-Control-Request-Method:询问服务器是否支持某方法;
  3. Access-Control-Request-Headers:询问服务器是否支持请求中包含的非简单Header;

其中后两个Header只会出现在Preflight请求中。然后浏览器收到包含以下Header的服务器响应:

  1. Access-Control-Allow-Origin
  2. Access-Control-Allow-Methods:对客户端回应服务器支持的请求方法列表;
  3. Access-Control-Allow-Headers:对客户端回应服务器支持的Header;

Preflight请求至此也算是告一段落,之后浏览器会检查当前请求发出的源是否在服务端响应的Access-Control-Allow-Origin列出的源的列表中,如果是才会发送真正的请求。在实验过程中,浏览器并不一定要在服务器支持Preflight请求查询的请求方法和Header时才发送真正的请求,只要发出请求的源是合法的就会在Preflight请求之后把请求发出去。

JSONP

JSONP 的全称是 JSON with Padding,译为被填充的JSON。前端在指定要请求的URL时可以通过和后端约定一个指定回调函数名称的参数,确保后台响应的脚本片段中调用了前端指定的回调函数,以此可以实现发送多个JSONP请求而且互不干扰。具体JSONP实现如下:

var delicious_callbacks = {};
function jsonp(url, callback) {
  var uid = (new Date()).getTime();
  delicious_callbacks[uid] = function (data) {
    delete delicious_callbacks[uid];
    callback(data);
  };
  url += "?jsonp=" + encodeURIComponent("delicious_callbacks[" + uid + "]");
  var script = document.createElement('script')
  script.src = url
  document.body.appendChild(script)
};

jsonp("http://example.com/api", function(data) { // here we get the data });

JSONP这种方式本身也是存在一定缺陷的,很明显它只能用于GET请求。另外,后端应用程序在处理过程可能会出现4xx、5xx错误或者遇到其他意外情况,导致无法返回正确的js函数调用格式的字符串的情况,所以还需要监听script标签的onerror事件来处理可能出现的意外情况。

Cookie跨域共享

cookie作为客户端存储的一种方案,在客户端设置cookie也有以下几种方法:

  1. 配置服务端返回Set-Cookie响应头
  2. 在页面上的JavaScript代码中通过document.cookie,直接设置document.cookie为我们需要存储的内容并不会覆盖现有的cookie,举例:
document.cookie="name=Jack;path=/"
document.cookie="age=25;path=/" // cookie中会同时保存name和age这两个字段

cookie是有过期时间的,如果像上面的代码一样没有显式地设置cookie的过期时间,则在浏览器退出之后相应的cookie也会被清除。

一般情况下,浏览器在访问页面时会自动将和当前域名以及路径匹配的Cookie发送到服务器端。而在一般情况下,我们在页面中发出的Ajax请求则不会自动将当前URL关联的Cookie同请求一同发送到服务端。如果我们需要在请求中将和当前访问的URL的Cookie发送到服务端,可以设置XMLHttpRequest对象的withCredentials属性为true。

而如果是要将当前页面所在域名的Cookie发送到另一个域名下的服务端,这时候需要对服务端进行配置,使其支持CORS,同时还需要注意此时服务端返回的Access-Control-Allow-Origin不能再设置为‘*’,同时服务端需要返回Access-Control-Allow-Credentials: true,否则服务端的响应依然会被浏览器block掉。

在这样配置之后,浏览器在发送这种携带凭据信息(也就是Cookie)的Ajax请求时就会把当前页面所在的域名下path属性和请求的URL相匹配(比如当前请求的URL为/test/example,那么设置在/,/test/,/test/example这些path之下的Cookie会被发送)的Cookie一同发送到服务端。这样就实现了Cookie的跨域共享。

跨页面通信

除了和不同源的服务器进行通信的需求以外,我们还会遇到跨页面通信问题,需要访问其他页面上的一些信息,或者将一些数据持久化,以供其他页面取用。具体方式如下:

document.domain

通过这种方式跨域的两个源需要满足一定的条件的,即两个源的域名需要是父子域的关系或者是相同的域。因为页面设置document.domain的值只能是当前域本身,或者是父域,而不能是其他不相关的域名。只有两个页面的document.domain都设置成相同的值,嵌入iframe的页面和iframe加载的页面才能相互获取到彼此的页面信息(包括DOM结构、window对象等)。

在实践中也发现需要注意的两个问题:

  1. 如果两个页面所在的源是一样的,可以直接通信,但是如果两个页面所在的域名相同但端口不同或者是其他情况,那么两个页面仍需要设置相同的document.domain,否则还是会被浏览器block掉。具体原因在MDN上也有提到:

浏览器单独保存端口号。任何的赋值操作,包括document.domain = document.domain都会导致端口号被重写为null。因此company.com:8080不能仅通过设置document.domain = "company.com"来与company.com通信。必须在他们双方中都进行赋值,以确保端口号都为null

  1. 需要在嵌入的iframe加载完成之后才能和其加载的子页面进行通信,否则拿到的值可能还是undefined。

window对象name属性

浏览器具有这样一个特性:同一个标签页或者同一个iframe框架加载过的页面共享相同的window.name属性值,意味着只要是在同一个标签页里面打开过的页面(不管是否同源),这些页面上window.name属性值都是相同的。利用这个特性,就可以将这个属性作为在不同页面之间传递数据的介质。

如果是通过iframe+window.name这种方式在完全没有父子域关系的两个源之间传递数据(假设源A要获取源B中的数据),源A页面上的iframe在加载源B的目标页面(源B页面把数据设置在window.name属性上)之后还需要再跳转到源A的某个页面上,以便于嵌入iframe的页面通过(上面介绍的)和在iframe中的页面将document.domain都设置为源A的方式来获取iframe中的数据。示例代码如下:

// www.a.com/getData.html
<script type="text/javascript">
function getData() {
  var frame = document.getElementsByTagName("iframe")[0];
  frame.onload = function () {
    var data = frame.contentWindow.name;
    // 此处获取数据
    alert(data);
  };
  frame.contentWindow.location = "./aaa.html";
  // 加载完www.b.com/data.html之后就加载www.a.com/下随便一个页面,获取数据
}
</script>
<iframe src="http://www.b.com/data.html" style="display: none;" onload="getData();"></iframe>

HTML5 cross-document message

HTML5中引入了另外一种跨页面通信的方式,称为跨文档消息传送。同样可以实现主页面和嵌入的iframe子页面(或者由当前页面打开的页面)之间完成数据的传递,另外这种方法也可以用于当前JavaScript引擎线程和其他worker线程之间完成数据交换。如果是与通过iframe加载的子页面进行通信,则需要先获取到接收数据的目标页面的window对象(具体通过前面提到的方法来获取),通过该对象的postMessage方法可以向目标页面发送数据。

<!--send.html-->
<iframe src="./receiver.html" id="frame"></iframe>
<button id="send-btn">send message</button>
<script>
  var frame = document.getElementById('frame')
  document.getElementById('send-btn').addEventListener('click', function() {
    frame.contentWindow.postMessage({
      name: 'Jack'
    }, 'http://localhost:8888') // 接收信息的页面所在的源
  })
</script>

<!--receiver.html-->
<script>
  window.addEventListener('message', function(e) {
    // 验证消息发送方所在的源
    if(e.origin === 'http://localhost:8888') {
      console.log(e.data)
      e.source.postMessage(...) // 回送消息
    }
  })
</script>

如果是需要和页面上的worker进行通信,直接调用创建出来的Worker实例的postMessage方法,在Worker实例执行的脚本中则通过self或者this来访问Worker实例,进而调用postMessage方法来完成通信。

localStorage

localStorage是HTML5引入的客户端存储方案,通过localStorage存储的内容会一直保存在客户端,除非调用removeItem方法显式移除,否则内容将永久保留。MDN上对localStorage的介绍也提到了一种通过cookie在不支持localStorage的浏览器上实现localStorage的方法,通过将cookie的过期时间设置为未来很长之后的一个时间点可以模拟localStorage永久保留的特性,而在模拟localStorage移除存储内容时则将对应的cookie。更进一步,如果不设置cookie的过期时间,还可以用来模拟浏览器中的另一种客户端存储方案--sessionStorage。和cookie不同的是,localStorage提供的存储容量上限更大。

前面也提到了,localStorage存储的内容是以源为单位进行管理的,这意味着即使域名相同,端口不同的页面也无法通过localStorage进行通信的。在浏览器的多个标签页中分别打开多个同源页面,这些页面中的window对象可以通过监听storage事件,当其他标签页的页面在设置localStorage中的内容时会触发该事件来进行通知,通过这种方式也可以实现跨页面通信。

其他跨域问题

字体文件加载

CSS中引用的字体文件加载也存在跨域问题,需要设置CORS才能加载其他域下的字体文件。默认情况下定义新的字体不会立即去下载对应的字体文件,只有当页面上的元素使用了这种字体才会去下载对应的字体文件。

跨域脚本错误处理

对于页面上加载的跨域脚本执行出错,页面上绑定的错误处理函数window.onerror在默认情况下是获取不到具体的错误信息的,这时候需要在加载跨域脚本的标签上使用crossorigin属性,也就是在请求跨域脚本的时候执行CORS。crossorigin属性可以设置的值有:

  1. anonymous:请求脚本的时候不会携带凭据
  2. use-credentials:请求脚本的时候携带凭据

设置为其他值都会被看作是anonymous关键字。设置了crossorigin属性意味着还需要对服务器进行配置,使其支持CORS。如果服务端没有正确配置CORS,跨域脚本是无法正常下载的。

canvas绘制内容转化为文件对象

canvas中动态加载的图片可以直接画到canvas中,但是在将canvas转化成文件对象进行操作时也存在跨域问题,会遇到“Tainted canvases may not be exported”错误。这时候需要对动态加载的图片对象设置crossOrigin属性,同时也需要配置服务器使其支持CORS。

let img = new Image()
img.crossOrigin = 'anonymous'
img.src = "//localhost:8888/images/1751527990314_.pic.jpg"
img.onload = () => {
  let canvas = document.getElementById('canvas')
  let ctx = canvas.getContext('2d')
  ctx.drawImage(img, 0, 0)
  canvas.toBlob(blob => console.log(blob), 'image/jpeg', .75)
}

总结

本文主要介绍了浏览器中的同源策略以及如何在同源策略的约束之下完成隶属不同源的客户端和服务端通信,以及跨页面通信。这些跨域方法在实际使用中也需要从具体的场景出发,根据不同的通信需求采用合适的方法。以上,如有疏漏之处,还望斧正。