非面试向跨域实践详解

1,793 阅读11分钟

前言

笔者经常在前端开源群答疑,加上之前的招聘面试经历。发现许多新手前端在问起跨域问题的解决方案,一套一套的,可是实际遇到跨域问题了就不知道怎么解决了。这次写这篇文章从实践角度聊一聊跨域问题。

跨域基本概念

出于浏览器的同源策略限制,浏览器会拒绝跨域请求。 这就是跨域问题的产生原因,同源策略是用于隔离潜在恶意文件的重要安全机制。

这句话的三个关键字:

  • 同源
  • 限制
  • 浏览器拒绝

什么是同源

那么第一个问题来了,什么算是同源。解决这个问题需要先了解一下URL的完整结构:

URL结构
两个URL的才算同源。反而言之,三者任何一个不相同都算跨域。 例如某个页面地址为 www.domain.com/page1.html, 该页面访问以下API接口跨域关系表:

API 接口地址 是否跨域 原因
www.domain.com/api/users/1 协议、主机、端口全部都相同
www.domain.com:80/api/users/1 端口不同
www.baidu.com/api/users/1 协议不同
api.domain.com/v1/users/1 主机不同

有哪些限制

  1. XmlHttpRequest(即ajax请求)和Fetch两种接口发出的HTTP请求进行限制。
  2. 对于嵌入资源标签scriptimglinkvideo等标签加载资源的请求(HTTP GET请求)不做限制。

具体的限制规则还有很多,这里只说常见和本文用得上的。

浏览器拒绝

那么那些环境算是浏览器?

  • PC端常见的 Chrome/Safari/Edge
  • 移动端的Chrome/Safari/各个App内嵌Webview 浏览器又是怎么拒绝的 先来看一张图,一个用户点击了一个按钮,发出了一个AJAX GET请求。那么常见的流程如图:

那么如果用户发出的AJAX GET请求是一个跨域请求,那么会在上图中哪一个阶段被阻止? 但是第3阶段,也就是说用户发送的信息可以到达服务端,服务器是能够接受处理并返回了。返回的浏览器发现这是一个跨域请求。就直接拒绝,同时把返回的信息替换为报错信息,返回给JavaScript程序。 对于更复杂的POST/PUT等请求, MDN CORS文档里面有更详细的处理方法。这里就不细说。

这一点很重要,但是总是被新人忽略。所以重要的事情说三遍,

  • 拒绝跨域请求是浏览器
  • 拒绝跨域请求是浏览器
  • 拒绝跨域请求是浏览器

反过来说,Nginx、Java/Nodejs等编程语言的HTTPClient以及手机App,他们发出的HTTP请求就完全没有跨域问题,因为他们不是浏览器,没有实现W3C规范。

跨域解决方案

JSONP

在浏览器中假设有以下一段代码会执行结果会是什么样?

<script>
window.callback = function (data) {
    console.log(data);
    delete window.callback;
}
</script>
<script>
callback({
    "code": 1,
    "data": [1,2,3]
});    
</script>

毫无疑问,肯定是在控制台输出了一个对象信息。 记得刚才在介绍跨域基本概念的时候说个浏览器不限制script标签加载js文件。那么把这二者的特性相结合。第二个script标签改为从网络加载. 就可以实现跨域. 例如 一个跨域APIhttp://api.domain.com/v1/users/1

  1. 在window对象上挂载一个函数callbackFun
  2. 创建一个script标签: <script src="http://api.domain.com/v1/users/1?callback=callbackFun"></script>
  3. script就会向服务器发出 GET http://api.domain.com/v1/users/1?callback=callbackFun的请求
  4. 让后端返回如下内容 ContentType为application/javascript
callbackFun({/*需要的数据*/});
  1. 数据返回成功以后处理数据,删除script标签

以上步骤就是JSONP的思想。实现一个完善的JSNOP请求库还有细节要处理,比如超时取消、回调函数防重名等。很多开源库(jQuery, axios)都实现了JSNOP请求。想要代码的去Github阅读源码,这里就不给出代码。

优劣势

JSONP虽然是一种实现跨域访问的方法,前端想要使用JSONP进行跨域访问却不容易。

  1. 只支持GET方法
  2. 要后端的配合 GET http://api.domain.com/v1/users/1 返回
ContentType:	application/json
{
    "code": 1,
    "data" : {"userid": 1}
}

GET http://api.domain.com/v1/users/1?callback=callbackFun 返回

ContentType:	application/javascript
callbackFun({
    "code": 1,
    "data" : {"userid": 1}
});

