在進行先後端分離的開發中,跨域是一個不得不解決的問題。如下基於 Vue-Resource、PHP 及 Nginx 介紹跨域問題及其解決方案。php
Nginx 中的配置只是簡單的指向 PHP 代碼的所在目錄:html
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 爲調用該接口,試圖獲取其中的數據:vue
this.$http.get('http://localhost:8088/api.php').then(res => { console.log(res); })
首先,咱們在 Postman 中進行測試:laravel
能夠看到,這一接口是可以返回預期值的。json
但當咱們刷新 Vue 頁面時,控制檯中卻沒有輸出想要的值,而是拋出了錯誤:segmentfault
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 返回值(視爲資源),便違背了同源策略,從而引起跨域問題。後端
有關同源策略的更多信息,能夠參考知乎的這篇討論:對於瀏覽器的同源策略你是怎樣理解的呢?。api
同源策略帶來兩個問題:跨域
如下提供三種方案以供參考。
PS:有同窗可能會有疑問,爲何在 Postman 中不會有跨域問題呢?注意,跨域問題針對的是腳本對資源的訪問限制,而 Postman 自己基於客戶端代碼,是 C/S 架構,天然不會有此問題。這就像是使用 curl
調用接口也不會受同源策略影響同樣。
當咱們談及獲取非本域資源時,能夠發現並非全部類型的資源都受同源策略限制的,好比圖片和 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 文件就能夠了。具體流程以下:
在這其中,有幾個問題須要解決:
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:
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。
在 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的區別。
經過瀏覽器的控制檯,或是將後臺代碼改成:
header('Access-Control-Allow-Origin : *'); header('Access-Control-Allow-Methods : PUT'); header('Access-Control-Allow-Headers : Content-type'); header('Content-typ : 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 配置前,咱們先將 PHP 代碼還原:
header('Content-type : application/json'); $data = $_COOKIE; echo json_encode($data);
注意,JavaScript 代碼與以前相同。如需考慮 Cookie 問題,仍是須要在異步請求中中加入 credentials: true
的。
而後,咱們修改 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' "*"; }
這樣,在前端請求這些後綴名的資源文件時,便不會出現報錯信息了。
最近在使用 Laravel 時,發現了一個詭異的現象:當在 Laravel 中使用中間件進行跨域時(代碼以下):
class CORSProtection { public function handle($request, Closure $next) { $response = $next($request); if(isset($_SERVER['HTTP_ORIGIN'])){ $origin = $_SERVER['HTTP_ORIGIN']; $response->header('Access-Control-Allow-Origin', $origin); } $response->header('Access-Control-Allow-Headers', 'Content-Type, X-Requested-With'); $response->header('Access-Control-Allow-Credentials', 'true'); $response->header('Access-Control-Allow-Methods', 'POST, PUT, DELETE, OPTIONS'); return $response; } }
來自前端的跨域請求:GET 和不帶參數的 POST 均可以正常發送。而一旦 POST 中攜帶了參數,瀏覽器就會輸出跨域錯誤。
經過查看瀏覽器的網絡,發如今 POST 請求前確實發送了一次 OPTIONS 請求,但該請求的響應並無按咱們以前所說的攜帶上容許跨域的信息頭。
經過查閱資料發現,Laravel 會對 OPTIONS 請求自動返回 200 狀態碼而無視中間件或其餘形式的響應頭附加。詳見參考:關於 Laravel 下 Cors 跨域 POST 請求的一種實現方法。
此時,咱們須要在路由中強制加入對 OPTIONS 類請求的響應,以使得 OPTIONS 探測請求可以正確的響應咱們想要的跨域容許信息:
Route::options('{any}', function ($any) { return response('ok'); })->middleware('cors');
因爲 Laravel 中彷佛沒有缺省路由,這裏須要根據請求 URL 的層級添加不一樣的路由。
可是,爲何 GET(不管帶參與否)以及不帶參的 POST 請求都沒有出現這一問題呢?還記得前面提到的複雜請求和簡單請求嗎?GET 和 POST 雖然都是簡單請求,但當 POST 攜帶參數時,因爲大多數前端 HTTP 請求框架的默認 POST 帶參請求頭 Content-Type 都是 application/json
,不屬於簡單請求的類型,於是觸發了 OPTIONS 探測。而 Laravel 會默認對 OPTIONS 請求返回 200 狀態,而不是攜帶咱們定義好的那些響應頭,因而就出現了上述詭異的狀況。
這裏要說明的是,使用 Nginx 方案是不會遇到這一問題的。此外,在請求中添加請求頭,強制 Content-Type 爲 x-www-form-urlencoded 或其餘簡單請求類型時(如 Vue-Resource 中使用 emulateJSON: true),也能夠跨過這一問題。