【Pride】再談風騷的跨源/域方案(今日篇)

前言

上接《再談風騷的跨源/域方案(昔日篇)》,本篇聊聊現代標準(HTML5以後)的跨源方案。
基礎概念都在昔日篇中,初學者請務必先看完昔日篇。
配套的演示案例傳送門
本人我的能力有限,歡迎批評指正。javascript

PostMessage

該方案使用了 HTML5 新的 window.postMessage 接口,該方法是專門爲不一樣源頁面通訊設計的,是一個經典的「訂閱-通知」模型。前端

原理

該方案原理與昔日篇的「子域代理」很類似,都是主頁面用 iframe 內非同源子頁面做爲代理去跟服務端交互獲取數據。不一樣之處在於,「子域代理」須要經過修改 document.domain 使主頁面獲取子頁面 document 操做權限,而 window.postMessage 已經原生提供了主頁面與子頁面通訊的辦法,故僅須要主頁面經過 window.postMessage 向子頁面下命令,子頁面請求完成後再以此通知主頁面便可實現跨源通訊,換句話說子頁面變成了一個相似轉發服務的存在。
不須要修改 document.domain 也意味着擺脫了「子域代理」嚴格的域限制,能夠更加自由的應用在第三方 API 上。
window.postMessage 是少有的不受同源限制的瀏覽器 API,準確來講是沒有調用權限的限制而已,它對發送和接收的目標仍是有嚴格限制的,這也是它安全性的體現。舉個例子:java

// 假設在 iframe 內頁面進行訂閱。
window.addEventListener('message', event => {
  // 驗證發送者,發送者不符合是能夠不理會的。
  if (event.origin !== 'http://demo.com') return
  // 這就是發送過來的信息。
  const data = event.data
  // 這是發送者的 window 實例,能夠調用上面的 postMessage 回傳信息。
  const source = event.source
})
// 主頁面通知。
// 第二個參數是接收者的源,須要源徹底匹配的頁面纔會接收到信息。(「源」的定義見昔日篇)
// 設置爲 * 能夠實現廣播,不過通常不推薦。
iframe.contentWindow.postMessage('hello there!', 'http://demo.com')

流程

  1. API 所在的域部署一個代理頁,設置好對 message 事件的監聽,包含發送 Ajax 並將響應結果 postMessage 回主頁面的功能;
  2. 主頁面也設置對 message 事件的監聽,並進行內容分發;
  3. 主頁面新建 iframe 標籤連接到代理頁;
  4. 當 iframe 內的代理頁就緒時,主頁面就可使用 iframe.contentWindow.postMessage 發送請求給代理頁;
  5. 代理頁接收到請求,後發起 Ajax 到服務端 API;
  6. 服務端處理並響應,代理接收到響應後再經過 event.source.postMessage 傳遞給主頁面。

PostMessage

錯誤處理

  • 經過 iframe 的 load 事件能夠檢查代理頁是否被加載(非同源須要 hack 方法),以此間接判斷是否有網絡錯誤,但並不可知具體的錯誤緣由,也就是說沒法獲取到服務器的響應狀態碼;
  • iframe 的 error 事件在大部分瀏覽器是無效的(默認),發送 Ajax 是在 iframe 中完成,若是發生錯誤只能經過 postMessage 轉發給主頁面,所以建議不要在 iframe 內處理錯誤,應統一交給主頁面處理。

實踐提示

  • 前端git

    • 加載代理頁是須要耗時的,所以要注意發起請求的時機,免在代理頁還未加載完的時候請求;
    • 並不須要每次請求都加載新的代理頁,強烈建議只保留一個,多個請求共享;
    • 若是聽從上一條的建議,還需考慮代理頁加載失敗的狀況,避免一次失敗後後續均不能夠;
    • 可使用預加載的方式提早加載代理頁,以避免增長請求的時間;
    • 不管是接收方仍是發送方,都應該設置和驗證 postMessage 的目標(targetOrigin),以確保安全性;
    • 不必每次請求都去監聽 message 事件,能夠在初始化時設置一個統一事件處理器進行內容分發,用一個對象將每次請求的回調保存起來,分配惟一的 id ,經過統一的事件處理器按 id 調用回調;
    • 若是聽從上一條的建議,全局對象內回調函數須要及時清理。
  • 服務端github

    • 代理頁的域必須與 API 的域是一致的;
    • 代理頁通常無需常常更新,能夠進行長期緩存;
    • 代理頁應儘可能精簡,Ajax 請求的結果不管成功或失敗都應 postMessage 給主頁面。

共享 iframe 的設計思路請參考昔日篇的「子域代理」。
前端「統一事件處理器」的設計思路:web

