跨域问题及其解决方案详解


#1

在进行前后端分离的开发中,跨域是一个不得不解决的问题。以下基于 Vue-Resource、PHP 及 Nginx 介绍跨域问题及其解决方案。

跨域问题

配置

Nginx 中的配置只是简单的指向 PHP 代码的所在目录:

server{

    listen         80;
    server_name localhost;
    root         /mnt/apps;
    index         index.php index.html index.htm;

    location / {
        index       index.php index.html;
    }
    
    location ~ \.php$ {
        fastcgi_pass     localhost:9000;
        fastcgi_index     index.php;
        fastcgi_param     SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include         fastcgi_params;
    }
}

PHP 只是输出一个 JSON 数据,代码如下:

// api.php

<?php 

header('Content-type : application/json');

$response = [
    'key' => 'value'
];

echo json_encode($response);

Vue-Resource 为调用该接口,试图获取其中的数据:

this.$http.get('http://localhost:8088/api.php').then(res => {
    console.log(res);
})

测试

首先,我们在 Postman 中进行测试:

可以看到,这一接口是能够返回预期值的。

但当我们刷新 Vue 页面时,控制台中却没有输出想要的值,而是抛出了错误:

XMLHttpRequest cannot load http://localhost:8088/api.php. No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'http://localhost:8080' is therefore not allowed access.

跨域问题

跨域问题基于浏览器的同源策略,简而言之,就是脚本不能调用来自不同域名、不同协议、不同端口的资源。如上,来自 localhost:8080 的 JavaScript 代码试图获取localhost:8088 的 PHP 返回值(视为资源),便违背了同源策略,从而引发跨域问题。

有关同源策略的更多信息,可以参考知乎的这篇讨论:对于浏览器的同源策略你是怎样理解的呢?

同源策略带来两个问题:

  1. 无法获取非本域的资源(无法调取 API 接口);
  2. 无法传递 Cookie;

以下便对此进行讨论。

PS:有同学可能会有疑问,为什么在 Postman 中不会有跨域问题呢?注意,跨域问题针对的是脚本对资源的访问限制,而 Postman 本身基于客户端代码,是 C/S 架构,自然不会有此问题。这就像是使用 curl 调用接口也不会受同源策略影响一样。

Jsonp

方案

当我们谈及获取非本域资源时,可以发现并不是所有类型的资源都受同源策略限制的,比如图片和 JavaScript、CSS 等。

这也使得我们可以转换思路,采用一种取巧的方式获得那些被同源策略拒绝的资源。比如,服务端动态地把数据放在 JavaScript 中,在前端请求时,将动态生成的 JavaScript 文件返回,文件中的内容包含相应的数据。

可是,单纯的在 JavaScript 文件中包含数据是不能被前端获取的。因为通过 JavaScript 代码是不能读取文件中的内容的。所以,我们的思路还需要转换一下。考虑以下场景:

<!DOCTYPE html>
<html>
<head>
    <title></title>
</head>
<body>
    
    <script>
        function handle(data){
            console.log(data);
        }
    </script>
    <script src="http://localhost:8088/remote.js"></script>
</body>
</html>

引用的远程 JavaScript 文件为:

<!-- http://localhost:8088/remote.js -->
<script>
    var json = {
        key: 'value'
    }
    handle(json);
</script>

这样一来,通过这种方式,我们便在本地获取到了远程的数据。期间存在跨域,但没有违背同源策略。

也就是说,只要后台能够根据前台的请求,动态的生成一个调用特殊函数的 JavaScript 文件就可以了。具体流程如下:

在这其中,有几个问题需要解决:

  1. JavaScript 代码如何才能向后台请求 JavaScript 文件?
  2. 前后端如何商议 handle 函数的名字?

先来看第一个问题。事实上,在 JavaScript 代码中是不能直接向后台索要 JavaScript 文件的。除非使用 DOM 操作创建一个 script 标签,再将请求地址通过 src 填充到该标签之中。可即使是这样,让后台动态生成 JavaScript 文件的方案还是不合适,这无疑增加了后台的负担。

不生成文件,如何实现函数调用和调用时传参呢?

在 JavaScript 中,我们可以使用 eval() 使得字符串具有特殊意义。如 eval("handle('data')") 可以使得中间的字符串变为 handle 函数的执行。这样一来,后台便不必再生成 JavaScript 文件,而只用发送字符串,再由前台通过 eval 处理即可。

第二个问题相对容易解决,我们都知道,在进行 API 请求时,无论是 GET 还是 POST,都可以携带参数。也就是说,我们只需要把想要后台使用的函数名通过参数传递即可,如 http://localhost:8088/api.php?callback=handle。后台接收到请求后,取得 callback 参数即可获得所需的函数名。

这样一来,整个流程就变为了:

实现

采用原生方法实现时,我们需要准备一个接收函数(如 handle),以及在收到数据后使用 eval 将其包裹。这一过程实际上引入了很多与业务无关的代码。

借助 Vue-Resource 或 jQuery 等库,我们可以轻松地实现 jsonp:

将原先的 JavaScript 代码改变为:

this.$http.jsonp('http://localhost:8088/api.php').then(res => {
    console.log(res);
})

并将 PHP 代码修改为:

<?php 

header('Content-type : application/json');

$data = [
    'key' => 'value'
];

$callback = $_GET['callback'];

$json = json_encode($data);

echo $callback.'('.$json.')';

测试

此时,我们可以在看到如下结果:

如此,我们便得到了想要的数据。

注意这里的请求 URL,Vue-Resource 自动帮我们加上了 callback 参数,即接收函数的名字。

如果我们想要自行指定接收参数的名字,或者在请求时添加额外的参数,可以使用如下方式:

this.$http.jsonp('http://localhost:8088/api.php', {
    params: {
        param1: 1
    },
    jsonp: 'callback'
}).then(res => {
    console.log(res);
})

在浏览器的控制台中,我们可以找到此次请求的网络传输过程:

可以看到,这里实际是发起了一次 GET 请求。

此外,由上图可知,使用 jsonp 的方式,Cookie 值也是可以成功传递的。

但是,这种做法其实是存在一些问题。因为需要适配 jsonp 的需求,返回值实际上变成了接收函数与实际数据的字符串拼接:

这确实是解决了跨域的需求,但对于不跨域的请求,就需要另行处理了。

另外,由于本身不能指定请求类型,采用 jsonp 难以进行 RESTful 风格的 API 请求(除非使用请求头方法覆盖),因而对于愈发流行的 API 请求范式,这一方式也显得有些过时。

服务端代码中增加响应头

方案

同源策略的目的是为了安全性,那么有没有一种方法,使得客户端和服务器之间彼此信任,从而同意对方跨域访问呢?

以下讨论通过添加响应头的方式解决跨域问题。

实现

跨域解决

首先我们将 PHP 和 JavaScript 代码还原:

header('Content-type', 'application/json');

$data = [
    'key' => 'value'
];

echo json_encode($data);
this.$http.get('http://localhost:8088/api.php').then(res => {
    console.log(res);
})

这时浏览器又会提示出现跨域问题。

接着我们为 PHP 代码增加一条语句:

header('Access-Control-Allow-Origin : *');

此时便可以得到期望的返回值了。可以注意到,我们此时并没有修改前台代码。

PUT、DELETE 等复杂请求问题

下面,我们将代码稍作修改,将前端请求方式改为 PUT:

this.$http.put('http://localhost:8088/api.php').then(res => {
    console.log(res);
})

这时,浏览器又抛出了跨域错误。为了解决这一问题,我们还需要给 PHP 代码加入一个响应头:

header('Access-Control-Allow-Methods : PUT');

当加入这一响应头后,浏览器依然会抛出跨域问题,只不过这一问题现在变成了:

XMLHttpRequest cannot load http://localhost:8088/api.php. Request header field Content-Type is not allowed by Access-Control-Allow-Headers in preflight response.

提示我们,Content-type 这一请求头不被允许。

针对这一问题,我们按照提示,在 PHP 代码再增加一个响应头:

header('Access-Control-Allow-Headers : Content-type');

此时,跨域问题便解决了。注意,这里不能使用 * 号,所需增加的内容需要根据实际情况。

为什么采用 GET 请求的时候,不需要这一条代码呢?这是由于 HEAD、GET、POST 类型的请求为简单请求,只需增加 Access-Control-Allow-Origin: * 即可。但除此之外的请求方式均为复杂请求。在复杂请求发起时,浏览器会首先发送 OPTIONS 类型的请求,询问浏览器是否同意跨域,以及允许跨域的条件(OPTIONS 无需写入 Access-Control-Allow-Methods 中),这一步被称为预请求(preflight request)。

就上面的情况而言,因为服务端没有设置是否允许复杂请求及具有特殊 Content-type 头的请求进行跨域,所以请求被拦截了下来。

通过浏览器的控制台我们可以更清晰地看到这些过程:

首先发送了一次 OPTIONS 请求:

然后才是真正的 PUT 请求:

同理,当使用 DELETE 等其他复杂请求时,只需修改响应头即可。

关于这一点,这篇博客有着非常详细的介绍:CORS 跨域 access-control-allow-headers 的问题 - CSDN

emulateHTTP 与 emulateJSON

在 Vue-Resource 中,提供了这两种参数。

其中,前者可以将 PUT、DELETE 和 PATCH 请求转换为 POST,并通过 X-HTTP-Method-Override 请求头标识真实的请求类型。这种做法可以兼容一些旧版本的协议。

而后者可以将请求的 body 使用 application/x-www-form-urlencoded 编码。有关 x-www-form-urlencoded 可以参考 form-data、x-www-form-urlencoded、raw、binary的区别

Cookie 问题

通过浏览器的控制台,或是将后台代码改为:

header('Access-Control-Allow-Origin : *');
header('Access-Control-Allow-Methods : PUT');
header('Access-Control-Allow-Headers : Content-type');