既然可以和后端商量配合你改造接口,那还有更好的方案可以解决。何必用这种方案。

JSONP有一个有点就是兼容性好,IE678通通兼容,所以一般JSNOP是后端同学如果主动需要开放API给他人使用,同时有需要极高的兼容的一个妥协方案。一般情况下不推荐这个方案。

JSONP 开心一刻

真实经历。之前开发项目需要调用另一个项目组的接口。 跨域造成接口掉不通,然后找Z君沟通, Z君说:"你用JSONP来掉接口就好了。这都不知道...." 然后我还在想大神这么NB的么,JSONP兼容都提前做完了。我试了JSONP。坑爹呀,你后端根本就没兼容JSONP,我怎么调用,呵呵... 呵呵呵呵....

请求代理

JSONP方案不推荐,那么又需要访问跨域接口,怎么办呢? 重要事情不在乎再多说一遍拒绝跨域请求是浏览器。 那么如果有一个非W3C标准的HttpClient帮助我们转发请求,不就可以了实现跨域访问了。

App端

通常App对于webview都有很强的控制权,可以在Webview的JS环境中注入一些方法。 那么移动端程序员可以在Webview中注入一个接口,运行在里面的js代码可以通过这个方法把自己的请求地址、请求参数、请求体等数据交给App Native端,让App Native代为收发请求。App Native不是浏览器,不受跨域限制。

具体实现方法可以搜 Hybird App开发或者请教移动端开发的同学。

Web端

Web端必然运行在浏览器环境中,那么没有App Native。还有服务器上可以做反向代理。 所谓的反向代理,原理和App Native请求代理的原理差不多,就是我们请求非跨域下的反向代理服务,反向代理服务会把你的请求转发给目标服务器。 反向代理服务可以是Nginx也可以是java/Nodejs程序等等。这些程序也不受跨域限制,可以接受目标服务器的请求,并返回给我们。

React/Vue 开发阶段跨域处理

React/Vue 这种SPA开发施行的完全的前后端分离的模式,开发阶段必然是需要跨域访问接口的。 Vue开发可以这样配置:

// vue.config.js
module.exports = {
  devServer: {
    proxy: {
      '/api': {
        target: '<url>',
        ws: true,
        changeOrigin: true
      },
      '/foo': {
        target: '<other_url>'
      }
    }
  }
}

详情见Vue CLI文档。React也有类似的配置,详情见 create-react-app文档

那么他们具体是怎么实现的呢? 本地起一个服务端程序提供反向代理的能力。而React/Vue本地启动的这个服务端程序就是Webpack-dev-server。来探索Webpack-dev-server源码,源码中启动server的关键代码在lib/Server.js中,挑重点

/* 此处省略许多行代码 */

// 27行 引入express 作为服务端框架
const express = require('express');

/* 此处省略许多行代码 */


// 31行 引入 http-proxy-middleware 提供反向代理的能力
const httpProxyMiddleware = require('http-proxy-middleware');

/* 此处省略许多行代码 */

// 328行  获取 proxyMiddleware 并加载到为express的中间件Middleware
app.use((req, res, next) = > {
    if (typeof proxyConfigOrCallback === 'function') {
        const newProxyConfig = proxyConfigOrCallback();

        if (newProxyConfig !== proxyConfig) {
            proxyConfig = newProxyConfig;
            // 334行 根据 proxyConfig 获取 处理proxy请求的中间件proxyMiddleware
            proxyMiddleware = getProxyMiddleware(proxyConfig);
        }
    }

    const bypass = typeof proxyConfig.bypass === 'function';

    const bypassUrl = (bypass && proxyConfig.bypass(req, res, proxyConfig)) || false;

    if (bypassUrl) {
        req.url = bypassUrl;

        next();
    } else if (proxyMiddleware) {
        // 347行 最最关键一行 经过多次判定某个请求是需要代理转发的请求,那么把它交给proxyMiddleware进行处理, proxyMiddleware
        return proxyMiddleware(req, res, next);
    } else {
        next();
    }
});

以上代码有点NodeJS服务端开发的同学基本能看明白,看不明白也没关系。你知道React/Vue可以通过相应的配置项获得接口跨域访问的能力就可以了。其中最核心的就是依靠Express的网络请求能力充当反向代理服务器。

React/Vue 线上部署阶段跨域处理

