瀏覽器在請求不同域的資源時,會因為同源策略的影響請求不成功,這就是通常被提到的“跨域問題”。作為前端開發,解決跨域問題應該是一個被熟練掌握的技能。而隨著技術不斷的更迭,針對跨域問題的解決也衍生出了多種解決方案。我們通常會根據項目的不同需要,而采取不同的方式。這篇文章,將詳細總結跨域問題的相關知識點,以便在遇到相同問題的時候,能有一個清晰的解決思路。
跨域問題的產生背景
早期為了防止CSRF(跨域請求偽造)的攻擊,瀏覽器引入了同源策略(SOP)來提高安全性。
CSRF(Cross-site request forgery),跨站請求偽造,也被稱為:one click attack/session riding,縮寫為:CSRF/XSRF。 —— 淺談CSRF攻擊方式
而所謂"同源策略",即同域名(domain或ip)、同端口、同協議的才能互相獲取資源,而不能訪問其他域的資源。在同源策略影響下,一個域名A的網頁可以獲取域名B下的腳本,css,圖片等,但是不能發送Ajax請求,也不能操作Cookie、LocalStorage等數據。同源策略的存在,一方面提高了網站的安全性,但同時在面對前后端分離、模擬測試等場景時,也帶來了一些麻煩,從而不得不尋求一些方法來突破限制,獲取資源。
JS跨域
這里所說的JS跨域,指的是在處理跨域請求的過程中,技術面會偏瀏覽器端較多一些,一般是利用瀏覽器的一些特性進行hack處理,從而避開同源策略的限制。
JSONP
由于同源策略不會阻止動態腳本的插入到文檔中去,所以催生出了一種很常用的跨域方式: JSONP(JSON with Padding)。
原理說起來也很簡單:
假設,我們源頁面是在a.com,想要獲取b.com的數據,我們可以動態插入來源于b.com的腳本:
script = document.createElement('script');
script.type = 'text/javascript';
script.src = 'http://www.b.com/getdata?callback=demo';
這里,我們利用動態腳本的src屬性,變相地發送了一個http://www.b.com/getdata?callback=demo
的GET請求。這時候,b.com頁面接受到這個請求時,如果沒有JSONP,會正常返回json的數據結果,像這樣:
{ msg: 'helloworld' }
而利用JSONP
,服務端會接受這個callback
參數,然后用這個參數值包裝要返回的數據:
demo({msg: 'helloworld'});
這時候,如果a.com的頁面上正好有一個demo的函數:
function demo(data) {
console.log(data.msg);
}
當遠程數據一返回的時候,隨著動態腳本的執行,這個demo函數就會被執行。
到這里,你應該能明白這個技術為什么叫JSONP了吧?就是因為使用這種技術服務器會接受回調函數名作為請求參數,并將JSON數據填充進回調函數中去。
不過一般在實際開發的時候,我們一般會利用jQuery對JSONP的支持,而避免手寫很多代碼。從1.2版本開始,jQuery中加入了對JSONP的支持,可以使用$.getJSON
方法來請求跨域數據:
//callback后面的?會由jQuery自動生成方法名
$.getJSON('http://www.b.com/getdata?callback=?', function(data) {
console.log(data.msg);
});
還有一種更加常用的方法是,利用$.ajax
方法,只要指定dataType
為jsonp
即可:
$.ajax({
url: 'http://www.b.com/getdata?callback=?', //不指定回調名,可省略callback參數,會由jQuery自動生成
dataType: 'jsonp',
jsonpCallback: 'demo', //可省略
success: function(data) {
console.log(data.msg);
}
});
雖然JSONP在跨域ajax請求方面有很強的能力,但是它也有一些缺陷。首先,它沒有關于JSONP調用的錯誤處理,一旦回調函數調用失敗,瀏覽器會以靜默失敗的方式處理。其次,它只支持GET請求,這是由于該技術本身的特性所決定的。因此,對于一些需要對安全性有要求的跨域請求,JSONP的使用需要謹慎一點了。
由于JSONP對于老瀏覽器兼容性方面比較良好,因此,對于那些對IE8以下仍然需要支持的網站來說,仍然被廣泛應用。不過,針對高級瀏覽器,建議還是使用接下來會介紹的CORS方法。
document.domain
目前,很多大型網站都會使用多個子域名,而瀏覽器的同源策略對于它們來說就有點過于嚴格了。如,來自www.a.com想要獲取document.a.com中的數據。只要基礎域名相同,便可以通過修改document.domain為基礎域名的方式來進行通信,但是需要注意的是協議和端口也必須相同。
document.a.com中通過設置
document.domain = 'a.com';
document.domain = 'a.com';
var iframe = document.createElement('iframe');
iframe.src = 'http://document.a.com';
iframe.style.display = 'none';
document.body.appendChild(iframe);
iframe.onload = function() {
var targetDocument = iframe.contentDocument || iframe.contentWindow.document;
//可以操作targetDocument
}
最后,推薦一個使用iframe跨域的庫https://github.com/jpillora/xdomain,感興趣的可以去研究研究。
window.name
window.name這個全局屬性主要是用來獲取和設置窗口名稱的,但是通過結合iframe也可以跨域獲取數據。我們知道,每個iframe都有包裹它的window對象,而這個window是最外層窗口的子對象。所以window.name屬性就可以被共享。
下面這個簡單的例子,展示了a.com域名下獲取b.com域名下的數據:
var iframe = document.createElement('iframe');
var canGetData = false;
//監聽加載事件
iframe.onload = function() {
if (!canGetData) {
//修改成同源
iframe.src = 'http://www.a.com';
canGetData = true;
} else {
var data = iframe.contentWindow.name;
//獲取數據后清除iframe,防止不斷刷新
iframe.contentWindow.document.write('');
iframe.contentWindow.close();
document.body.removeChild(iframe);
}
}
iframe.src = 'http://www.b.com/getdata.html';
iframe.style.display = 'none';
document.body.appendChild(iframe);
b.com/getdata.html中要存放的數據需要存儲在window.name屬性中:
<script>
var data = {msg: 'hello, world'};
window.name = JSON.stringify(data); //name屬性只支持字符串,支持最大2MB的數據
</script>
還有一種iframe結合location.hash的方式,跟該方法十分類似:是通過檢測iframe的src的hash屬性來傳遞數據的。由于該方法相應速度較慢,這里就不做介紹了。
window.name+iframe的方法曾經被作為比JSONP更加安全的替代方案,然而對于托管敏感數據的現代Web應用程序來說,已經不推薦使用window.name來進行跨域消息傳遞了,而是推薦使用接下來介紹的postMessage
API。
window.postMessage
postMessage
是HTML5新增在window對象上的方法,目的是為了解決在父子頁面上通信的問題。該技術有個專有名詞:跨文檔消息(cross-document messaging)。利用postMessage
的特性可以實現較為安全可信的跨域通信。
postMessage
方法接受兩個參數:
- message: 要傳遞的對象,只支持字符串信息,因此如果需要發送對象,可以使用JSON.stringify和JSON.parse做處理
- targetOrigin: 目標域,需要注意的是協議,端口和主機名必須與要發送的消息的窗口一致。如果不想限定域,可以使用通配符“*”,但是從安全上考慮,不推薦這樣做。
下面介紹一個例子:
首先,先創建一個demo的html文件,我們這里采用的是iframe的跨域,當然也可以跨窗口。
<p>
<button id="sendMsg">sendMsg</button>
</p>
<iframe id="receiveMsg" src="http://b.html">
</iframe>
然后,在sendMsg
的按鈕上綁定點擊事件,觸發postMessage
方法來發送信息給iframe:
window.onload = function() {
var receiveMsg = document.getElementById('receiveMsg').contentWindow; //獲取在iframe中顯示的窗口
var sendBtn = document.getElementById('sendMsg');
sendBtn.addEventListener('click', function(e) {
e.preventDefault();
receiveMsg.postMessage('Hello world', 'http://b.html');
});
}
接著,你需要在iframe的綁定的頁面源中監聽message
事件就能正常獲取消息了。其中,MessageEvent
對象有三個重要屬性:data
用于獲取數據,source
用于獲取發送消息的窗口對象,origin
用于獲取發送消息的源。
window.onload = function() {
var messageBox = document.getElementById('messageBox');
window.addEventListener('message', function(e) {
//do something
//考慮安全性,需要判斷一下信息來源
if(e.origin !== 'http://xxxx') return;
messageBox.innerHTML = e.data;
});
}
總得來說,postMessage
的使用十分簡單,在處理一些和多頁面通信、頁面與iframe等消息通信的跨域問題時,有著很好的適用性。
服務器跨域
在實踐過程中,一般我們喜歡讓服務器來多做一些處理,從而盡可能讓前端簡化。這里將介紹兩種常用的方法:反向代理和CORS。
反向代理
所謂反向代理服務器,它是代理服務器中的一種。客戶端直接發送請求給代理服務器,然后代理服務器會根據客戶端的請求,從真實的資源服務器中獲取資源返回給客戶端。所以反向代理就隱藏了真實的服務器。利用這種特性,我們可以通過將其他域名的資源映射成自己的域名來規避開跨域問題。
下面我將以node.js所寫的服務器來做一個演示:
const http = require('http');
const server = http.createServer((req, res) => {
const proxy_req = http.request({
port: 8080,
host: req.headers['host'],
method: req.method,
path: req.url,
headers: req.headers
});
proxy_req.on('response', proxy_res => {
proxy_res.on('data', data => {
res.write(data, 'binary');
});
proxy_res.on('end', () => {
res.end();
});
res.writeHead(proxy_res.statusCode, proxy_res.headers);
});
req.on('end', () => {
proxy_req.end();
});
req.on('data', data => {
proxy_req.write(data, 'binary');
});
});
server.listen(80);
以上代碼會將請求80端口的資源映射到8080端口上去。原理就是在監聽到客戶端請求后,啟動一個代理服務器,然后獲取代理服務器返回的結果,直接返回給客戶端。
如果你使用的是express, 則代碼量將更少,也很方便:
const express = require('express');
const request = require('request');
const app = express();
const proxyServer = 'localhost:8080';
app.use('/', (req, res) => {
const url = proxyServer + req.url;
req.pipe(request(url)).pipe(res);
});
app.listen(process.env.PORT || 80);
利用反向代理,你可以將任何請求委托給另外的服務器,從而避免在瀏覽器端進行跨域操作。不過你需要注意的是:不要使用bodyParser
中間件,因為你需要直接將原始請求通過管道傳輸到外部服務器。
一般來說,如果你的生產環境上應用和API在同一臺服務器上運行,就沒有必要使用跨域了。 而在開發階段采用這種反向代理,則更加方便我們前端開發和測試。
在使用反向代理上,你也可以借助node-http-proxy庫來減少代碼量。
CORS
"跨域資源共享"(Cross-origin resource sharing)是W3C出的一個標準。兼容性方面可以支持IE8+(IE8和IE9需要使用XDomainRequest對象來支持CORS),所以現在CORS也已經成為主流的跨域解決方案。
CORS的核心思想是通過一系列新增的HTTP頭信息來實現服務器和客戶端之間的通信。所以,要支持CORS,服務端都需要做好相應的配置,這樣,在保證安全性的同時也更方便了前端的開發。
瀏覽器會將CORS請求分為兩類:簡單請求和非簡單請求:
簡單請求
在CORS標準中,會根據是否觸發CORS preflight(預請求)來區分簡單請求和非簡單請求。
簡單請求需要滿足以下幾個條件:
1.請求方法只允許:GET,HEAD,POST
2.對于請求頭字段有嚴格的要求,一般情況下不會超過以下幾個字段:
- Accept
- Accept-Language
- Content-Language
- Content-Type
3.當發起POST請求時,只允許Content-Type
為application/x-www-form-urlencoded
,multipart/form-data
,text/plain
。
對于簡單請求來說,服務器和客戶端之間的通信只是進行簡單的交換。如圖:
瀏覽器發送一個帶有Orgin
字段的HTTP請求頭,用來表明請求來源。服務器的Access-Control-Allow-Origin
響應頭表明該服務器允許哪些源的訪問,一旦不匹配,瀏覽器就會拒絕資源的訪問。大部分情況,大家都喜歡將Access-Control-Allow-Origin
設置為*
,即任意外域都能訪問該資源。但是,還是推薦做好訪問控制,以保證安全性。
非簡單請求
對于非簡單請求,情況就稍微復雜了點。在正式發送請求數據之前,瀏覽器會先發送一個帶有'OPTIONS'方法的請求來確保該請求對于目標站點來說是安全的,這個請求也被稱為”預請求“(preflight)。
瀏覽器和服務器之間具體的交互過程如圖所示:
瀏覽器會在預檢請求中,多發送兩個字段Access-Control-Request-Method
和Access-Control-Request-Headers
,前者用于告知服務器實際請求所用的方法,后者用于告知服務器實際請求所攜帶的自定義請求首部字段。然后,服務器將根據請求頭的信息來判斷是否允許該請求。
針對非簡單請求,服務器端可以設置幾個相關字段:
- Access-Control-Allow-Methods, 用來限制允許的方法名,
- Access-Control-Allow-Header,用來限制允許的自定義字段名
- Access-Control-Allow-Credentials,用來表明服務器是否允許credentials標志為true的場景。
- Access-Control-Max-Age,用來表明預檢請求響應的有效時間
- Access-Control-Expose-Headers,用來指定服務器端允許的首部字段集合
另外,如果是在具體的實踐過程中,調試OPTIONS請求可以使用
curl -X OPTIONS http://xxx.com
來進行查看相應頭信息。也可以通過chrome://net-internals/#events
來獲取更加詳細的網絡請求信息。
優化CORS
針對非簡單請求來說,由于每個請求都會發送預請求,這就導致接口數據的返回會有所延遲,時間被加長。所以,在使用CORS的過程中,可以采用一些方案來優化請求,將非簡單請求轉換成簡單請求,從而提高請求的速度。
1.請求緩存
可以在服務器端使用Access-Control-Max-Age
來緩存預請求的結果。從而提高網站性能。但是需要注意的是,大部分瀏覽器不會允許緩存‘OPTIONS‘請求太長時間,如:火狐是24小時(86400s),chromium是10分鐘(600s)。
2.針對GET請求
對于GET請求,沒必要使用Content-Type
, 盡可能地保持GET請求是簡單請求。這樣就可以減少Header上所攜帶的字段。從安全性上考慮,所有的API調用應該盡可能使用https
協議,而這樣可以將一些授權認證信息(如token)直接放在url中去,而不必放在頭部。
3.針對POST請求
對于POST請求,我們可以盡量使用FormData
這種原生的格式:
function sendQuery(url, postData) {
let formData = new FormData();
for(var key in postData) {
formData.append(key, postData.key);
}
return fetch(url, {
body: formData,
headers: {
'Accept': '*/*'
},
method: 'POST'
});
}
sendQuery('http://www.xxx.com', {msg: 'hello'}).then(function(response) {
//do something with response
});
附帶憑證信息的請求
CORS預請求會將用戶的身份認證憑據排除在外,包括cookie、http-authentication報頭等。如果需要支持用戶憑證,需要在XHR的withCredentials屬性設置為true,同時Access-control-allow-origin
不能設置為*
。在服務器端會利用響應報頭Access-Control-Allow-Credentials
來聲明是否支持用戶憑證。
同時,利用withCredentials
這個屬性,也可以檢測瀏覽器是否支持CORS。下面創建一個帶有兼容性處理的cors請求:
function createCORSRequest(method, url){
var xhr = new XMLHttpRequest();
if ("withCredentials" in xhr){
xhr.open(method, url, true);
} else if (typeof XDomainRequest != "undefined"){
xhr = new XDomainRequest();
xhr.open(method, url);
} else {
xhr = null;
}
return xhr;
}
var request = createCORSRequest("POST", "http://www.xxx.com");
if (request){
request.onload = function(){
//do something with request.responseText
};
request.send();
}
如果瀏覽器支持fetch,則使用它做跨域請求更加方便:
fetch('http://www.xxx.com', {
method: 'POST',
mode: 'cors',
credentials: 'include' //接受憑證
}).then(function(response) {
//do something with response
});
總結
以上介紹的這些跨域方法,可能有些已經很少使用了,但是這些方法在解決問題的思路上都有著一定的參考意義。所以當面對不可避免的跨域問題的時候,也希望這篇文章對你能有所幫助。
參考資料
https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Access_control_CORS
http://damon.ghost.io/killing-cors-preflight-requests-on-a-react-spa/
http://blog.teamtreehouse.com/cross-domain-messaging-with-postmessage