header('Content-type', 'application/json');

$data = [
    'key' => 'value'
];

$data = $_COOKIE;

echo json_encode($data);

我们可以检测到,这次的请求并没有携带 Cookie 进行发送。而没有 Cookie 会使得大量需求无法实现。那么该如何解决这一问题呢?

此时,我们需要对前端和后台代码同时进行修改:

修改 JavaScript 代码为:

this.$http.put('http://localhost:8088/api.php',{}, {
    credentials: true,
}).then(res => {
    console.log(res);
})

注意,在 credentials: true 的前面还有一个 {},这是因为对于 PUT、POST、DELETE 等可以携带 body 体参数的请求而言,其第二个参数为 body 参数项,其他配置需要放在第三个参数中。

对于 GET 请求,我们则需要把它放到第二个参数中:

this.$http.get('http://localhost:8088/api.php',{
    credentials: true,
}).then(res => {
    console.log(res);
})

在 PHP 代码中,我们需要增加一个响应头:

header('Access-Control-Allow-Credentials: true');

此时,刷新浏览器可以发现又抛出了一个错误:

XMLHttpRequest cannot load http://localhost:8088/api.php. Response to preflight request doesn't pass access control check: The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'. Origin 'http://localhost:8080' is therefore not allowed access. The credentials mode of requests initiated by the XMLHttpRequest is controlled by the withCredentials attribute.

这提示我们,当使用 credentials 时,Access-Control-Allow-Origin 的响应头不能设置为 *。此时,我们需要把原本的后台设置改为:

$origin = $_SERVER['HTTP_ORIGIN'];

header('Access-Control-Allow-Origin : '.$origin);

即前端域名为什么,后台便允许什么跨域。

这样一来,我们便又可以正常的得到 Cookie 值了:

通过这种方式依然需要我们增加很多与业务无关的代码。当然了,我们可以通过在后台框架中增加中间件的方式为响应结果统一添加响应头。虽然这种方式确实很方便,但当后台返回的状态码不是 2xx,即后台报错或进行重定向时,浏览器收到的结果依然会变成跨域错误。这种情况使得我们无法在测试时准确的知道哪里出现了问题。

为了更进一步的改进跨域方案,我们试着将增加响应头的工作交给服务器来做,如 Apache 或 Nginx。

Nginx 配置

在修改 Nginx 配置前,我们先将 PHP 代码还原:

header('Content-type', 'application/json');

$data = $_COOKIE;

echo json_encode($data);

然后,我们修改 Nginx 配置为:

server{

    listen         80;
    server_name localhost;
    root         /mnt/apps;
    index         index.php index.html index.htm;

    location / {
        index       index.php index.html;
    }
    
        location ~ \.php$ {
        add_header 'Access-Control-Allow-Origin' "$http_origin";
        add_header 'Access-Control-Allow-Credentials' "true";
        add_header 'Access-Control-Allow-Methods' "PUT, DELETE";
        add_header 'Access-Control-Allow-Headers' "Content-type";
        fastcgi_pass     localhost:9000;
        fastcgi_index     index.php;
        fastcgi_param     SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include         fastcgi_params;
    }

}

注意,这里主要的代码在于通过 Nginx 为响应附加响应头:

add_header 'Access-Control-Allow-Origin' "$http_origin";
add_header 'Access-Control-Allow-Credentials' "true";
add_header 'Access-Control-Allow-Methods' "PUT, DELETE";
add_header 'Access-Control-Allow-Headers' "Content-type";

而添加的响应头类型均与之前在 PHP 代码中的改动一致。

如此,也可以解决跨域问题。

静态资源文件跨域问题

在进行前端开发时,很可能会使用字体图标,当我们试图获取非本域的如 iconfont.eot 等资源时,很可能也会在控制台中看到跨域拒绝的报错信息。

诸如此类的问题都可以使用添加响应头来实现。只不过对于这种静态资源文件,由于它们都是 GET 请求,所以我们只需要在服务器中添加 Access-Control-Allow-Origin: *。如在 Nginx 中:

location ~* \.(eot|otf|ttf|woff|svg)$ {
    add_header  'Access-Control-Allow-Origin' "*";
}

这样,在前端请求这些后缀名的资源文件时,便不会出现报错信息了。


参考

  1. JSON数据的HTTP Header应该怎么标记? - segmentfault
  2. Vue2.0 vue-source.js jsonp demo vue跨域请求 - 博客园
  3. 说说JSON和JSONP,也许你会豁然开朗 - 博客园
  4. CORS 跨域 access-control-allow-headers 的问题 - CSDN
  5. 跨域(CORS) 解决方案中,为什么 Access-Control-Allow-Methods 不起作用? - segmentfault
  6. 使用withCredentials发送跨域请求凭据 - iteye
  7. PHP Ajax 跨域问题最佳解决方案 - 博客园
  8. Nginx CORS实现JS跨域 - CSDN

文章作者 dailybird
转载自 https://segmentfault.com/a/1190000010111731