开发阶段还可以通过本地启动一个Express服务器作为代理,帮助我们处理跨域问题,问题是生产环境是不推荐这么做的。React/Vue 项目通常在build以后会生成以下文件:

  • xxx.html 文件1份
  • xxx.xxxxxx.js Javacript文件若干
  • xx.xxxx.css 文件若干
  • xxx.map 文件若干,当然也可能没有 而且里面的js/css/图片等文件通常部署在cdn上,最为要紧的页面入口index.html则需要小心部署,否则易遇到2个问题
  1. 页面没办法访问
  2. 接口跨域导致没办法访问

1

对于index.html的部署,Vue-Router文档写的很清楚。推荐通过nginx try-file命令来进行部署。同时nginx又是一个反向代理服务器。假设 网页需要在host http://www.domain.com/下, 真实API服务部署在http://api.domain.com/api。那么通过反向代理把接口代理到 http://www.domain.com/api下。那么跨域访问就变成了同域名访问。 那么nginx的配置文件可以这样写

server { 
    listen       80;
    server_name  www.domain.com ;
    root www; # 存放html文件的文件夹
    location ^/api { # 接口代理到 8080
      proxy_set_header Host $host;
      proxy_set_header X-Real-IP $remote_addr;
      proxy_pass       http://api.domain.com/api;
    }
    location / { # 其他请求返回index.html
        try_files $uri $uri/ /index.html;
    }
  }

这样做完, 访问API就会被代理转发,访问其他路径就返回html。如下图所示:

线上部署的方式可能根据系统架构选型而多种多样。这只是其中一种比较通用且为官方推荐的方式。仅做参考。类似ngixn的服务端软件还是Caddy、Envoy

这种方案的优点是不需要后端同学改动接口,只需要运维小哥帮助配置一下nginx即可完成兼容。缺点是多一次转发可能带来性能损失。

CORS

实际情况多种多样,有些时候没办法使用JSONP,也通过nginx转发又会产生性能损失。那么还有一个终极大招———— CORS.

W3C的同源策略出来以后造成了很多不便,无法应对某些跨域访问的强需求。为此W3C增加了CORS相关的规范, 文档之前也提及过:MDN CORS文档

重要的事情再重复一遍:拒绝跨域请求是浏览器,那么CORS的原理就是CORS相关的规范中制定了一些响应头(Response Header),这些响应头以Access-Control-Request-开头。简单枚举几个,具体这些头的含义和用法见MDN CORS文档.

Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-PINGOTHER, Content-Type
Access-Control-Allow-Origin: http://foo.example
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: X-PINGOTHER, Content-Type
Access-Control-Max-Age: 86400

浏览器在接收到设置过CORS响应头的返回以后,会根据CORS规范检查合法性,检查通过则不再阻止,放行通过。

简而言之就是CORS响应头就是用来告诉浏览器:"我是虽然是跨域请求,但是我是合法的,请不要拒绝我"。

CORS方案的优点是支持各种方法 GET、POST、PUT、DELTE等等。而且改动量比较小。可以在服务端程序比如Java或者NodeJS上做,也可通过前置代理服务器nginx完成。

缺点就是

  1. 浏览器兼容性差
  2. 降低了安全性,毕竟W3C之所以禁止跨域,是为了安全。现在推出CORS方案虽然已经在安全和灵活方面做到一个较好平衡。但是如果CORS响应头设置不当,还是可能会产生安全问题。

其他

其他还有用与父页面与子页面(iframe)之间的通信的跨域问题,window.name、postMessage等方法。这里就不详细说了。日常用的确实不多,有需要再查把。

要点总结

  • 跨域的基本概念
    • 跨域是W3C组织为了保证安全指定的规范
    • 协议、主机、端口全部都相同才是同源,否则就是跨域
    • 限制XHR与Fetch,不限制资源类标签
    • 拒绝跨域请求是浏览器 拒绝跨域请求是浏览器 拒绝跨域请求是浏览器 重要事情说三遍
  • 常见跨域解决方案
    • JSONP 只能发出GET请求。一般不推荐,除非需要很强的接口兼容性
    • 访问代理
      • APP端可以通过Native端发请求
      • Web端可以通过架设反向代理服务器
        • React/Vue日常开发就是通过Express服务器做的反向代理
        • 生产环节部署可以使用nginx
    • CORS是W3C准许跨域规范,需要后端配合改程序
    • 其他略过

生产环境中建议选择顺序是 反向代理 > CORS >> JSONP。 因为反向代理兼容性最好,程序改动少。 CORS适用于无法容忍反向代理的性能损失和第三方OpenAPI访问。 JSONP 只有当后端需要兼容性高,没办法部署反向代理服务器的情况以及前端访问第三方提供的JSONP接口。其他任何情况下不推荐。