在前后端分离的开发模式中,经常会遇到跨域问题,即 Ajax 请求发出去了,服务器也成功响应了,前端就是拿不到这个响应。接下来我们就来好好讨论一下这个问题。

1.为什么引入安全策略

其实主要是用来防止 CSRF 攻击的。简单点说,CSRF 攻击是利用用户的登录态发起恶意请求。
也就是说,没有同源策略的情况下,A 网站可以被任意其他来源的 Ajax 访问到内容。如果你当前 A 网站还存在登录态,那么对方就可以通过 Ajax 获得你的任何信息。当然跨域并不能完全阻止 CSRF。

2.什么是跨域

回顾一下 URI 的组成:
浏览器跨域 - 图1
浏览器遵循同源政策(scheme(协议)host(主机)port(端口)都相同则为同源)。非同源站点有这样一些限制:

  • 不能读取和修改对方的 DOM
  • 不读访问对方的 Cookie、IndexDB 和 LocalStorage
  • 限制 XMLHttpRequest 请求。(后面的话题着重围绕这个)

当浏览器向目标 URI 发 Ajax 请求时,只要当前 URL 和目标 URL 不同源,则产生跨域,被称为跨域请求
跨域请求的响应一般会被浏览器所拦截,注意,是被浏览器拦截,响应其实是成功到达客户端了。那这个拦截是如何发生呢?
首先要知道的是,浏览器是多进程的,以 Chrome 为例,进程组成如下: 浏览器跨域 - 图2浏览器跨域 - 图3
WebKit 渲染引擎V8 引擎都在渲染进程当中。
xhr.send被调用,即 Ajax 请求准备发送的时候,其实还只是在渲染进程的处理。为了防止黑客通过脚本触碰到系统资源,浏览器将每一个渲染进程装进了沙箱,并且为了防止 CPU 芯片一直存在的SpectreMeltdown漏洞,采取了站点隔离的手段,给每一个不同的站点(一级域名不同)分配了沙箱,互不干扰。具体见YouTube上Chromium安全团队的演讲视频
在沙箱当中的渲染进程是没有办法发送网络请求的,那怎么办?只能通过网络进程来发送。那这样就涉及到进程间通信(IPC,Inter Process Communication)了。接下来我们看看 chromium 当中进程间通信是如何完成的,在 chromium 源码中调用顺序如下:
浏览器跨域 - 图4
可能看了你会比较懵,如果想深入了解可以去看看 chromium 最新的源代码,IPC源码地址Chromium IPC源码解析文章
总的来说就是利用Unix Domain Socket套接字,配合事件驱动的高性能网络并发库libevent完成进程的 IPC 过程。
好,现在数据传递给了浏览器主进程,主进程接收到后,才真正地发出相应的网络请求。
在服务端处理完数据后,将响应返回,主进程检查到跨域,且没有cors(后面会详细说)响应头,将响应体全部丢掉,并不会发送给渲染进程。这就达到了拦截数据的目的。
接下来我们来说一说解决跨域问题的几种方案。

3.CORS

CORS 其实是 W3C 的一个标准,全称是跨域资源共享。它需要浏览器和服务器的共同支持,具体来说,非 IE 和 IE10 以上支持CORS,服务器需要附加特定的响应头,后面具体拆解。不过在弄清楚 CORS 的原理之前,我们需要清楚两个概念: 简单请求非简单请求
浏览器根据请求方法和请求头的特定字段,将请求做了一下分类,具体来说规则是这样,凡是满足下面条件的属于简单请求:

  • 请求方法为 GET、POST 或者 HEAD
  • 请求头的取值范围: Accept、Accept-Language、Content-Language、Content-Type(只限于三个值application/x-www-form-urlencodedmultipart/form-datatext/plain)

浏览器画了这样一个圈,在这个圈里面的就是简单请求, 圈外面的就是非简单请求,然后针对这两种不同的请求进行不同的处理。

3.1 简单请求

