同源策略/SOP(Same origin policy)是一種約定,它是瀏覽器最核心也最基本的安全功能,如果缺少了同源策略,瀏覽器很容易受到XSS、CSFR等攻擊(可以參考我的這篇文章)。
SOP要求兩個通訊地址的協議、域名、端口號必須相同,否則兩個地址的通訊將被瀏覽器視為不安全的,並被block下來。比如“http頁面”和“https頁面”屬於不同協議;“qq.com”、“www.qq.com”、“a.qq.com”都屬於不同域名(或主機);“a.com”和“a.com:8000”屬於不同端口號。這三種情況常規都是無法直接進行通訊的。
我們很容易模擬不同源的環境,用iframe來幫忙即可:
<!DOCTYPE html> <html> <head lang="en"> <meta charset="UTF-8"> <title>模擬跨域</title> </head> <body> <iframe src="http://baidu.com"></iframe> <script> window.frames[0].onload = function () { alert("1"); } </script> </body> </html>
上述代碼在chrome中會輸出blocking信息:
即我們無法監聽百度首頁文檔onload的事件,因為top窗口跟iframe窗體是不同源的。
現代瀏覽器的確在安全性上下了不少功夫,除了上述提到的默認禁止非同源頁面通訊,還新增了CSP(Content Security Policy)報頭特性等安全限制功能。不過既然為了用戶安全而關閉了一扇窗戶,自然也會為開發者開啟一扇便利的窗戶,要突破SOP的限制,咱還是有不少辦法和花樣的。
目錄
HTML5解決方案
1. Cross-document messaging
2. WebSocket
iframe形式
1. document.domain
2. location.hash
3. window.name
其它形式
1. 服務器代理
2. flash socket
CORS
同域安全策略CORS(Cross-Origin Resource Sharing)是W3C在05年提出的跨域資源請求機制,它要求當前域(常規為存放資源的服務器)在響應報頭添加Access-Control-Allow-Origin標簽,從而允許指定域的站點訪問當前域上的資源。我們使用node/iojs來模擬一下(不懂node/iojs?不急,先看下我的入門文章):
服務器端:
require("http").createServer(function(req,res){ //報頭添加Access-Control-Allow-Origin標簽,值為特定的URL或“*” //“*”表示允許所有域訪問當前域 res.setHeader("Access-Control-Allow-Origin","*"); res.end("OK"); }).listen(1234);
客戶端:
<!DOCTYPE html> <html> <head lang="en"> <meta charset="UTF-8"> <title>CORS</title> <script src="jq.js"></script> </head> <body> <div>catching data...</div> <script> $.ajax({ url:"http://127.0.0.1:1234/", success:function(data){ $("div").text(data) } }) </script> </body> </html>
運行客戶端頁面后,便能看到div內容成功變為服務端發來的“OK”,實現了兩個不同域的頁面間的通訊。通過上述代碼我們也發現,CORS主要是在服務端上的實現(也不外乎是添加一個報頭標簽),客戶端的實現跟常規的請求沒啥出入。
不過CORS默認只支持GET/POST這兩種http請求類型,如果要開啟PUT/DELETE之類的方式,需要在服務端在添加一個"Access-Control-Allow-Methods"報頭標簽:
服務端:
require("http").createServer(function(req,res){ res.setHeader("Access-Control-Allow-Origin","http://127.0.0.1"); res.setHeader( "Access-Control-Allow-Methods", "PUT, GET, POST, DELETE, HEAD, PATCH" ); res.end(req.method+" "+req.url); }).listen(1234);
XDR
惱人的IE8-是不支持上述的CORS滴,不過不走尋常路的巨硬在IE8開始引入了XDR(XDomainRequest)新特性(IE11已經不再支持該特性),它實現了CORS的部分規范,只支持GET/POST形式的請求。另外在協議部分只支持 http 和 https 。
在服務器端,依舊要求在響應報頭添加"Access-Control-Allow-Methods"標簽(這點跟CORS一致)。
在客戶端,DR對象的使用方法與XHR對象非常相似,也是創建一個XDomainRequest的實例,調用open()方法,再調用send()方法。但與XHR對象的open()方法不同,XDR對象的open()方法只接收兩個參數:請求的類型和URL,因為所有XDR請求都是異步執行的,不能用它來創建同步請求。
請求返回之后,會觸發load事件,相應的數據也會保存在responseText屬性中,如下所示:
var xdr = new XDomainRequest(); xdr.onload = function() { alert(xdr.responseText); }; xdr.onerror = function() { alert("一個錯誤發生了!"); }; xdr.open("get", "http://127.0.0.1:1234/"); xdr.send(null);
由於XDR實在太過時,這里不做太多介紹,了解下即可,更多細節請查閱msdn。
HTML5解決方案
1. Cross-document messaging
在 Cross-document messaging 中,我們可以使用 postMessage 方法和 onmessage 事件來實現不同域之間的通信,其中postMessage用於實時向接收信息的頁面發送消息,其語法為:
otherWindow.postMessage(message, targetOrigin);
otherWindow: 對接收信息頁面的window的引用。可以是頁面中iframe的contentWindow屬性;window.open的返回值;通過name或下標從window.frames取到的值。
message: 所要發送的數據,string類型。
targetOrigin: 允許通信的域的url,“*”表示不作限制。
我們可以在父頁面中嵌入不同域的子頁面(iframe實現,而且常規會把它隱藏掉),在子頁面調用 postMessage 方法向父頁面發送數據:
父頁面(http://localhost:10847/sop/a.html):
<!DOCTYPE html> <html> <head lang="en"> <meta charset="UTF-8"> <title>postMessage</title> </head> <body> <iframe style="display:none;" id="ifr" src="http://127.0.0.1:10847/sop/b.html"></iframe> <script type="text/javascript"> window.addEventListener('message', function(event){ // 通過origin屬性判斷消息來源地址 if (event.origin == 'http://127.0.0.1:10847') { alert(event.data); // 彈出從子頁面post過來的信息 } }, false); </script> </body> </html>
子頁面(http://127.0.0.1:10847/sop/b.html):
<!DOCTYPE html> <html> <head lang="en"> <meta charset="UTF-8"> <title>子頁面</title> </head> <body> <script type="text/javascript"> var ifr = window.parent; //獲取父窗體 var targetOrigin = 'http://localhost:10847'; // 若寫成 http://127.0.0.1:10847 則將無法執行postMessage ifr.postMessage('這是傳遞給a.html的信息', targetOrigin); </script> </body> </html>
執行如下:
關於 Cross-document messaging 的更多細節可參考這篇文檔。
2. WebSocket
WebSocket protocol 是HTML5一種新的協議。它實現了瀏覽器與服務器全雙工通信,同時允許跨域通訊,是server push技術的一種很棒的實現。
我們先簡單看下webSocket在客戶端上的api:
var ws = new WebSocket('ws://127.0.0.1:8080/url'); //新建一個WebSocket對象,注意服務器端的協議必須為“ws://”或“wss://”,其中ws開頭是普通的websocket連接,wss是安全的websocket連接,類似於https。 ws.onopen = function() { // 連接被打開時調用 }; ws.onerror = function(e) { // 在出現錯誤時調用,例如在連接斷掉時 }; ws.onclose = function() { // 在連接被關閉時調用 }; ws.onmessage = function(msg) { // 在服務器端向客戶端發送消息時調用 // msg.data包含了消息 }; // 這里是如何給服務器端發送一些數據 ws.send('some data'); // 關閉套接口 ws.close();
服務端這塊我們繼續用node/iojs來編寫,並使用socket.io模塊輔助,socket.io很好地封裝了webSocket接口,提供了更簡單、靈活的接口,也對不支持webSocket的瀏覽器提供了向下兼容(例如替換為Flash Socket/Comet)。
我們先寫服務端,首先我們得在項目根目錄下使用npm命令安裝好socket.io模塊:
npm install socket.io
接着新建服務端腳本(訪問地址是http://127.0.0.1:1234/):
var io = require('socket.io'); var server = require("http").createServer(function(req,res){ res.writeHead(200, { 'Content-type': 'text/html'}); }).listen(1234); io.listen(server).on('connection', function (client) { client.on('message', function (msg) { //監聽到信息處理 console.log('Message Received: ', msg); client.send('服務器收到了信息:'+ msg); }); client.on("disconnect", function() { //斷開處理 console.log("Server has disconnected"); }) });
客戶端頁面(http://localhost:10847/sop/a.html,注意使用了socket.io之后,接口跟原生的不太一樣了):
<!DOCTYPE html> <html> <head lang="en"> <meta charset="UTF-8"> <title>socket.io</title> <script src="jq.js"></script> <script src="https://cdn.socket.io/socket.io-1.3.4.js"></script> </head> <body> Incoming Chat: <ul></ul> <br/> <input type="text" /> <script> $(function () { var iosocket = io.connect('http://127.0.0.1:1234/'), $ul = $("ul"), $input = $("input"); iosocket.on('connect', function() { //接通處理 $ul.append($('<li>連上啦</li>')); iosocket.on('message', function(message) { //收到信息處理 $ul.append($('<li></li>').text(message)); }); iosocket.on('disconnect', function() { //斷開處理 $ul.append('<li>Disconnected</li>'); }); }); $input.keypress(function (event) { if (event.which == 13) { //回車 event.preventDefault(); iosocket.send($input.val()); $input.val(''); } }); }); </script> </body> </html>
客戶端頁面執行效果如下:
WebSocket可以很好地擺脫無狀態的http連接,從而很好地處理連接斷開、數據錯誤的情況,不過缺點是兼容性還不夠好,但咱可使用上述的socket.io來向下兼容。
JSONP
這個實在用到爛大街了,提起跨域實現,其實最容易想到的就是它。JSONP(JSON with Padding)是JSON的一種“使用模式”,主要是利用script標簽不受同源策略限制的特性,向跨域的服務器請求並返回一段JSON數據。
常規前后端會約定好某個JSONP請求的callback名(比如隨便起個名字“abc”),服務端返回的JSON數據會被這個callback名包裹起來,進而方便服務器區分收到的請求,也方便客戶端區分其收到的響應數據。我們可以利用jQuery輕松實現JSONP:
客戶端(訪問地址http://localhost:10847/sop/a.html):
<!DOCTYPE html> <html> <head lang="en"> <meta charset="UTF-8"> <title>JSONP</title> <script src="jq.js"></script> </head> <body> <div></div> <script> $.ajax({ url:'http://127.0.0.1:1234/', dataType:"jsonp", //告知jQ我們走的JSONP形式 jsonpCallback:"abc", //callback名 success:function(data){ console.log(data) } }); </script> </body> </html>
服務端(訪問地址http://127.0.0.1:1234/ ):
var http = require('http'); var urllib = require('url'); var data = {'name': 'vajoy', 'addr': 'shenzhen'}; http.createServer(function(req, res){ res.writeHead(200, { 'Content-type': 'text/plain'}); var params = urllib.parse(req.url, true); //console.log(params); if (params.query && params.query.callback) { //console.log(params.query.callback); var str = params.query.callback + '(' + JSON.stringify(data) + ')';//jsonp res.end(str); } else { res.end(JSON.stringify(data));//普通的json } }).listen(1234)
客戶端執行結果:
不過JSONP始終是無狀態連接,不能獲悉連接狀態和錯誤事件,而且只能走GET的形式。
iframe形式
在很久以前的石器時代,對於不支持 XMLHttpRequest 的瀏覽器的最佳回溯方法之一就是使用IFRAME對象,當然常規只是用它來實現流模式的Comet,而不是解決跨域通信的問題。
使用iframe跨域其實有點劍走偏鋒的既視感,也存在一些限制性。下面均來介紹下。
1. document.domain
該方法只適合主域相同但子域不同的情況,比如 a.com 和 www.a.com,我們只需要給這兩個頁面都加上一句 document.domain = 'a.com' ,就可以在其中一個頁面嵌套另一個頁面,然后進行窗體間的交互。
為了方便模擬環境,我們修改下hosts文件:
127.0.0.1 a.com
127.0.0.1 www.a.com
這樣我們訪問 a.com 的時候便能映射到本地了。
頁面a.html(訪問地址http://a.com:8080/sop/a.html):
<!DOCTYPE html> <html> <head lang="en"> <meta charset="UTF-8"> <title>iframe</title> <script src="jq.js"></script> </head> <body> <iframe src="http://www.a.com:8080/sop/b.html"></iframe> <script> document.domain = 'a.com'; $("iframe").load(function(){ $(this).contents().find("div").text("OK") }) </script> </body> </html>
頁面b.html(訪問地址http://www.a.com:8080/sop/b.html):
<!DOCTYPE html> <html> <head lang="en"> <meta charset="UTF-8"> <title>b.html</title> <script src="jq.js"></script> </head> <body> <div></div> <script> document.domain = 'a.com'; </script> </body> </html>
這時候我們訪問a.html會發現b.html里的內容被成功修改:
2. location.hash
location.hash/url hash 是個好東西,在之前我們曾利用avalon前端路由來實現簡單的SPA頁面(這篇文章),便是助力於location.hash。
利用url地址改變但不刷新頁面的特性(在url: http://a.com#hello 中的 '#hello' 就是location.hash,改變hash並不會導致頁面刷新,所以可以利用hash值來進行數據傳遞)和iframe,我們可以實現跨域傳遞簡單信息。
不過這個實現略麻煩,常規我們會想,在a.html下嵌套一個不同域的b.html,然后 a 和 b 互相修改彼此的hash值,也不斷監聽自己的hash值,從而實現我們的需求。可惜的是,大部分瀏覽器不允許修改不同域的父窗體的hash值(parent.location.hash),也就是說a雖能修改b的hash值,但反過來由b修改a的hash值卻不成立。
為了解除該限制,我們可以在b頁面中增加一個和a同域的iframe(c.html)來做代理,這樣b可以修改c,而c可以修改a(即修改parent.parent.location.hash,別忘了a和c同域哦)。下面直接模擬這三個頁面,做到讓b向a傳輸信息(當然本質上是b向c,c再向a傳輸):
a.html(訪問地址http://a.com:8080/sop/a.html):
<!DOCTYPE html> <html> <head lang="en"> <meta charset="UTF-8"> <title>iframe</title> <script src="jq.js"></script> </head> <body> <div></div> <iframe src="http://www.a.com:8080/sop/b.html" style="display: none;"></iframe> <script> var hash = ""; function checkHash() { var data = location.hash ? location.hash.substring(1) : hash; if (hash !== data) { $("div").text('hash變化為:' + data); hash = data; } } setInterval(checkHash, 2000); </script> </body> </html>
b.html(訪問地址http://www.a.com:8080/sop/b.html):
<!DOCTYPE html> <html> <head lang="en"> <meta charset="UTF-8"> <title>b.html</title> </head> <body> <script> try { //有的瀏覽器(Firefox)還是可以直接操作parent.location.hash的 parent.location.hash = 'a=1&b=2'; } catch (e) { // ie、chrome的安全機制無法修改parent.location.hash // 所以要利用一個代理iframe var ifrproxy = document.createElement('iframe'); ifrproxy.style.display = 'none'; ifrproxy.src = 'http://a.com:8080/sop/c.html#a=1&b=2'; //必須跟a.html同域 document.body.appendChild(ifrproxy); } </script> </body> </html>
c.html(訪問地址http://a.com:8080/sop/c.html):
<!DOCTYPE html> <html> <head lang="en"> <meta charset="UTF-8"> <title>c.html</title> <script src="jq.js"></script> </head> <body> <script> //因為parent.parent和自身屬於同一個域,所以可以改變其location.hash的值 parent.parent.location.hash = self.location.hash.substring(1); </script> </body> </html>
訪問a.html后,效果如下:
成功傳遞了數據“a=1&b=2”。該方法優點是兼容較好,缺點卻顯而易見——可傳遞的數據類型、長度均受限,數據還是直接顯示在url上的,不夠安全。另外其實現也較麻煩,還要搞setInterval不斷監聽,跟輪詢沒區別了。
3. window.name
window.name 的美妙之處在於,窗體的name值在頁面跳轉后依舊存在、保持原值(即使跳轉的頁面不同域),並且可以支持非常長的 name 值(2MB)。
如果我們在a頁面需要和不同域的b頁面通信,我們可以現在a頁面嵌入b頁面,待b頁面有數據要傳遞時,把數據附加到b頁面窗口的window.name上,然后把窗口跳轉到一個和a頁面同域的c頁面,這樣a就能輕松獲取到內嵌窗體(地址已由跨域的b變為同域的c)的window.name了(如果需要,獲取到數據后再把c跳轉到b,並重復循環前面的步驟,同時a頁面以setInterval的形式來達到輪詢的效果)。我們繼續模擬這三個頁面:
a.html(訪問地址http://a.com:8080/sop/a.html):
<!DOCTYPE html> <html> <head lang="en"> <meta charset="UTF-8"> <title>window.name</title> <script src="jq.js"></script> </head> <body> <div></div> <iframe src="http://www.a.com:8080/sop/b.html" style="display: none;"></iframe> <script> varifr = window.frames[0], loc = "", data = ""; function checkData(){ loc = ifr.location; if(loc.host){ //獲取到了,說明iframe已轉到同域的c頁面 if(ifr.name !== data){ //說明有新數據 data = ifr.name; $("div").text(JSON.parse(data).name); ifr.location = "http://www.a.com:8080/sop/b.html"; //數據收到后重回b頁面接收新數據 } }else return; } setInterval(checkData,2000); //每2秒輪詢一次 </script> </body> </html>
b.html(訪問地址http://www.a.com:8080/sop/b.html):
<!DOCTYPE html> <html> <head lang="en"> <meta charset="UTF-8"> <title>b.html</title> </head> <body> <script> window.name = '{"name":"vajoy","addr":"shenzhen"}'; location = "http://a.com:8080/sop/c.html"; //跳轉到和a同域的c頁面 </script> </body> </html>
c.html頁面啥都不用寫,純粹一個空的html即可,畢竟只是一個代理頁面罷了。
我們訪問a頁面,會成功收到來自不同域b的數據:
其它形式
1. 服務器代理
頁面直接向同域的服務端發請求,服務端進行跨域處理或爬蟲后,再把數據返回給客戶端頁面。依舊用node/iojs來模擬服務端,下面的代碼來自木的樹的文章:
客戶端:

<!doctype html> <html> <head> <meta charset="utf-8"> <meta name="viewport" content="initial-scale=1, maximum-scale=1,user-scalable=no"> <title>proxy_test</title> <script> var f = function(data){ alert(data.name); } var xhr = new XMLHttpRequest(); xhr.onload = function(){ alert(xhr.responseText); }; xhr.open('POST', 'http://localhost:8888/proxy?http://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer', true); xhr.send("f=json"); </script> </head> <body> </body> </html>
服務端:

var proxyUrl = ""; if (req.url.indexOf('?') > -1) { proxyUrl = req.url.substr(req.url.indexOf('?') + 1); console.log(proxyUrl); } if (req.method === 'GET') { request.get(proxyUrl).pipe(res); } else if (req.method === 'POST') { var post = ''; //定義了一個post變量,用於暫存請求體的信息 req.on('data', function(chunk){ //通過req的data事件監聽函數,每當接受到請求體的數據,就累加到post變量中 post += chunk; }); req.on('end', function(){ //在end事件觸發后,通過querystring.parse將post解析為真正的POST請求格式,然后向客戶端返回。 post = qs.parse(post); request({ method: 'POST', url: proxyUrl, form: post }).pipe(res); }); }
2. flash socket
其實在前面介紹socket.io的時候就有提到,在不兼容WebSocket的瀏覽器下,socket.io會以flash socket或Comet的形式來兼容,而flash socket是支持跨域通信的形式,跟WebSocket一樣走的TCP/IP套接字協議。具體的實現可參考Adobe官方文檔,本文不贅述。
至此便介紹了這么幾種常用的跨域通信的實現方式,希望對你能有所幫助。
大過年的還這么辛苦寫文章我也是瘋了,anyway,祝各位新春快樂,新的一年揚眉吐氣、萬事順利!
共勉~