為什麼需要跨域
瀏覽器出於安全的考慮,引入了同源策略。這種策略會對我們頁面上執行的js訪問資源的時候進行限制,比如我們不能直接通過js訪問不同源之下的頁面DOM結構,同時在對不同源傳送請求時也無法獲取到伺服器響應內容(伺服器會正常處理請求並返回響應內容,但是返回的內容被瀏覽器攔截掉了)。這裡還牽扯到“源”這個概念,如果我們訪問的目標url和當前頁面所在的url兩者的協議、域名、埠只要有一個不相同,那麼就認為是屬於兩個不同的源。明白了源的定義之後,我們再來看看在同源策略的作用下,我們可以在頁面上做的以及不能做的都有哪些操作。
先說能夠做的,比如通過js重定向我們的頁面(修改location.href),表單提交,這些都是可以的。還有就是通過嵌入一些HTML標籤來載入我們需要的資源,比如script標籤引入一段指令碼、img標籤插入一張圖片、link標籤載入樣式檔案、iframe嵌入不同源的頁面等等也都是可以的。這些也是我們日常開發過程中再正常不過的操作了。
但是,同源策略對js訪問一些敏感資源則進行了限制。除了開頭提到的那兩點之外,還有就是js中無法訪問不屬於同個源的cookie、LocalStorage中儲存的內容。具體來說,cookie和LocalStorage在控制哪些源可以訪問的問題上還是細微的差別,父域在設定cookie的時候可以設定允許子域訪問這段cookie,同時Cookie只和域名以及路徑關聯,如果是同個域名不同埠的源依然是共享同個域名下的Cookie的,而LocalStorage則是以源為單位進行管理,相互獨立,不同源之間無法相互訪問LocalStorage中的內容。
常用的跨域方法
客戶端與服務端通訊
客戶端與不同源的伺服器的通訊問題是平常開發過程中需要解決的很常見的問題,主要有以下幾種方式:
CORS
這種方法應該是用得比較多的一種。CORS全稱是Cross-Origin Resource Sharing,翻譯過來就是跨域資源共享。基本思想就是引入一些自定義的HTTP Header來完成客戶端與服務端的通訊。
對於一些簡單請求,瀏覽器在傳送請求時會帶上Origin請求頭,指示當前的源,伺服器端在處理請求時不會去檢查當前請求來源是否合法,依然會正常處理請求並響應,最終瀏覽器在拿到響應之後會檢查服務端響應的Access-Control-Allow-Origin列表中是否存在當前頁面所在的源,如果不存在會直接block掉當前請求。
在瀏覽器看來,同時滿足以下條件的請求都認為是簡單請求:
請求方法為GET或者POST;
只包含Accept、Accept-Language、Content-Language或者Content-Type(取值為application/x-www-form-urlencoded
,multipart/form-data
, 或者text/plain
),其餘情況的Header則屬於非簡單Header;
對於非簡單請求,瀏覽器會先向伺服器傳送一個Preflight請求,該請求使用Option方法,幷包含以下Header:
- Origin
- Access-Control-Request-Method:詢問伺服器是否支援某方法;
- Access-Control-Request-Headers:詢問伺服器是否支援請求中包含的非簡單Header;
其中後兩個Header只會出現在Preflight請求中。然後瀏覽器收到包含以下Header的伺服器響應:
- Access-Control-Allow-Origin
- Access-Control-Allow-Methods:對客戶端迴應伺服器支援的請求方法列表;
- Access-Control-Allow-Headers:對客戶端迴應伺服器支援的Header;
Preflight請求至此也算是告一段落,之後瀏覽器會檢查當前請求發出的源是否在服務端響應的Access-Control-Allow-Origin列出的源的列表中,如果是才會傳送真正的請求。在實驗過程中,瀏覽器並不一定要在伺服器支援Preflight請求查詢的請求方法和Header時才傳送真正的請求,只要發出請求的源是合法的就會在Preflight請求之後把請求發出去。
JSONP
JSONP 的全稱是 JSON with Padding,譯為被填充的JSON。前端在指定要請求的URL時可以通過和後端約定一個指定回撥函式名稱的引數,確保後臺響應的指令碼片段中呼叫了前端指定的回撥函式,以此可以實現傳送多個JSONP請求而且互不干擾。具體JSONP實現如下:
var delicious_callbacks = {};
function jsonp(url, callback) {
var uid = (new Date()).getTime();
delicious_callbacks[uid] = function (data) {
delete delicious_callbacks[uid];
callback(data);
};
url += "?jsonp=" + encodeURIComponent("delicious_callbacks[" + uid + "]");
var script = document.createElement('script')
script.src = url
document.body.appendChild(script)
};
jsonp("http://example.com/api", function(data) { // here we get the data });
複製程式碼
JSONP這種方式本身也是存在一定缺陷的,很明顯它只能用於GET請求。另外,後端應用程式在處理過程可能會出現4xx、5xx錯誤或者遇到其他意外情況,導致無法返回正確的js函式呼叫格式的字串的情況,所以還需要監聽script標籤的onerror事件來處理可能出現的意外情況。
Cookie跨域共享
cookie作為客戶端儲存的一種方案,在客戶端設定cookie也有以下幾種方法:
- 配置服務端返回Set-Cookie響應頭
- 在頁面上的JavaScript程式碼中通過document.cookie,直接設定document.cookie為我們需要儲存的內容並不會覆蓋現有的cookie,舉例:
document.cookie="name=Jack;path=/"
document.cookie="age=25;path=/" // cookie中會同時儲存name和age這兩個欄位
複製程式碼
cookie是有過期時間的,如果像上面的程式碼一樣沒有顯式地設定cookie的過期時間,則在瀏覽器退出之後相應的cookie也會被清除。
一般情況下,瀏覽器在訪問頁面時會自動將和當前域名以及路徑匹配的Cookie傳送到伺服器端。而在一般情況下,我們在頁面中發出的Ajax請求則不會自動將當前URL關聯的Cookie同請求一同傳送到服務端。如果我們需要在請求中將和當前訪問的URL的Cookie傳送到服務端,可以設定XMLHttpRequest物件的withCredentials屬性為true。
而如果是要將當前頁面所在域名的Cookie傳送到另一個域名下的服務端,這時候需要對服務端進行配置,使其支援CORS,同時還需要注意此時服務端返回的Access-Control-Allow-Origin不能再設定為‘*’,同時服務端需要返回Access-Control-Allow-Credentials: true,否則服務端的響應依然會被瀏覽器block掉。
在這樣配置之後,瀏覽器在傳送這種攜帶憑據資訊(也就是Cookie)的Ajax請求時就會把當前頁面所在的域名下path屬性和請求的URL相匹配(比如當前請求的URL為/test/example,那麼設定在/,/test/,/test/example這些path之下的Cookie會被髮送)的Cookie一同傳送到服務端。這樣就實現了Cookie的跨域共享。
跨頁面通訊
除了和不同源的伺服器進行通訊的需求以外,我們還會遇到跨頁面通訊問題,需要訪問其他頁面上的一些資訊,或者將一些資料持久化,以供其他頁面取用。具體方式如下:
document.domain
通過這種方式跨域的兩個源需要滿足一定的條件的,即兩個源的域名需要是父子域的關係或者是相同的域。因為頁面設定document.domain的值只能是當前域本身,或者是父域,而不能是其他不相關的域名。只有兩個頁面的document.domain都設定成相同的值,嵌入iframe的頁面和iframe載入的頁面才能相互獲取到彼此的頁面資訊(包括DOM結構、window物件等)。
在實踐中也發現需要注意的兩個問題:
- 如果兩個頁面所在的源是一樣的,可以直接通訊,但是如果兩個頁面所在的域名相同但埠不同或者是其他情況,那麼兩個頁面仍需要設定相同的document.domain,否則還是會被瀏覽器block掉。具體原因在MDN上也有提到:
瀏覽器單獨儲存埠號。任何的賦值操作,包括
document.domain = document.domain
都會導致埠號被重寫為null
。因此company.com:8080
不能僅通過設定document.domain = "company.com"
來與company.com
通訊。必須在他們雙方中都進行賦值,以確保埠號都為null
。
- 需要在嵌入的iframe載入完成之後才能和其載入的子頁面進行通訊,否則拿到的值可能還是undefined。
window物件name屬性
瀏覽器具有這樣一個特性:同一個標籤頁或者同一個iframe框架載入過的頁面共享相同的window.name屬性值,意味著只要是在同一個標籤頁裡面開啟過的頁面(不管是否同源),這些頁面上window.name屬性值都是相同的。利用這個特性,就可以將這個屬性作為在不同頁面之間傳遞資料的介質。
如果是通過iframe+window.name這種方式在完全沒有父子域關係的兩個源之間傳遞資料(假設源A要獲取源B中的資料),源A頁面上的iframe在載入源B的目標頁面(源B頁面把資料設定在window.name屬性上)之後還需要再跳轉到源A的某個頁面上,以便於嵌入iframe的頁面通過(上面介紹的)和在iframe中的頁面將document.domain都設定為源A的方式來獲取iframe中的資料。示例程式碼如下:
// www.a.com/getData.html
<script type="text/javascript">
function getData() {
var frame = document.getElementsByTagName("iframe")[0];
frame.onload = function () {
var data = frame.contentWindow.name;
// 此處獲取資料
alert(data);
};
frame.contentWindow.location = "./aaa.html";
// 載入完www.b.com/data.html之後就載入www.a.com/下隨便一個頁面,獲取資料
}
</script>
<iframe src="http://www.b.com/data.html" style="display: none;" onload="getData();"></iframe>
複製程式碼
HTML5 cross-document message
HTML5中引入了另外一種跨頁面通訊的方式,稱為跨文件訊息傳送。同樣可以實現主頁面和嵌入的iframe子頁面(或者由當前頁面開啟的頁面)之間完成資料的傳遞,另外這種方法也可以用於當前JavaScript引擎執行緒和其他worker執行緒之間完成資料交換。如果是與通過iframe載入的子頁面進行通訊,則需要先獲取到接收資料的目標頁面的window物件(具體通過前面提到的方法來獲取),通過該物件的postMessage方法可以向目標頁面傳送資料。
<!--send.html-->
<iframe src="./receiver.html" id="frame"></iframe>
<button id="send-btn">send message</button>
<script>
var frame = document.getElementById('frame')
document.getElementById('send-btn').addEventListener('click', function() {
frame.contentWindow.postMessage({
name: 'Jack'
}, 'http://localhost:8888') // 接收資訊的頁面所在的源
})
</script>
<!--receiver.html-->
<script>
window.addEventListener('message', function(e) {
// 驗證訊息傳送方所在的源
if(e.origin === 'http://localhost:8888') {
console.log(e.data)
e.source.postMessage(...) // 回送訊息
}
})
</script>
複製程式碼
如果是需要和頁面上的worker進行通訊,直接呼叫建立出來的Worker例項的postMessage方法,在Worker例項執行的指令碼中則通過self或者this來訪問Worker例項,進而呼叫postMessage方法來完成通訊。
localStorage
localStorage是HTML5引入的客戶端儲存方案,通過localStorage儲存的內容會一直儲存在客戶端,除非呼叫removeItem方法顯式移除,否則內容將永久保留。MDN上對localStorage的介紹也提到了一種通過cookie在不支援localStorage的瀏覽器上實現localStorage的方法,通過將cookie的過期時間設定為未來很長之後的一個時間點可以模擬localStorage永久保留的特性,而在模擬localStorage移除儲存內容時則將對應的cookie。更進一步,如果不設定cookie的過期時間,還可以用來模擬瀏覽器中的另一種客戶端儲存方案--sessionStorage。和cookie不同的是,localStorage提供的儲存容量上限更大。
前面也提到了,localStorage儲存的內容是以源為單位進行管理的,這意味著即使域名相同,埠不同的頁面也無法通過localStorage進行通訊的。在瀏覽器的多個標籤頁中分別開啟多個同源頁面,這些頁面中的window物件可以通過監聽storage事件,當其他標籤頁的頁面在設定localStorage中的內容時會觸發該事件來進行通知,通過這種方式也可以實現跨頁面通訊。
其他跨域問題
字型檔案載入
CSS中引用的字型檔案載入也存在跨域問題,需要設定CORS才能載入其他域下的字型檔案。預設情況下定義新的字型不會立即去下載對應的字型檔案,只有當頁面上的元素使用了這種字型才會去下載對應的字型檔案。
跨域指令碼錯誤處理
對於頁面上載入的跨域指令碼執行出錯,頁面上繫結的錯誤處理函式window.onerror在預設情況下是獲取不到具體的錯誤資訊的,這時候需要在載入跨域指令碼的標籤上使用crossorigin屬性,也就是在請求跨域指令碼的時候執行CORS。crossorigin屬性可以設定的值有:
- anonymous:請求指令碼的時候不會攜帶憑據
- use-credentials:請求指令碼的時候攜帶憑據
設定為其他值都會被看作是anonymous關鍵字。設定了crossorigin屬性意味著還需要對伺服器進行配置,使其支援CORS。如果服務端沒有正確配置CORS,跨域指令碼是無法正常下載的。
canvas繪製內容轉化為檔案物件
canvas中動態載入的圖片可以直接畫到canvas中,但是在將canvas轉化成檔案物件進行操作時也存在跨域問題,會遇到“Tainted canvases may not be exported”錯誤。這時候需要對動態載入的圖片物件設定crossOrigin屬性,同時也需要配置伺服器使其支援CORS。
let img = new Image()
img.crossOrigin = 'anonymous'
img.src = "//localhost:8888/images/1751527990314_.pic.jpg"
img.onload = () => {
let canvas = document.getElementById('canvas')
let ctx = canvas.getContext('2d')
ctx.drawImage(img, 0, 0)
canvas.toBlob(blob => console.log(blob), 'image/jpeg', .75)
}
複製程式碼
總結
本文主要介紹了瀏覽器中的同源策略以及如何在同源策略的約束之下完成隸屬不同源的客戶端和服務端通訊,以及跨頁面通訊。這些跨域方法在實際使用中也需要從具體的場景出發,根據不同的通訊需求採用合適的方法。以上,如有疏漏之處,還望斧正。