请求发出去之前,浏览器做了什么?
它会自动在请求头当中,添加一个Origin字段,用来说明请求来自哪个。服务器拿到请求之后,在回应时对应地添加Access-Control-Allow-Origin字段,如果Origin不在这个字段的范围中,那么浏览器就会将响应拦截。
因此,Access-Control-Allow-Origin字段是服务器用来决定浏览器是否拦截这个响应,这是必需的字段。与此同时,其它一些可选的功能性的字段,用来描述如果不会拦截,这些字段将会发挥各自的作用。
Access-Control-Allow-Credentials。这个字段是一个布尔值,表示是否允许发送 Cookie,对于跨域请求,浏览器对这个字段默认值设为 false,而如果需要拿到浏览器的 Cookie,需要添加这个响应头并设为true, 并且在前端也需要设置withCredentials属性:

  1. let xhr = new XMLHttpRequest();
  2. xhr.withCredentials = true;

Access-Control-Expose-Headers。这个字段是给 XMLHttpRequest 对象赋能,让它不仅可以拿到基本的 6 个响应头字段(包括Cache-ControlContent-LanguageContent-TypeExpiresLast-ModifiedPragma), 还能拿到这个字段声明的响应头字段。比如这样设置:

  1. Access-Control-Expose-Headers: aaa

那么在前端可以通过 XMLHttpRequest.getResponseHeader('aaa') 拿到 aaa 这个字段的值。

3.2 非简单请求

非简单请求相对而言会有些不同,体现在两个方面: 预检请求响应字段
我们以 PUT 方法为例。

  1. var url = 'http://xxx.com';
  2. var xhr = new XMLHttpRequest();
  3. xhr.open('PUT', url, true);
  4. xhr.setRequestHeader('X-Custom-Header', 'xxx');
  5. xhr.send();

当这段代码执行后,首先会发送预检请求。这个预检请求的请求行和请求体是下面这个格式:

  1. OPTIONS / HTTP/1.1
  2. Origin: 当前地址
  3. Host: xxx.com
  4. Access-Control-Request-Method: PUT
  5. Access-Control-Request-Headers: X-Custom-Header

预检请求的方法是OPTIONS,同时会加上Origin源地址和Host目标地址,这很简单。同时也会加上两个关键的字段:

  • Access-Control-Request-Method, 列出 CORS 请求用到哪个HTTP方法
  • Access-Control-Request-Headers,指定 CORS 请求将要加上什么请求头

这是预检请求。接下来是响应字段,响应字段也分为两部分,一部分是对于预检请求的响应,一部分是对于 CORS 请求的响应。
预检请求的响应。如下面的格式:

  1. HTTP/1.1 200 OK
  2. Access-Control-Allow-Origin: *
  3. Access-Control-Allow-Methods: GET, POST, PUT
  4. Access-Control-Allow-Headers: X-Custom-Header
  5. Access-Control-Allow-Credentials: true
  6. Access-Control-Max-Age: 1728000
  7. Content-Type: text/html; charset=utf-8
  8. Content-Encoding: gzip
  9. Content-Length: 0

其中有这样几个关键的响应头字段:

  • Access-Control-Allow-Origin: 表示可以允许请求的源,可以填具体的源名,也可以填*表示允许任意源请求。
  • Access-Control-Allow-Methods: 表示允许的请求方法列表。
  • Access-Control-Allow-Credentials: 简单请求中已经介绍。
  • Access-Control-Allow-Headers: 表示允许发送的请求头字段
  • Access-Control-Max-Age: 预检请求的有效期,在此期间,不用发出另外一条预检请求。

在预检请求的响应返回后,如果请求不满足响应头的条件,则触发XMLHttpRequestonerror方法,当然后面真正的CORS请求也不会发出去了。
CORS 请求的响应。绕了这么一大转,到了真正的 CORS 请求就容易多了,现在它和简单请求的情况是一样的。浏览器自动加上Origin字段,服务端响应头返回Access-Control-Allow-Origin。可以参考以上简单请求部分的内容。

4.JSONP

