跨域 是前端領域繞不開的一道題,今天就來好好聊一聊前端跨域。
同源策略
同源策略(same-origin policy) 最初是由 Netspace 公司在 1995 年引入瀏覽器的一種安全策略,現在所有的瀏覽器都遵守同源策略,它是瀏覽器安全的基石。
同源策略規定跨域之間的指令碼是相互隔離的,一個域的指令碼不能訪問和操作另外一個域的絕大部分屬性和方法。所謂的 同源 指的是 協議相同,域名相同,埠相同。
同源策略最初只是用來防止不同域的指令碼訪問 Cookie 的,但是隨著網際網路的發展,同源策略越來越嚴格,目前在不同域的場景下,Cookie、本地儲存(LocalStorage,SessionStorage,IndexDB),DOM 內容,AJAX(Asynchronous JavaScript and XML,非同步的 JavaScript 與 XML 技術) 都無法正常使用。
下表給出以 http://www.a.com/page/index.html 為例子進行同源檢測的示例:
示例 | URL | 結果 | 原因 |
---|---|---|---|
A | http://www.a.com/page/login.html | 成功 | 同源 |
B | http://www.a.com/page2/index.html | 成功 | 同源 |
C | https://www.a.com/page/secure.html | 失敗 | 不同協議 |
D | http://www.a.com:8080/page/index.html | 失敗 | 不同埠 |
E | http://static.a.com/page/index.html | 失敗 | 不同域名 |
F | http://www.b.com/page/index.html | 失敗 | 不同域名 |
解決方案
解決方案按照解決方式可以分為四個大的方面:
- 純前端方式
- 純後端方式
- 前後端配合的方式
- 其他方式
純前端方式
- 有
src
或者herf
屬性的標籤 - window.name
- document.domain
- location.hash
- postMessage
- CSST (CSS Text Transformation)
- Flash
有 src
或者 herf
屬性的標籤
所有具有 src
屬性的標籤都是可以跨域,比如:<script>
、<img>
、<iframe>
,以及 <link>
標籤,這些標籤給我們了提供呼叫第三方資源的能力。
這些標籤也有限制,如:只能用於 GET
方式獲取資源,需要建立一個 DOM 物件等。
不同的標籤傳送請求的機制不同,需要區別對待。如:<img>
標籤在更改 src
屬性時就會發起請求,而其他的標籤需要新增到 DOM 樹之後才會發起請求。
const img = new Image()
img.src = 'http://domain.com/picture' // 發起請求
const iframe = document.createElement('iframe')
iframe.src = 'http://localhost:8082/window_name_data.html'
document.body.appendChild(iframe) // 發起請求
複製程式碼
window.name
原理:利用神奇的 window.name
屬性以及 iframe
標籤的跨域能力。 window.name 的值不是普通的全域性變數,而是當前視窗的名字,iframe 標籤也有包裹的窗體,自然也就有 window.name 屬性。
window.name 屬性神奇的地方在於 name 值在不同的頁面(甚至不同域)載入後依舊存在,且在沒有修改的情況下不會變化。
// 開啟一個空白頁,開啟控制檯
window.name = JSON.stringify({ name: 'window', version: '1.0.0' })
window.location = 'http://baidu.com'
//頁面跳轉且載入成功後, window.name 的值還是我們最初賦值的值
console.log(window.name) // {"name":"window","version":"1.0.0"}
複製程式碼
window.name 屬性結合 iframe 的跨域能力就可以實現不同域之間的資料通訊,具體步驟如下:
- 在訪問頁面(http://a.com/page.html)動態建立 iframe 標籤,src 屬性指向資料頁面(http://b.com/data.html)
- 為 iframe 繫結 load 事件,當資料頁面載入成功後,把 iframe 的 src 屬性指向同源代理頁面(也可以是空白頁)
- 當 iframe 再次 load,即可以操作 iframe 物件的 contentWindow.name 屬性,獲取資料來源頁面設定的 window.name 值
注意:當資料來源頁面載入成功後(即 window.name 已經賦值),需要把 iframe 的 src 指向訪問頁面的同源頁面(或者空白頁 about:blank;
),否則在讀取 iframe.contentWindow.name
屬性時會因為同源策略而報錯。
window.name 還有一種實現思路,就是 資料頁在設定完 window.name 值之後,通過 js 跳轉到與父頁面同源的一個頁面地址,這樣的話,父頁面就能通過操作同源子頁面物件的方式獲取 window.name 的值,以達到通訊的目的。
document.domain
原理:通過使用 js 對父子框架頁面設定相同的 document.domain
值來達到父子頁面通訊的目的。
限制:只能在主域相同的場景下使用。
iframe 標籤是一個強大的標籤,允許在頁面內部載入別的頁面,如果沒有同源策略那我們的網站在 iframe 標籤面前基本沒有安全可言。
www.a.com
與 news.a.com
被認為是不同的域,那麼它們下面的頁面能夠通過 iframe 標籤巢狀顯示,但是無法互相通訊(不能讀取和呼叫頁面內的資料與方法),這時候我們可以使用 js 設定 2 個頁面的 document.domain
的值為 a.com
(即它們共同的主域),瀏覽器就會認為它們處於同一個域下,可以互相呼叫對方的方法來通訊。
// http://www.a.com/www.html
document.domain = 'a.com'
// 設定一個測試方法給 iframe 呼叫
window.openMessage = function () {
alert('www page message !')
}
const iframe = document.createElement('iframe')
iframe.src = 'http://news.a.com:8083/document_domain_news.html'
iframe.style.display = 'none'
iframe.addEventListener('load', function () {
// 如果未設定相同的主域,那麼可以獲取到 iframeWin 物件,但是無法獲取 iframeWin 物件的屬性與方法
const iframeWin = iframe.contentWindow
const iframeDoc = iframeWin.document
const iframeWinName = iframeWin.name
console.log('iframeWin', iframeWin)
console.log('iframeDoc', iframeDoc)
console.log('iframeWinName', iframeWinName)
// 嘗試呼叫 getTestContext 方法
const iframeTestContext = iframeWin.getTestContext()
document.querySelector('#text').innerText = iframeTestContext
})
document.body.appendChild(iframe)
// http://news.a.com/news.html
document.domain = 'a.com'
// 設定 windon.name
window.name = JSON.stringify({ name: 'document.domain', version: '1.0.0' })
// 設定一些全域性方法
window.getTestContext = function () {
// 嘗試呼叫父頁面的方法
if (window.parent) {
window.parent.openMessage()
}
return `${document.querySelector('#test').innerText} (${new Date()})`
}
複製程式碼
location.hash
原理:利用修改 URL 中的錨點值來實現頁面通訊。URL 中有 #abc
這樣的錨點資訊,此部分資訊的改變不會產生新的請求(但是會產生瀏覽器歷史記錄),通過修改子頁的 hash 值傳遞資料,通過監聽自身 URL hash 值的變化來接收訊息。
該方案要做到父子頁面的雙向通訊,需要用到 3 個頁面:主呼叫頁,資料頁,代理頁。這是因為主呼叫頁可以修改資料頁的 hash 值,但是資料頁不能通過 parent.location.hash
的方式修改父頁面的 hash 值(僅 IE 與 Chrome 瀏覽器不允許),所以只能在資料頁中再載入一個代理頁(代理頁與主呼叫頁同域),通過同域的代理頁去操作主呼叫頁的方法與屬性。
// http://www.a.com/a.html
const iframe = document.createElement('iframe')
iframe.src = 'http://www.b.com/b.html'
iframe.style.display = 'none'
document.body.appendChild(iframe)
setTimeout(function () {
// 向資料頁傳遞資訊
iframe.src = `${iframe.src}#user=admin`
}, 1000)
window.addEventListener('hashchange', function () {
// 接收來自代理頁的訊息(也可以讓代理頁直接操作主呼叫頁的方法)
console.log(`page: data from proxy.html ---> ${location.hash}`)
})
// http://www.a.com/b.html
const iframe = document.createElement('iframe')
iframe.src = 'http://www.a.com/proxy.html'
iframe.style.display = 'none'
document.body.appendChild(iframe)
window.addEventListener('hashchange', function () {
// 收到主呼叫頁傳來的資訊
console.log(`data: data from page.html ---> ${location.hash}`)
// 一些其他的操作
const data = location.hash.replace(/#/ig, '').split('=')
if (data[1]) {
data[1] = String(data[1]).toLocaleUpperCase()
}
setTimeout(function () {
// 修改子頁 proxy.html iframe 的 hash 傳遞訊息
iframe.src = `${iframe.src}#${data.join('=')}`
}, 1000)
})
// http://www.a.com/proxy.html
window.addEventListener('hashchange', function () {
console.log(`proxy: data from data.html ---> ${location.hash}`)
if (window.parent.parent) {
// 把資料代理給同域的主呼叫頁(也可以直接呼叫主呼叫頁的方法傳遞訊息)
window.parent.parent.location.hash = location.hash
}
})
複製程式碼
postMessage
postMessage 是 HTML5 XMLHttpRequest Level 2 中的 API,可以安全的實現跨域通訊,它可用於解決以下方面的問題:
- 頁面和其開啟的新視窗的資料傳遞
- 多視窗之間訊息傳遞
- 頁面與巢狀的 iframe 訊息傳遞
- 上面三個場景的跨域資料傳遞
postMessage 的具體使用方法可以參考 window.postMessage ,其中有 2 點需要注意:
- postMessage 方法依附於具體的 window 物件,比如 iframe 的 contentWindow,執行
window.open
語句返回的視窗物件等。 targetOrigin
引數可以指定哪些視窗接收訊息,包含 協議 + 主機 + 埠號,也可以設定為萬用字元 '*'。
// http://www.a.com/a.html
const iframe = document.createElement('iframe')
iframe.src = 'http://www.b.com/b.html'
iframe.style.display = 'none'
iframe.addEventListener('load', function () {
const data = { user: 'admin' }
// 向 b.com 傳送跨域資料
// iframe.contentWindow.postMessage(JSON.stringify(data), 'http://www.b.com')
iframe.contentWindow.postMessage(JSON.stringify(data), '*')
})
document.body.appendChild(iframe)
// 接受 b.com 返回的資料
window.addEventListener('message', function (e) {
console.log(`a: data from b.com ---> ${e.data}`)
}, false)
// http://www.b.com/b.html
window.addEventListener('message', function (e) {
console.log(`b: data from a.com ---> ${e.data}`)
const data = JSON.parse(e.data)
if (data) {
data.user = String(data.user).toLocaleUpperCase()
setTimeout(function () {
// 處理後再發回 a.com
// window.parent.postMessage(JSON.stringify(data), 'http://www.a.com')
window.parent.postMessage(JSON.stringify(data), '*')
}, 1000)
}
}, false)
複製程式碼
CSST (CSS Text Transformation)
原理:藉助 CSS3 的 content
屬性獲取傳送內容的跨域傳輸文字的方式。
相比較 JSONP 來說更為安全,不需要執行跨站指令碼。
缺點就是沒有 JSONP 適配廣,且只能在支援 CSS3 的瀏覽器正常工作。
具體內容可以通過檢視 CSST 瞭解。
Flash
Flash 有自己的一套安全策略,伺服器可以通過 crossdomain.xml 檔案來宣告能被哪些域的 SWF 檔案訪問,通過 Flash 來做跨域請求代理,並且把響應結果傳遞給 javascript,實現跨域通訊。
純後端方式
- Server Proxy
- CORS(Cross-origin resource sharing)
Server Proxy
同源策略針對的是瀏覽器,http/https 協議不受此影響,所以通過 Server Proxy 的方式就能解決跨域問題。
實現步驟也比較簡單,主要是服務端接收到客戶端請求後,通過判斷 URL 實現特定跨域請求就代理轉發(http,https),並且把代理結果返回給客戶端,從而實現跨域的目的。
// NodeJs
const http = require('http')
const server = http.createServer(async (req, res) => {
if (req.url === '/api/proxy_server') {
const data = 'user=admin&group=admin'
const options = {
protocol: 'http:',
hostname: 'www.b.com',
port: 8081,
path: '/api/proxy_data',
method: req.method,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(data),
},
}
const reqProxy = http.request(options, (resProxy) => {
res.writeHead(resProxy.statusCode, { 'Content-Type': 'application/json' })
resProxy.pipe(res) // 將 resProxy 收到的資料轉發到 res
})
reqProxy.write(data)
reqProxy.end()
}
})
複製程式碼
NodeJs 中 Server Proxy 主要使用 http
模組的 request
方法以及 stream
的 pipe
方法。
上面是一個最簡單的 NodeJs Server Proxy 實現,真實場景需要考慮更多複雜的情況,更詳細的可以介紹可以點選 如何編寫一個 HTTP 反向代理伺服器 進行了解。
進一步瞭解:HTTP 代理原理及實現(一) HTTP 代理原理及實現(二)
CORS(Cross-origin resource sharing)
CORS 的全稱是“跨域資源共享”(Cross-origin resource sharing),是 W3C 標準。通過 CORS 協議實現跨域通訊關鍵部分在於伺服器以及瀏覽器支援情況(IE不低於IE10),整個 CORS 通訊過程都是瀏覽器自動完成,對開發者來說 CORS 通訊與同源的 AJAX 請求沒有差別。
瀏覽器將 CORS 請求分為兩類:簡單請求(simple request)和 非簡單請求(not-so-simple request)。更加詳細的資訊可以通過閱讀 阮一峰老師 的 跨域資源共享 CORS 詳解 文章進行深入瞭解。
// server.js
// http://www.b.com/api/cors
const server = http.createServer(async (req, res) => {
if (typeof req.headers.origin !== 'undefined') {
// 如果是 CORS 請求,瀏覽器會在頭資訊中增加 origin 欄位,說明請求來自於哪個源(協議 + 域名 + 埠)
if (req.url === '/api/cors') {
res.setHeader('Access-Control-Allow-Origin', req.headers.origin)
res.setHeader('Access-Control-Allow-Credentials', true)
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, PUT, DELETE, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Cache-Control, X-Access-Token')
const resData = {
error_code: 0,
message: '',
data: null,
}
if (req.method === 'OPTIONS') {
// not-so-simple request 的 預請求
res.setHeader('status', 200)
res.setHeader('Content-Type', 'text/plain')
res.end()
return
} else if (req.method === 'GET') {
// simple request
Object.assign(resData, { data: { user: 'admin' } })
} else if (req.method === 'PUT') {
// not-so-simple
res.setHeader('Set-Cookie', ['foo=bar; HttpOnly', 'bar=baz; HttpOnly', 'y=88']) // 設定伺服器域名 cookie
Object.assign(resData, { data: { user: 'ADMIN', token: req.headers['x-access-token'] } })
} else {
Object.assign(resData, { data: { user: 'woqu' } })
}
res.setHeader('status', 200)
res.setHeader('Content-Type', 'application/json')
res.write(JSON.stringify(resData))
res.end()
return
}
res.setHeader('status', 404)
res.setHeader('Content-Type', 'text/plain')
res.write(`This request URL '${req.url}' was not found on this server.`)
res.end()
return
}
})
// http://www.a.com/cors.html
setTimeout(function () {
console.log('CORS: simple request')
ajax({
url: 'http://www.b.com:8082/api/cors',
method: 'GET',
success: function (data) {
data = JSON.parse(data)
console.log('http://www.b.com:8082/api/cors: GET data', data)
document.querySelector('#test1').innerText = JSON.stringify(data)
},
})
}, 2000)
setTimeout(function () {
// 設定 cookie
document.cookie = 'test cookie value'
console.log('CORS: not-so-simple request')
ajax({
url: 'http://www.b.com:8082/api/cors',
method: 'PUT',
body: { user: 'admin' },
header: { 'X-Access-Token': 'abcdefg' },
success: function (data) {
data = JSON.parse(data)
console.log('http://www.b.com:8082/api/cors: PUT data', data)
document.querySelector('#test2').innerText = JSON.stringify(data)
},
})
}, 4000)
複製程式碼
前後端配合的方式
- JSONP(JSON with Padding)
JSONP(JSON with Padding)
原理: <script>
標籤可以跨域載入並執行指令碼。
JSONP 是一種簡單高效的跨域方式,並且易於實現,但是因為有跨站指令碼的執行,比較容易遭受 CSRF(Cross Site Request Forgery,跨站請求偽造) 攻擊,造成使用者敏感資訊洩露,而且 因為 <script>
標籤跨域方式的限制,只能通過 GET 方式獲取資料。
// server.js
// http://www.b.com/api/jsonp?callback=callback
const server = http.createServer((req, res) => {
const params = url.parse(req.url, true)
if (params.pathname === '/api/jsonp') {
if (params.query && params.query.callback) {
res.writeHead(200, { 'Content-Type': 'text/plain' })
res.write(`${params.query.callback}(${JSON.stringify({ error_code: 0, data: 'jsonp data', message: '' })})`)
res.end()
}
}
// ...
})
// http://www.a.com/jsonp.html
const script = document.createElement('script')
const callback = function (data) {
console.log('jsonp data', typeof data, data)
}
window.callback = callback // 把回撥函式掛載到全域性物件 window 下
script.src = 'http://www.b.com:8081/api/jsonp?callback=callback'
setTimeout(function () {
document.body.appendChild(script)
}, 1000)
複製程式碼
其他方式
- WebSocket
- SSE(Server-sent events)
WebSocket
WebSocket protocol 是 HTML5 一種新的協議。它實現了瀏覽器與伺服器全雙工通訊,同時允許跨域通訊,是 server push 技術的一種很好的實現。
// 服務端實現可以使用 socket.io,詳見 https://github.com/socketio/socket.io
// client
const socket = new WebSocket('ws://www.b.com:8082')
socket.addEventListener('open', function (e) {
socket.send('Hello Server!')
})
socket.addEventListener('message', function (e) {
console.log('Message from server', e.data)
})
複製程式碼
SSE(Server-sent events)
SSE 即 伺服器推送事件,支援 CORS,可以基於 CORS 做跨域通訊。
// server.js
const server = http.createServer((req, res) => {
const params = url.parse(req.url, true)
if (params.pathname === '/api/sse') {
// SSE 是基於 CORS 標準實現跨域的,所以需要設定對應的響應頭資訊
res.setHeader('Access-Control-Allow-Origin', req.headers.origin)
res.setHeader('Access-Control-Allow-Credentials', true)
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, PUT, DELETE, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Cache-Control, X-Access-Token')
res.setHeader('status', 200)
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
res.write('retry: 10000\n')
res.write('event: connecttime\n')
res.write(`data: starting... \n\n`)
const interval = setInterval(function () {
res.write(`data: (${new Date()}) \n\n`)
}, 1000)
req.connection.addListener('close', function () {
clearInterval(interval)
}, false)
return
}
})
// http://www.a.com:8081/sse.html
const evtSource = new EventSource('http://www.b.com:8082/api/sse')
evtSource.addEventListener('connecttime', function (e) {
console.log('connecttime data', e.data)
document.querySelector('#log').innerText = e.data
})
evtSource.onmessage = function(e) {
const p = document.createElement('p')
p.innerText = e.data
console.log('Message from server', e.data)
document.querySelector('#log').append(p)
}
setTimeout(function () {
evtSource.close()
}, 5000)
複製程式碼
最佳實踐
No silver bullets:沒有一種方案能夠適用所有的跨域場景,針對特定的場景使用合適的方式,才是最佳實踐。
- 資源跨域
- 頁面相互通訊
- 客戶端與服務端通訊
資源跨域
對於靜態資源,推薦藉助 <link>
<script>
<img>
<iframe>
標籤原生的能力實現跨域資源請求。
對於第三方介面,推薦基於 CORS 標準實現跨域,瀏覽器不支援 CORS 時推薦使用 Server Proxy 方式跨域。
頁面相互通訊
頁面間的通訊首先推薦 HTML5 新 API postMessage 方式通訊,安全方便。
其次瀏覽器支援不佳時,當主域相同時推薦使用 document.domain
方式,主域不同推薦 location.hash
方式。
客戶端與服務端通訊
非雙工通訊場景建議使用輕量級的 SSE 方式。
雙工通訊場景推薦使用 WebSocket 方式。