前端基礎 | 淺談前端跨域
@author YogaZheng
鑑於本人目前正處於前端基礎知識學習階段,本文僅是在閱讀了網路上諸多相關文章後的一個總結,加上一點自己寫的小例子,更多的是用於自己的個人積累。看到這篇文章的朋友,我更建議大家去閱讀本文參考資料中的文章(均附有連結),可以對跨域問題有更深刻的理解。
目錄
同源與跨域
同源
如果兩個頁面協議相同、域名相同、埠相同,則稱這兩個頁面同源。相反的,如果兩個頁面的協議、域名、埠中有一個或多個不同,則稱這兩個頁面非同源。
對於網址http://www.cityworks.cn:80而言,http://是協議,www.cityworks.cn是域名,80是埠(預設埠通常省略)。對於這個網址,其同源情況如下:
-
http://www,cityworks,cn/pages/newsInfo,html 同源,協議、域名、埠均相同
-
https://www.cityworks.cn:80 不同源,協議不同
-
http://www.yuque.com 不同源,域名不同
-
http://www.cityworks.cn:2333 不同源,埠不同
同源策略
簡單來說,同源策略是限制了兩個源之間資源互動的一種瀏覽器安全機制。對於非同源網頁/網站,會受到三種行為限制:
-
無法讀取對方的Cookies、LocalStorage、IndexDB
-
無法獲得對方的DOM
-
無法向對方傳送AJAX請求
同源策略的制定是出於網路安全性考慮,防止惡意竊取資料,隔離潛在惡意檔案。設想以下兩種情景:
-
你打開了銀行賬戶頁面,然後又"不小心"打開了一個惡意網站,如果沒有同源策略,此時該惡意網站就可以通過javascript指令碼"隨心所欲地"訪問、竊取、修改你的銀行資訊,包括賬號密碼。
-
當你使用Cookie來維護使用者的登入狀態時(這也是我們現在經常做的),如果沒有同源策略,這些Cookie資訊就會洩露,其他網站就可以冒充這個登入使用者。
跨域
如果兩個頁面非同源,我們試圖進行這兩個頁面通訊的行為稱為跨域。
這裡值得一提的是,跨域並非是瀏覽器限制了發起跨域請求,而是跨站請求可以正常發起,但返回結果被瀏覽器攔截了。也有特例,比如Chrome和Firefox瀏覽器對於從HTTPS協議訪問HTTP協議的跨域請求在其未發出時就攔截。
在實際的專案開發過程中,我們總會不可避免的要進行跨域請求操作。但是,對於協議和埠不同的跨域問題,前端是無法解決的,需要通過後臺實現。 所以通常而言,前端所說的跨域處理指的是對於不同域名通訊的跨域實現,也就是本文討論的主要內容。
Cookie跨子域共享
我們知道,由於同源策略的限制,Cookie只有同源的頁面才能共享。但是,如果兩個頁面主域名相同,子域名不同,瀏覽器允許通過設定docuement.domain共享Cookie和DOM。
舉個栗子,A頁面地址是http://a.example.com/a.html,B頁面地址是http://b.example.com/b.html,那麼只要兩個頁面將各自的document.domain指向同一主域example.com,它們就可以共享Cookie。
在A頁面設定document.domain,並通過指令碼設定一個Cookie:
document.domain = 'example.com';
document.cookie = 'favourite_food=chocolate';
在B頁面設定相同的document.domain,就可以讀取到這個Cookie:
document.domain = 'example.com';
console.log(document.cookie); //包括'favourite_food=chocolate'
另外,伺服器也可以在設定Cookies的時候,指定Cookies的所屬域名為主域名:
Set-Cookie: key=value; domain=.example.com; path=/
如此,二級、三級域名不用做任何設定,就都可以讀取這個Cookie。
iframe跨域視窗通訊
專案中會有使用iframe把其他域名的內容嵌入頁面中的場景(比如登入/註冊等表單提交浮窗),有時候會需要與父視窗進行通訊。
document.domain
如果開啟視窗的主域與父視窗主域相同,子域名不同,那麼同Cookie一樣,可以使用document.domain進行通訊,獲取彼此的DOM。
舉個栗子:在A頁面http://a.example.com/a.html中有一個<iframe>標籤,它的src屬性的值是http://b.example.com/b.html,對應B頁面,它們有共同的主域example.com,那麼只要兩個頁面將document.domain設定為這一主域,就可以相互訪問DOM。
父視窗a.html:
<iframe id="iframe" src="http://b.example.com/b.html"></iframe>
<ul id="arms">
<li>陳情</li>
<li>避塵</li>
</ul>
<script type="text/javascript">
document.domain = 'example.com';
window.onload = function() {
var iframe = document.getElementBtId('iframe');
var childDoc = iframe.contentWindow.document;
console.log(doc.getElementById('cp')); // 魏無羨 藍忘機
}
</script>
子視窗b.html:
<ul id="cp">
<li>魏無羨</li>
<li>藍忘機</li>
</ul>
<script type="text/javascript">
var parentDoc = window.parent.document;
console.log(doc.getElementById('arms')); // 陳情 避塵
</script>
window.name(完整例子見附一)
window.name有一個特徵:在一個視窗(window)的生命週期內,視窗載入的所有頁面都是共享一個window.name的,且每個頁面對window.name都有讀寫的許可權,window.name是持久存在一個視窗載入過的所有頁面中的。也就是說,無論是否同源,只要在同一個視窗中,前一個頁面設定了這個屬性,後一個頁面可以讀取。
基於這個特徵,我們可以實現兩個域名完全不同的頁面之間的通訊,只要它們在同一個視窗內先後開啟。
舉個栗子,我們新建標籤頁,在位址列輸入https://www.baidu.com,在控制檯輸入:
window.name = 'Superman';
然後在位址列輸入http://www.cityworks.cn,在控制檯檢視window.name:
>> window.name
<< "Superman"
由此我們可以實現跨域頁面的通訊。
結合iframe,父視窗可以獲取到子視窗下的window.name。
首先,父視窗http://parent.url.com/a.html載入了不同源的子視窗http://child.path.com/index.html:
<iframe id="iframe" src="http://child.path.com/index.html"></iframe>
接著,使子視窗跳回一個與主視窗同域的網頁http://parent.url.com/b.html,此時該視窗的window.name不變:
document.getElementById('iframe').src = "http://parent.url.com/b.html;
如此,父視窗就可以通過讀取此時子視窗的window.name,獲取非同源頁面的window.name了:
console.log(document.getElementById('iframe').contentWindow.name);
另外,結合iframe或window.open,window.name可以實現父視窗向子視窗傳遞資料。
在iframe中,只需指定標籤的name屬性即可:
<iframe src="http://child.path.com" name="Thor"></iframe>
然後根據上述父視窗獲取子視窗的window.name的方式,我們可以發現,此時子視窗的window.name為"Thor"。
在window.open中,只需指定target即可:
window.open('http://child.path.com', 'Loki');
此時在開啟視窗的控制檯可以看到其window.name:
>> window.name
<< "Loki"
window.name在跨域使用上需要注意:
1.window.name僅支援string型別的資料,其他資料型別都會被強制轉換為string。
>> window.name = 123
<< "123"
>> window.name = ['iron-man','captain-america']
<< "irom-man,captain-america"
>> window.name = {name: 'black-widow'}
<< "[object Object]"
>> window.name = null
<< "null"
>> window.name = undefined
<< "undefined"
2.window.name傳遞資料大小限制一般為2M,不同瀏覽器有一定差異。
location.hash
對於網址http://example.com/index.html#ant-man,我們稱該url的#號後面部分為片段識別符號,片段識別符號不會被髮送到伺服器端,不會引起頁面重新整理。利用片段識別符號,我們可以把傳遞的資料依附在url上,實現非同源父視窗與子視窗之間的通訊。顯然,用片段識別符號傳遞資料的方法既適用於iframe標籤,也適用於window.open開啟視窗傳遞資料。
舉個栗子,父視窗向<iframe>子視窗傳遞資料,將資料寫入子視窗的片段識別符號:
<iframe id="iframe" src="http://child.example.com/b.html#ant-man"></iframe>
子視窗可以通過監聽事件檢測片段識別符號的變化:
window.onhashchange = checkData;
function checkData() {
var data = location.hash;
console.log(data); // "#ant-man"
}
同window.name類似,子視窗要通過location.hash向父視窗傳遞資料,需要在子視窗中再嵌入與父視窗同源的第三個視窗,將資訊設定在第三個視窗的hash值上,然後第三個視窗改變父視窗的hash值,從而實現跨域。通過location.hash實現子視窗向父視窗傳遞資料的方法比較複雜,通常不做考慮。
location.hash在跨域使用上需要注意:
1.同window.name一樣,location.hash僅支援string型別的資料。
2.location.hash欄位是加在URL後的,因此受到URL長度限制,不同瀏覽器限制不同,如IE瀏覽器限制最長URL為2083個字元,Google Chrome限制為8182個字元。
window.postMessage
HTML5為解決跨域通訊的問題,引進了一個全新的API:跨文件通訊API(Cross-document messaging)。這個API為window物件新增了一個window.postMessage方法,允許跨視窗通訊,無論兩個視窗是否同源。
otherWindow.postMessage(message, targetOrigin)
otherWindow:
其他視窗的一個引用,比如iframe的contentWindow屬性、執行window.open返回的視窗物件、或者是命名過或數值索引的window.frames。
message:
將要傳送到其他Window的資料,支援型別String、Object。
targetOrigin:
用於指定哪些視窗能接收到資料,其值可以是字串"*"(表示任意視窗)或一個URI。
舉個栗子,父視窗http://parent.url.com向子視窗http://child.path.com傳送資料,使用postMessage方法:
var child = window.open('http://child.path.com');
child.postMessage('Wonder Woman', '*');
相反的,子視窗向父視窗傳送資料:
window.opener.postMessage('Wolverine', '*');
進一步的,父視窗和子視窗都可以通過message事件,監聽對方的訊息。
window.addEventListener('message', function(event) {
console.log(event);
});
message事件的事件物件event提供三個屬性:
-
event.source:傳送訊息的源視窗
-
event.origin:訊息傳送指向的網址
-
event.data:訊息內容
舉個栗子,子視窗可以通過event.source屬性引用父視窗,從而使用postMessage向父視窗傳送資訊:
window.addEventListener('message', function(e) {
event.source.postMessage('Deadpool')
}
AJAX跨域實現
AJAX由於同源策略的限制,只能向同源的網址傳送請求。為了突破這個限制,除了假設伺服器代理(瀏覽器請求同源伺服器,再由後者請求外部服務)以外,還可以通過JSONP和CORS方法向非同源伺服器傳送請求。
JSONP
我們知道,凡是擁有src屬性的標籤(如<script>、<img>、<iframe>等)都不受同源策略的限制,擁有跨域請求靜態資源的能力。JSONP正是利用了<script>標籤的這個特性實現跨域資源請求。
JSONP(JSON with Padding,填充式JSON)是一種非官方跨域資料互動協議,允許一個域(以下稱客戶端)傳遞一個callback引數給另一個域(以下稱服務端),服務端用這個callback引數作為函式名包裹JSON資料並返回給客戶端,如此,客戶端就可以進一步處理返回的資料。
JSONP具體實現過程:
1.客戶端在頁面新增一個<script>標籤。
2.將請求介面地址作為建立的<script>標籤src屬性的值,其中關鍵是設定回撥函式名作為請求介面的引數,構建一個JSONP請求。
3.服務端接收到請求後,通過引數獲得回撥函式名,在JSON資料外新增函式包裹層,再返回給客戶端。
4.客戶端獲得的返回資料,實際上是處理函式的呼叫,進行資料處理。
舉個栗子,呼叫豆瓣讀書API獲取JSON資料:
客戶端js程式碼:
// 建立script標籤
var script = document.createElement('script');
script.type = 'text/javascript';
// src屬性的值為介面地址,同時傳遞引數指定回撥函式名
script.src = 'https://api.douban.com/v2/book/26763013?callback=onBack';
// 插入script標籤
document.head.appendChild(script);
// 回撥函式
function onBack(data) {
console.log(data);
}
服務端返回資料:
onBack({
"author": ["墨香銅臭"],
"pubdate": "2016-12-8",
"alt": "https://book.douban.com/subject/26763013/",
"id": "26763013",
"publisher": "平心工作室",
"title": "魔道祖師",
"url": "https://api.douban.com/v2/book/26763013",
"summary": "前世的魏無羨萬人唾罵,聲名狼藉。被護持一生的師弟帶人端了老巢,縱橫一世,死無全屍。曾掀起腥風血雨的一代魔道祖師,重生成了一個……腦殘。還特麼是個人人喊打的斷袖腦殘!我見諸君多有病,料諸君見我應如是。但修鬼道不修仙,任你千軍萬馬,十方惡霸,九州奇俠,高嶺之花,但凡化為一抔黃土,統統收歸旗下,為我所用,供我驅策!高貴冷豔悶騷攻×邪魅狂狷風騷受"
});
注意:
-
返回的JSON資料被視為JavaScript物件而非字串,從而避免了使用JSON.parse。
-
<script>不僅可以訪問 JSONP介面,也可以訪問普通介面或 js檔案,它們的返回資料是有區別的,因此如果介面要做 JSONP相容,需要判斷是否對應 callback關鍵字引數。
利用JSONP,我們可以實現AJAX的跨域請求。
原生JavaScript實現:
ajax({
url: 'http://a.example.com/api', // 請求地址
jsonp: 'onBack', // 採用jsonp請求,且回撥函式名為"onBack"
data: {'name': 'joker'}, // 傳輸資料
success: function(res){
console.log(res);
},
error: function(error) {
console.log(error)
}
});
function ajax(params) {
//建立script標籤並加入到頁面中
var head = document.getElementsByTagName('head')[0];
params.data['callback'] = params.jsonp;
var script = document.createElement('script');
head.appendChild(script);
//建立jsonp回撥函式
window[params.jsonp] = function(json) {
head.removeChild(script);
window[params.jsonp] = null;
params.success && params.success(json);
};
//傳送請求
script.src = params.url + '?' + params.data;
};
jQuery對ajax的JSONP跨域實現進行了封裝,只需指定dataType:
$.ajax({
url: 'http://a.example.com/api',
type: 'GET',
dataType: 'jsonp',
success: function(res) {
console.log(res.data);
}
)
基於JSONP的實現原理,我們可以看出JSONP只能發起GET請求,不能進行較為複雜的POST或其他請求。因此對於複雜的請求,現在通常考慮用CORS解決跨域問題。
CORS
CORS(Cross-origin resource sharing,跨域資源共享)是一個W3C標準,允許瀏覽器向跨源伺服器傳送XMLHttpRequest請求,從而克服了AJAX只能同源使用的限制。
CORS的實現原理如下圖所示:
-
客戶端發出CORS請求,瀏覽器首先判斷請求是否同時滿足以下兩大條件,將請求分為簡單請求和非簡單請求:
-
請求方法是以下三種方法之一:
-
HEAD
-
GET
-
POST
-
-
HTTP的頭資訊不超出以下幾種欄位:
-
Accept
-
Accept-Language
-
Content-Language
-
Last-Event-ID
-
Content-Type:只限三個值application/x-www-form-urlencoded、multipart/form-data、text/plain
-
凡是不同時滿足上面兩個條件的,就屬於簡單請求。
-
-
判斷請求型別後,瀏覽器對不同的請求各自進行一定的處理。
-
對於簡單請求,瀏覽器會在頭資訊中增加一個Origin欄位,用於說明本次請求來自於那個源。
-
對於非簡單請求,瀏覽器會發送一個OPTION請求進行預檢,向伺服器詢問是否允許跨源請求,若不允許,則返回不含CORS相關欄位資訊的HTTP響應。
-
-
簡單請求增加了Origin欄位、非簡單請求通過預檢之後,向服務端傳送正式CORS請求。服務端會對Origin欄位資訊進行檢查,判斷Origin指定的源是否在請求許可範圍之內。
-
若Origin指定域名在許可範圍內,伺服器返回正確的資料及頭部資訊,頭部資訊欄位中包含三個與CORS請求相關的欄位:
-
Access-Control-Allow-Origin:必須,Origin欄位的值或"*"
-
Access-Control-Allow-Credentials:可選,表示是否允許傳送Cookie
-
Access-Control-Expose-Headers:可選,指定需要返回的其他欄位
-
-
若Origin指定域名不在許可範圍內,伺服器返回一個正常的HTTP相應,但頭部資訊不包含Access-Control-Allow-Origin等CORS相關欄位。
-
-
瀏覽器通過判斷返回的響應頭部是否包含Access-Control-Allow-Origin欄位判斷CORS請求是否成功。若失敗,則丟擲錯誤。
簡單來說,CORS的跨域原理可以理解為,伺服器通過增加響應頭欄位來"告訴"瀏覽器(注意同源策略是瀏覽器安全機制)這是一個符合標準的ajax跨域請求,使瀏覽器"同意"獲取資料返回給客戶端。
原理講完,舉個栗子。
以本地Nodejs(Express框架)服務為例,在app.js檔案中為響應頭部新增CORS對應欄位:
var express = require('express');
var app = express();
...
app.all('*', function(req, res, next) {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Headers", "X-Requested-With");
res.header("Access-Control-Allow-Methods", "PUT,POST,GET,DELETE,OPTIONS");
res.header("X-Powered-By", ' 3.2.1')
if (req.method == 'OPTIONS') {
res.send(200); // 讓options請求快速返回
}
else {
next();
}
});
...
app.listen(3000);
如此,前端頁面只需直接訪問介面地址即可。
$.ajax({
url: 'http://localhost:3000/getData',
type: 'get',
success: function (data){
console.log(data);
},
error: function (err) {
console.log(err);
}
})
參考資料
附一:document.name在跨域中的應用舉例
父視窗頁面window_name.html:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="IE=edge">
<title>父視窗</title>
</head>
<body>
<h2>這是父視窗</h2>
<!-------------------- Part-1 ------------------->
<!-- Start iframe父視窗獲取非同源子視窗window.name -->
<div>
<h3>此部分演示父視窗獲取非同源子視窗的window.name</h3>
<iframe id="iframe1" src="https://www.yuque.com/"></iframe>
<button onclick="changeIframe1()">點選變換iframe內容</button>
<button onclick="checkIframe1()">點選檢視iframe的window.name</button>
</div>
<!-- End iframe父視窗獲取非同源子視窗window.name -->
<!---------------------- Part 2 ---------------------------->
<!-- Start Part-2 iframe父視窗設定非同源子視窗window.name的值 -->
<div>
<h3>此部分演示父視窗設定非同源子視窗的window.name的值</h3>
<iframe id="iframe2" src="https://www.baidu.com/" name="Thor & Loki"></iframe>
<button onclick="changeIframe2()">點選變換iframe內容</button>
<button onclick="checkIframe2()">點選檢視iframe的window.name</button>
</div>
<!-- End iframe父視窗設定非同源子視窗window.name的值 -->
</body>
<script type="text/javascript">
/***************** Part-1 *****************/
// 改變iframe子視窗內容為同源頁面
function changeIframe1() {
document.getElementById('iframe1').src = './window_name_child.html';
}
// 獲取子視窗window.name
function checkIframe1() {
try {
alert('非同源子視窗window.name設定成功:' + document.getElementById('iframe1').contentWindow.name);
} catch (e) {
alert('出現跨域咯!錯誤資訊:' + e.message);
}
}
/****************** Part-2 ******************/
// 改變iframe子視窗內容為同源頁面
function changeIframe2() {
document.getElementById('iframe2').src = './window_name_child.html';
}
// 獲取子視窗window.name
function checkIframe2() {
try {
alert('非同源子視窗window.name設定成功:' + document.getElementById('iframe2').contentWindow.name);
} catch (e) {
alert('出現跨域咯!錯誤資訊:' + e.message);
}
}
</script>
</html>
同源子視窗頁面window_name_child.html:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="IE=edge">
<title>同源子視窗</title>
</head>
<body>
<h2>這是與父視窗同源的子視窗</h2>
</body>
</html>