虽然XMLHttpRequest对象遵循同源政策,但是script标签不一样,它可以通过 src 填上目标地址从而发出 GET 请求,实现跨域请求并拿到响应。这也就是 JSONP 的原理,接下来我们就来封装一个 JSONP:

  1. const jsonp = ({ url, params, callbackName }) => {
  2. const generateURL = () => {
  3. let dataStr = '';
  4. for(let key in params) {
  5. dataStr += `${key}=${params[key]}&`;
  6. }
  7. dataStr += `callback=${callbackName}`;
  8. return `${url}?${dataStr}`;
  9. };
  10. return new Promise((resolve, reject) => {
  11. // 初始化回调函数名称
  12. callbackName = callbackName || Math.random().toString.replace(',', '');
  13. // 创建 script 元素并加入到当前文档中
  14. let scriptEle = document.createElement('script');
  15. scriptEle.src = generateURL();
  16. document.body.appendChild(scriptEle);
  17. // 绑定到 window 上,为了后面调用
  18. window[callbackName] = (data) => {
  19. resolve(data);
  20. // script 执行完了,成为无用元素,需要清除
  21. document.body.removeChild(scriptEle);
  22. }
  23. });
  24. }

当然在服务端也会有响应的操作, 以 express 为例:

  1. let express = require('express')
  2. let app = express()
  3. app.get('/', function(req, res) {
  4. let { a, b, callback } = req.query
  5. console.log(a); // 1
  6. console.log(b); // 2
  7. // 注意哦,返回给script标签,浏览器直接把这部分字符串执行
  8. res.end(`${callback}('数据包')`);
  9. })
  10. app.listen(3000)

前端这样简单地调用一下就好了:

  1. jsonp({
  2. url: 'http://localhost:3000',
  3. params: {
  4. a: 1,
  5. b: 2
  6. }
  7. }).then(data => {
  8. // 拿到数据进行处理
  9. console.log(data); // 数据包
  10. })

CORS相比,JSONP 最大的优势在于兼容性好,IE 低版本不能使用 CORS 但可以使用 JSONP,缺点也很明显,请求方法单一,只支持 GET 请求。

5.Nginx

Nginx 是一种高性能的反向代理服务器,可以用来轻松解决跨域问题。
what?反向代理?我给你看一张图你就懂了。
浏览器跨域 - 图5
正向代理帮助客户端访问客户端自己访问不到的服务器,然后将结果返回给客户端。
反向代理拿到客户端的请求,将请求转发给其他的服务器,主要的场景是维持服务器集群的负载均衡,换句话说,反向代理帮其它的服务器拿到请求,然后选择一个合适的服务器,将请求转交给它。
因此,两者的区别就很明显了,正向代理服务器是帮客户端做事情,而反向代理服务器是帮其它的服务器做事情。
好了,那 Nginx 是如何来解决跨域的呢?
比如说现在客户端的域名为client.com,服务器的域名为server.com,客户端向服务器发送 Ajax 请求,当然会跨域了,那这个时候让 Nginx 登场了,通过下面这个配置:

  1. server {
  2. listen 80;
  3. server_name client.com;
  4. location /api {
  5. proxy_pass server.com;
  6. }
  7. }

Nginx 相当于起了一个跳板机,这个跳板机的域名也是client.com,让客户端首先访问 client.com/api,这当然没有跨域,然后 Nginx 服务器作为反向代理,将请求转发给server.com,当响应返回时又将响应给到客户端,这就完成整个跨域请求的过程。
其实还有一些不太常用的方式,大家了解即可,比如postMessage,当然WebSocket也是一种方式,但是已经不属于 HTTP 的范畴,另外一些奇技淫巧就不建议大家去死记硬背了,一方面从来不用,名字都难得记住,另一方面临时背下来,面试官也不会对你印象加分,因为看得出来是背的。当然没有背并不代表减分,把跨域原理和前面三种主要的跨域方式理解清楚,经得起更深一步的推敲,反而会让别人觉得你是一个靠谱的人。