function initMessageListener() {
  // 保存回調對象的對象。
  const cbStore = {}
  // 設置監聽,只需一個。
  window.addEventListener('message', function (event) {
    // 驗證發送域。
    if (event.origin !== targetOrigin) {
      return
    }
    // ...
    try {
      // 運行失敗分支。
      if (...) {
        cbStore[msgId].reject(new Error(...))
        return
      }
      // 運行成功分支。
      cbStore[msgId].resolve(...)
    } finally {
      // 執行清理。
      delete cbStore[msgId]
    }
  })
  // 這裏造成了一個閉包,只能用特定方法操做 cbStore。
  return {
    // 設置回調對象的方法。
    set: function (msgId, resolve, reject) {
      // 回調對象包含成功和失敗兩個分支函數。
      cbStore[msgId] = {
        resolve,
        reject
      }
    },
    // 刪除回調對象的方法。
    del: function (msgId) {
      delete cbStore[msgId]
    }
  }
}
// 初始化,每次請求都調用其 set 方法設置回調對象。
const messageListener = initMessageListener()

配合上面的「統一事件處理器」,msgId 其實不必傳遞到服務端,在代理頁處理便可:json

window.addEventListener('message', event => {
  // 驗證發送域。
  if (event.origin !== targetOrigin) {
    return
  }
  // 這是主頁面 postMessage 的數據。
  // 其中 msgId 與「統一事件處理器」有關,其餘參數與 Ajax 有關,按實際須要傳遞便可。
  const { msgId, method, url, data } = event.data
  // 發送 Ajax。
  xhr(...).then(res => {
    // 將 msgId 加入回傳數據,其他保留原樣。
    res.response.data = {
      ...res.response.data,
      msgId
    }
    // 回傳給主頁面。
    event.source.postMessage(res, targetOrigin)
  })
})

具體代碼請參考演示案例 PostMessage 部分源碼。canvas

總結

  • 優勢segmentfault

    • 能夠發送任意類型的請求;
    • 可使用標準的 API 規範;
    • 能提供與正常 Ajax 請求無差異的體驗;
    • 錯誤捕獲方便準確(除了 iframe 的網絡錯誤);
    • 對域無要求,可用於第三方 API。
  • 缺點api

    • iframe 對瀏覽器性能影響較大;
    • 實際測試, PostMessage 接口的轉發有小延遲;
    • 僅能用於現代瀏覽器。

CORS(跨源資源分享)

CORS 全稱 Cross-origin resource sharing ,是 W3C 組織制訂的標準跨源方案(傳送門),也能夠說是跨源的官方終極解決方案,它讓現代的 web 開發方便很多。

原理

簡單來講 CORS 是一套服務端與瀏覽器的協商機制,經過報文頭實現,瀏覽器告知服務端來源(origin)和但願容許的方法,服務端返回「白名單」(也是一組報文頭),瀏覽器依據「白名單」判斷是否容許此次請求,可應用與 Ajax、canvas 等的跨源狀況。
CORS 分爲 簡單請求(simple) 和 複雜請求(complex),他們最主要的區別就是需不須要預檢(preflight)。
簡單請求須要知足以下條件(只挑重點):

  • 方法(method)爲以下之一

    • GET
    • POST
    • HEAD
  • 只容許設置以下報文頭(header)

    • Accept
    • Accept-Language
    • Content-Language
    • Content-Type (只容許三個)

      • text/plain
      • multipart/form-data
      • application/x-www-form-urlencoded
    • DPR
    • Downlink
    • Save-Data
    • Viewport-Width
    • Width

不知足上面條件的都會被斷定爲複雜請求,就實際使用而言 form 發出的請求基本都是容許的,若是要使用 json 格式傳遞數據(即 Content-Type: application/json),那一定是複雜請求。
複雜請求會先發出預檢請求,也就是先問問看服務端,若是返回的「白名單」符合要求再會發起正式的請求。
預檢請求是方法(method)爲 OPTION 的請求,它不須要攜帶任何業務數據,僅依照須要發送 CORS 相關請求報文頭給服務端,服務端也不須要響應任何業務數據,僅返回「白名單」,完成協商便可。

  • CORS 相關請求報文頭

    • Origin:發起請求頁面的源,由瀏覽器自動添加,不容許手動設置;
    • Access-Control-Request-Method:但願服務端容許的方法,瀏覽器預檢時依據正式請求的須要自動添加,不容許手動設置;
    • Access-Control-Request-Headers:但願服務端容許的請求報文頭,瀏覽器預檢時依據正式請求的須要自動添加,不容許手動設置。
  • CORS 相關響應報文頭(即「白名單」)

    • Access-Control-Allow-Origin:容許訪問該資源的域,這是開啓 CORS 一定會返回的響應報文頭,填寫爲 則表示容許來自全部域的請求,若是指定了非 的源,須要將源做爲緩存判斷依據,所以添加 Vary: Origin 以避免當 API 給不一樣源頁面返回不一樣數據時,被緩存搞混;
    • Access-Control-Expose-Headers:在跨域的狀況下, XMLHttpRequest 對象的 getResponseHeader() 方法只能拿到一些最基本的響應頭,若是要獲取而外頭部,須要進行指定;
    • Access-Control-Max-Age:本次預檢的最長有效期(秒),在這段時間內瀏覽器將不須要再次預檢,而是直接發送正式請求;
    • Access-Control-Allow-Credentials:是否容許攜帶 cookie ,默認爲 false,當設置爲 true 時,不容許 Access-Control-Allow-Origin 設爲 * ;
    • Access-Control-Allow-Methods:容許使用的請求方法;
    • Access-Control-Allow-Headers:容許使用的請求報文頭,經常使用於添加自定義報文頭。

