跨域及其解决方案

355 阅读2分钟

为什么会有跨域?

出于安全性考虑,浏览器限制脚本内发起的跨源HTTP请求。例如,XMLHttpRequest和Fetch API遵循同源策略。这意味着使用这些API的Web应用程序只能从加载应用程序的同一个域请求HTTP资源,除非响应报文包含了正确CORS响应头。

同源策略:如果两个URL的protocol、port(如果有指定的话)和host都相同的话,则这两个URL是同源。

跨域解决方案

1. JSONP

在CORS之前,开发人员也有跨域请求资源的需求,他们提出了多种方案,其中JSONP为常见的一种。JSONP作为一种古老的方案已经不被推荐在项目中使用,但其优势在于支持老式浏览器,以及可以向不支持CORS的网站请求数据,我们也有必要了解下其实现原理,开阔开发思路。

原理: 所有具有src属性的HTML标签都是可以跨域的。在浏览器中,<script>、<img>、<iframe>和<link>这几个标签是可以加载跨域(非同源)的资源的,并且加载的方式其实相当于一次普通的GET请求,唯一不同的是,为了安全起见,浏览器不允许这种方式下对加载到的资源的读写操作,而只能使用标签本身应当具备的能力(比如脚本执行、样式应用等等)。

JSONP的缺点:

  • 只能实现get一种请求方式。
  • JSONP在调用失败的时候前端不能获取各种HTTP状态码。
  • 安全性问题。万一假如提供JSONP的服务存在页面注入漏洞,即它返回的javascript的内容被人控制的。那么结果是什么?所有调用这个JSONP的网站都会存在漏洞。于是无法把危险控制在一个域名下…所以在使用JSONP的时候必须要保证使用的JSONP服务必须是安全可信的。

利用script标签天生具有跨域能力和脚本执行的能力的原理,前后端约定好一个函数名callbackName,使用script标签发送请求,服务器端获取数据res后响应请求返回callback(res)格式的脚本,则会在客户端将res作为参数执行函数callback,至此,客户端获取到了接口的返回值res。

先实现一个简单的JSONP。

jsonp_v1版本

服务器示例代码(node) xxx.xxx.com

router.get('/test', async ctx => {
    const { keyword, callback } = ctx.query;
    let res = await testController.getData({ keyword });
    ctx.body = `${callback}({
      code: 1,
      data: ${JSON.stringify(res)},
      msg: '请求成功'
    })`;
});

前端示例代码

// 先处理下参数
const getEncodeParams = (data = {}) => {
  let res = []
  for (let key in data) {
    res.push(`${encodeURIComponent(key)}=${encodeURIComponent(data[key])}`);
  }
  return res.join('&');
}
function jsonp_v1 (url, data, callbackFunc) {
  let elem = document.createElement('script');
  elem.type = 'text/javascript';
  elem.src = `${url}?${getEncodeParams(data)}&callback=callbackFunc`;
  document.body.appendChild(elem);
  window.callbackFunc = callbackFunc;
}
// 发送请求
jsonp_v1('http://localhost:3000/tool/test', { keyword: 'hello-world' }, function (res) { // 服务器处理请求后会返回"调用这个函数的脚本"
  alert(res)
});

缺点:发送多个请求时,window.callbackFunc会被重写,例如运行以下代码,我们期望alert一次,console一次,结果却是console两次。

jsonp_v1('http://localhost:3000/tool/test', { keyword: 'hello-world' }, function (res) { // 服务器处理请求后会返回"调用这个函数的脚本"
  alert(res)
});
jsonp_v1('http://localhost:3000/tool/test', { keyword: 'hello' }, function (res) { // 服务器处理请求后会返回"调用这个函数的脚本"
  console.log(res)
});

jsonp_v2版本

对v1版本的优化:需要考虑同时发送多个JSONP请求的情况,callbackFunc 挂在 window上的属性名需要唯一。

function jsonp_v2(url, data, callbackFunc) {
  let elem = document.createElement('script');
  let callbackName = `jsonp_${new Date().getTime()}`;
  elem.type = 'text/javascript';
  elem.src = `${url}?${getEncodeParams(data)}&callback=${callbackName}`;
  document.body.appendChild(elem);
  window[callbackName] = callbackFunc;
}

jsonp_v3版本

优化:封装promise获取返回值,加入错误处理机制

function jsonp_v3 (url, data) {
 return new Promise((resolve, reject) => {
   let elem = document.createElement('script');
   let callbackName = `jsonp_${new Date().getTime()}`;
   elem.type = 'text/javascript';
   elem.src = `${url}?${getEncodeParams(data)}&callback=${callbackName}`;
   document.body.appendChild(elem);
   window[callbackName] = resolve;
   elem.onerror = function () { reject('调用接口失败') }
 });
}

jsonp_v4版本

