那些年,那些跨域問題

瀏覽器在請求不同域的資源時,會因為同源策略的影響請求不成功,這就是通常被提到的“跨域問題”。作為前端開發,解決跨域問題應該是一個被熟練掌握的技能。而隨著技術不斷的更迭,針對跨域問題的解決也衍生出了多種解決方案。我們通常會根據項目的不同需要,而采取不同的方式。這篇文章,將詳細總結跨域問題的相關知識點,以便在遇到相同問題的時候,能有一個清晰的解決思路。

跨域問題的產生背景

早期為了防止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方法,只要指定dataTypejsonp即可:

$.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';

www.b.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方法接受兩個參數:

  1. message: 要傳遞的對象,只支持字符串信息,因此如果需要發送對象,可以使用JSON.stringify和JSON.parse做處理
  2. 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-Typeapplication/x-www-form-urlencoded,multipart/form-data,text/plain

對于簡單請求來說,服務器和客戶端之間的通信只是進行簡單的交換。如圖:

簡單請求(來源:MDN)

瀏覽器發送一個帶有Orgin字段的HTTP請求頭,用來表明請求來源。服務器的Access-Control-Allow-Origin響應頭表明該服務器允許哪些源的訪問,一旦不匹配,瀏覽器就會拒絕資源的訪問。大部分情況,大家都喜歡將Access-Control-Allow-Origin設置為*,即任意外域都能訪問該資源。但是,還是推薦做好訪問控制,以保證安全性。

非簡單請求

對于非簡單請求,情況就稍微復雜了點。在正式發送請求數據之前,瀏覽器會先發送一個帶有'OPTIONS'方法的請求來確保該請求對于目標站點來說是安全的,這個請求也被稱為”預請求“(preflight)。

瀏覽器和服務器之間具體的交互過程如圖所示:

非簡單請求(來源:MDN)

瀏覽器會在預檢請求中,多發送兩個字段Access-Control-Request-MethodAccess-Control-Request-Headers,前者用于告知服務器實際請求所用的方法,后者用于告知服務器實際請求所攜帶的自定義請求首部字段。然后,服務器將根據請求頭的信息來判斷是否允許該請求。

針對非簡單請求,服務器端可以設置幾個相關字段:

  1. Access-Control-Allow-Methods, 用來限制允許的方法名,
  2. Access-Control-Allow-Header,用來限制允許的自定義字段名
  3. Access-Control-Allow-Credentials,用來表明服務器是否允許credentials標志為true的場景。
  4. Access-Control-Max-Age,用來表明預檢請求響應的有效時間
  5. 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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,546評論 6 533
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,570評論 3 418
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,505評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,017評論 1 313
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,786評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,219評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,287評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,438評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,971評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,796評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,995評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,540評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,230評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,662評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,918評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,697評論 3 392
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,991評論 2 374

推薦閱讀更多精彩內容

  • 跨域資源共享 CORS 對于web開發來講,由于瀏覽器的同源策略,我們需要經常使用一些hack的方法去跨域獲取資源...
    默默先生Alec閱讀 596評論 0 0
  • 1. 什么是跨域? 跨域一詞從字面意思看,就是跨域名嘛,但實際上跨域的范圍絕對不止那么狹隘。具體概念如下:只要協議...
    他在發呆閱讀 828評論 0 0
  • 什么是跨域? 2.) 資源嵌入:、、、等dom標簽,還有樣式中background:url()、@font-fac...
    電影里的夢i閱讀 2,382評論 0 5
  • 翻落于地的燈籠轉眼便燒得只剩個竹殼,晚風帶起一片灰燼飄飛院中,忽隱忽現。雜亂腳步聲響起,院中各角落突然涌出無數黑影...
    十一鸞閱讀 247評論 0 1
  • 本文主要是對iOS 11下APP中tableView內容下移20pt或下移64pt的問題適配的一個總結。內容包括五...
    貝勒老爺閱讀 1,023評論 0 5