流程

簡單請求與通常的 Ajax 流程徹底相同,僅需瀏覽器發送 Origin 請求報文頭,服務端返回 Access-Control-Allow-Origin 響應報文頭便可。
下面詳講複雜請求的狀況。
假設如今網頁源爲 http://demo.com ,服務端 API 源爲 http://api.demo.com ,需求請求的方法爲 POST ,數據類型是 json,自定義報文頭 token 。

  1. 瀏覽器檢查到,將發起 Ajax 請求的 API 源與當前頁面源不一樣,則進入 CORS 協商;
  2. 數據類型是 json,而已還要自定義報文頭,斷定本次是複雜請求;
  3. 發送預檢 OPTION 請求,有關 CORS 的報文頭設置以下:

    • 讀取當前頁面的源寫入 Origin: http://demo.com
    • 因爲須要 POST 請求,則 Access-Control-Request-Method: POST
    • 因爲須要數據類型是 json,也就是默認三種 content-type 不符合要求,還有自定義報文頭 token 則 Access-Control-Request-Headers: content-type, token
  4. 服務器接收到預檢請求進行響應,有關 CORS 的報文頭設置以下:

    • 寫入容許的域 Access-Control-Allow-Origin: http://demo.com
    • 寫入容許的方法 Access-Control-Allow-Methods: POST, GET, OPTIONS
    • 寫入容許的報文頭 Access-Control-Allow-Headers: Content-Type, token
    • 寫入 Vary: Origin(上面有說明,它不屬於 CORS 報文頭,但必須)
  5. 瀏覽器接收到響應,驗證 CORS 響應報文頭,驗證經過則緊接着發送正式 POST 請求,僅需添加 Origin: http://demo.com ,其他與正常請求一致;
  6. 服務器接收正式請求,處理後進行響應,僅需添加 Access-Control-Allow-Origin: http://demo.comVary: Origin ,其他與正常響應一致;
  7. 瀏覽器接收到響應,驗證 CORS 響應報文頭,驗證經過則完成請求。

CORS

錯誤處理

  • 服務器錯誤能夠像通常請求那樣捕獲,得到準確的狀態碼;
  • 當發生跨源相關的錯誤時,可在 XMLHttpRequest 對象的 error 事件捕獲到;
  • 跨源相關的錯誤整體分兩類。

    • 攔截響應的錯誤:好比簡單請求的時候,接收到響應數據,但響應報文頭驗證未經過,這時候雖然從抓包上看已經完成請求,但瀏覽器依然會報錯;
    • 限制請求的錯誤:好比複雜請求的時候,預檢返回的響應報文頭驗證未經過,則瀏覽器不會發起正式的請求,而是直接報錯,這時候抓包是看不到正式請求的。

實踐提示

  • 前端

    • 該方案對前端的影響是十分小的,幾乎是瀏覽器自動完成,像通常請求那樣發起便可;
    • 錯誤處理部分有提到兩類跨源相關的錯誤,這是在調試時須要注意的點。
  • 服務端

    • 不建議無腦添加 CORS 相關響應報文頭,要按需添加,以避免形成頭部冗餘,參考上面的流程,能夠大體可分爲兩組。

      • 簡單請求頭部:Access-Control-Allow-Origin 和 Vary 兩個便可;
      • 預檢請求頭部:按需選擇 CORS 的頭部,外加 Vary。
    • Access-Control-Max-Age 是一個有效的優化手段,它能夠減小頻繁的預檢請求,節約資源。
    • 除非是公共的第三方 API,不建議將 Access-Control-Allow-Origin 設爲 * 號。
    • 爲了安全性,最好驗證 Origin 請求報文頭,而不是忽略它,當不符合要求時,能夠返回 403 狀態碼。

具體代碼請參考演示案例 CORS 部分源碼。

總結

  • 優勢

    • 能夠發送任意類型的請求;
    • 可使用標準的 API 規範;
    • 能提供與正常 Ajax 請求無差異的體驗;
    • 錯誤捕獲方便準確;
    • 對域無要求,可用於第三方 API。
  • 缺點

    • 僅能用於現代瀏覽器。
相關文章
相關標籤/搜索