优化:当发送大量请求后,我们会发现页面多了很多全局变量和script标签,这些数据应该在完成请求后删除。

function jsonp_v4 (url, data) {
  return new Promise((resolve, reject) => {
    let elem = document.createElement('script');
    let callbackName = `jsonp_${new Date().getTime()}`;
    elem.type = 'text/javascript';
    elem.src = `${url}?${getEncodeParams(data)}&callback=${callbackName}`;
    document.body.appendChild(elem);
    window[callbackName] = function (res) { // 服务器处理请求后会返回"调用这个函数的脚本"
      resolve(res);
      delete window[callbackName];
      document.body.removeChild(elem);
    }
    elem.onerror = function() {
      reject('调用接口失败');
      delete window[callbackName];
      document.body.removeChild(elem);
   }
  });
}

示例代码

2. 图像ping

图像Ping原理与JSONP差不多,它是与服务器进行简单、单向的跨域通信的一种方式。 请求的数据是通过査询字符串形式发送的,而响应可以是任意内容,但通常是像素图或204响应。通过图像Ping,浏览器得不到任何具体的数据,但通过侦听load和error事件,它能知道响应是什么时候接收到的。

  let img = new Image();
  img.onload = img.onerror = function () {
    console.log('已通知服务端nick浏览了此页面')
  }
  img.src = `http://localhost:3000/tool/test?name=nick`

示例代码

3. CORS跨域资源共享

CORS是一个W3C标准,名为“跨域资源共享”(Cross-origin resource sharing)。它是一种基于HTTP头的机制,该机制通过允许服务器标示除了它自己以外的其它origin(域、协议和端口),这样浏览器就可以访问加载这些资源。跨域资源共享还通过一些机制来检查服务器是否会允许要发送的真实请求,该机制通过浏览器发起一个到服务器托管的跨域资源的“预检”请求(option请求)。在预检中,浏览器发送的头中标示有HTTP方法和真实请求中会用到的头。

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

以下为示例代码(nodejs中间件)

 module.exports = function (WHITE_WEBSITES) {
  return async function (ctx, next) {
    const allowHost = WHITE_WEBSITES; // 白名单
    if (allowHost.includes(ctx.request.header.origin)) {
      ctx.set('Access-Control-Allow-Origin', ctx.request.header.origin);
      ctx.set('Access-Control-Allow-Headers', 'Content-Type, Content-Length, Authorization, Accept, X-Requested-With');
      ctx.set('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
      ctx.set('Access-Control-Allow-Credentials', true);
    }
    if (ctx.method == 'OPTIONS') {
      ctx.response.status = 204;
    } else {
      await next();
    }
  }
}

前端发送请求时,将withCredentials置为true,可以在跨域请求中发送cookie。

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

4. 反向代理

注意,由于接口代理是有代价的,所以这个一般是开发过程中进行的。

前端本地开发的过程中,往往需要使用代理解决跨域问题。

// vue.config.js
  devServer: {
    ...
    proxy: {
      '/test-proxy': {
        target: 'http://192.168.22.173:3000',          
        pathRewrite: {
          '^/test-proxy': ''
        }
      }
    }
  }

简单了解下正向代理和反向代理

image 举个不那么恰当但容易理解的比喻,正向代理相当于帮我们找房的中介,方向代理相当于二房东。

5. nodejs中间件代理跨域

使用如koa2-proxy-middleware之类的中间件

6. iframe

方法一 iframe + postMessage

iframe使得一个页面可以嵌套非同源站点的html文件,他俩可以通过postMessage api通信,利用这两特性,我们可以在页面(假设a.html)内嵌一个iframe(假设指向b.html,b.html与服务器能正常通信),设置其display为none,由不跨域的b.html请求接口,获取接口返回值后通过postMessage返回给a.html

// a.html
<script>
  /**
  * 通过iframe通信
  * @author astar
  * @date 2021-07-03 12:47
  */
  function iframeCommunicate(url, cb) {
    let iframe = document.createElement('iframe');
    let key = `callbackName_${new Date().getTime()}`;
    iframe.style.display = 'none';
    iframe.name = key;
    iframe.src = url + `&key=${key}`; // 增加请求唯一标识
    document.body.appendChild(iframe);
    window[key] = cb;
  }
  function handleIframe (e) {
    let { data, key } = e.data;
    if (window[key]) {
      window[key](e.data);
      let iframes = document.getElementsByName(key);
      document.body.removeChild(iframes[0]);
      delete window[key];
    }
  }
  // 所有接口返回都会调用handleIframe,需要指定唯一的标识(key),辨别返回的信息是哪一次调用iframeCommunicate
  window.addEventListener('message', handleIframe);

  iframeCommunicate(`http://localhost:3000?type=GET&queryUrl=${encodeURIComponent('http://localhost:3000/tool/test?keyword=888&callback=test')}`, function(params) {
    console.log('接口返回', params)
  });
  iframeCommunicate(`http://localhost:3000?type=POST&queryUrl=${encodeURIComponent('http://localhost:3000/tool/test')}&keyword=999&callback=test`, function(params) {
    console.log('接口返回', params)
  });
</script>
// b.html
<script>
  /**
  * 解析url,获取接口url,请求方式和参数
  * @author astar
  * @date 2021-07-03 17:37
  */
  function parseSearch() {
    let search = location.search.slice(1);
    let keyValuePairArr = search.split('&');
    let obj = {};

    keyValuePairArr.forEach(pair => {
      let [key, value] = pair.split('=');
      obj[decodeURIComponent(key)] = decodeURIComponent(value);
    });
    return obj;
  }
  /**
  * 获取接口参数
  * @author astar
  * @date 2021-07-03 17:38
  */
  function getParams(obj) {
    delete obj.type;
    delete obj.queryUrl;
    return obj;
  }
  let obj = parseSearch();
  // 简单使用xhr发送请求
  let xhr = new XMLHttpRequest();
  xhr.onreadystatechange = function () {
    if (xhr.readyState == 4) {
      console.log(xhr.status, xhr.responseText)
      if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
        window.parent.postMessage({ data: xhr.responseText, key: obj.key }, '*');
      } else {
        console.log("Request was unsuccessful:" + xhr.status);
      }
    }
  };
  xhr.open(obj.type, obj.queryUrl);
  xhr.send(JSON.stringify(getParams(obj)));
