1. 程式人生 > >前端基礎 | 淺談前端跨域

前端基礎 | 淺談前端跨域

@author YogaZheng

鑑於本人目前正處於前端基礎知識學習階段,本文僅是在閱讀了網路上諸多相關文章後的一個總結,加上一點自己寫的小例子,更多的是用於自己的個人積累。看到這篇文章的朋友,我更建議大家去閱讀本文參考資料中的文章(均附有連結),可以對跨域問題有更深刻的理解。

目錄

同源與跨域

同源

同源策略

跨域

JSONP

CORS

參考資料

同源與跨域

同源

如果兩個頁面協議相同、域名相同、埠相同,則稱這兩個頁面同源。相反的,如果兩個頁面的協議、域名、埠中有一個或多個不同,則稱這兩個頁面非同源。

對於網址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請求,瀏覽器首先判斷請求是否同時滿足以下兩大條件,將請求分為簡單請求和非簡單請求:

    1. 請求方法是以下三種方法之一:

      • HEAD

      • GET

      • POST

    2. 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>