那些年前端跨過的域

沃趣葫蘆娃發表於2018-04-27

跨域 是前端領域繞不開的一道題,今天就來好好聊一聊前端跨域。

同源策略

同源策略(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 的跨域能力就可以實現不同域之間的資料通訊,具體步驟如下:

  1. 在訪問頁面(http://a.com/page.html)動態建立 iframe 標籤,src 屬性指向資料頁面(http://b.com/data.html)
  2. 為 iframe 繫結 load 事件,當資料頁面載入成功後,把 iframe 的 src 屬性指向同源代理頁面(也可以是空白頁)
  3. 當 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.comnews.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 方法以及 streampipe 方法。

上面是一個最簡單的 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 方式。

相關文章