</script>

以上实现还有优化空间,比如请求接口失败处理等,但这非本文重点,读者有兴趣可自己完善。

示例代码 - a.html

示例代码 - b.html

方法二 iframe + window.name

iframe之间共享window.name变量,我们可以在d.html获取接口返回值后将返回值赋予window.name,在利用window.href打开e.html(e与c在同个域名下), 在e页面可以使用window.parent访问c页面的函数。

// c.html // 在a.html基础上稍作修改
<script>
  /**
  * 通过iframe通信
  * @author astar
  * @date 2021-07-03 12:47
  */
  function iframeCommunicate(url, cb) {
    let iframe = document.createElement('iframe');
    let key = `callbackName_${new Date().getTime()}`;
    iframe.style.display = 'none';
    iframe.name = key;
    iframe.src = url + `&key=${key}`; // 增加请求唯一标识
    document.body.appendChild(iframe);
    window[key] = function (data) {
      cb(data)
      delete window[key];
      document.body.removeChild(iframe);
    };
  }
  iframeCommunicate(`http://localhost:3000?type=GET&queryUrl=${encodeURIComponent('http://localhost:3000/tool/test?keyword=888&callback=test')}`, function (params) {
    console.log('接口1返回', params)
  });
  iframeCommunicate(`http://localhost:3000?type=POST&queryUrl=${encodeURIComponent('http://localhost:3000/tool/test')}&keyword=999&callback=test`, function(params) {
    console.log('接口2返回', params)
  });
</script>
// d.html // 在b.html基础上稍作修改
<script>
  /**
  * 解析url,获取接口url,请求方式和参数
  * @author astar
  * @date 2021-07-03 17:37
  */
  function parseSearch() {
    let search = location.search.slice(1);
    let keyValuePairArr = search.split('&');
    let obj = {};

    keyValuePairArr.forEach(pair => {
      let [key, value] = pair.split('=');
      obj[decodeURIComponent(key)] = decodeURIComponent(value);
    });
    return obj;
  }
  /**
  * 获取接口参数
  * @author astar
  * @date 2021-07-03 17:38
  */
  function getParams(obj) {
    delete obj.type;
    delete obj.queryUrl;
    return obj;
  }
  let obj = parseSearch();
  let xhr = new XMLHttpRequest();
  xhr.onreadystatechange = function () {
    if (xhr.readyState == 4) {
      if ((xhr.status >= 200 && xhr.status < 300) || xhr.status === 304) {
        window.name = JSON.stringify({ data: xhr.responseText, key: obj.key });
        location.href = 'http://localhost:8080/e.html';
      } else {
        console.log("Request was unsuccessful:" + xhr.status);
      }
    }
  };
  xhr.open(obj.type, obj.queryUrl);
  xhr.send(JSON.stringify(getParams(obj)));
</script>
// e.html
<script>
  let { key, data } = JSON.parse(window.name);
  window.parent[key](data);
</script>

示例代码 - c.html

示例代码 - d.html

示例代码 - e.html

其他iframe解决跨域的方案有document.domainlocation.hash等,篇幅限制,不再赘述。

【参考】

JSONP和图像ping跨域过程

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

前端常见跨域解决方案(全)

javascript 跨域方法总结

ajax跨域,这应该是最全的解决方案了

终于有人把正向代理和反向代理解释的明明白白了!

正向代理与反向代理【总结】

iframe解决跨域ajax请求的方法