瀏覽器跨域方法與基於Fetch的Web請求最佳實踐

發表於2016-08-31

本文從屬於筆者的Web前端中DOM系列文章.

同源策略與跨域

同源策略

可謂同源?URL由協議、域名、埠和路徑組成,如果兩個URL的協議、域名和埠相同,則表示他們同源。瀏覽器的同源策略,限制了來自不同源的”document”或指令碼,對當前”document”讀取或設定某些屬性,即從一個域上載入的指令碼不允許訪問另外一個域的文件屬性。比如一個惡意網站的頁面通過iframe嵌入了銀行的登入頁面(二者不同源),如果沒有同源限制,惡意網頁上的javascript指令碼就可以在使用者登入銀行的時候獲取使用者名稱和密碼。所謂道高一尺魔高一丈,雖然瀏覽器以同源策略限制了我們隨意請求資源,但是從這個策略出現開始就有很多各種各樣的Hacker技巧來。

JSONP

JSONP是較為常用的一種跨域方式,不受到瀏覽器相容性的限制,但是因為它只能以GET動詞進行請求,這樣就破壞了標準的REST風格,比較醜陋。JSONP本質上是利用<script>標籤的跨域能力實現跨域資料的訪問,請求動態生成的JavaScript指令碼同時帶一個callback函式名作為引數。其中callback函式本地文件的JavaScript函式,伺服器端動態生成的指令碼會產生資料,並在程式碼中以產生的資料為引數呼叫 callback函式。當這段指令碼載入到本地文件時,callback函式就被呼叫。

(1)瀏覽器端構造請求地址

標準的Script標籤的請求地址為:請求資源的地址+獲取函式的欄位名+回撥函式名稱,這裡的獲取函式的欄位名是需要和服務端提前約定好,譬如jQuery中預設的獲取函式名就是callback。而resolveJson是我們預設註冊的回撥函式,注意,該函式名需要全域性唯一,該函式接收服務端返回的資料作為引數,而函式內容就是對於該引數的處理。

(2)服務端構造返回值

在接受到瀏覽器端 script 的請求之後,從url的query的callbackName獲取到回撥函式的名字,例子中是resolveJson

然後動態生成一段javascript片段去給這個函式傳入引數執行這個函式。比如:

(3)客戶端以指令碼方式執行服務端返回值

服務端返回這個 script 之後,瀏覽器端獲取到 script 資源,然後會立即執行這個 javascript,也就是上面那個片段。這樣就能根據之前寫好的回撥函式處理這些資料了。

CORS:跨域資源共享

跨域資源共享,Cross-Origin Resource Sharing是由W3C提出的一個用於瀏覽器以XMLHttpRequest方式向其他源的伺服器發起請求的規範。不同於JSONP,CORS是以Ajax方式進行跨域請求,需要服務端與客戶端的同時支援。目前CORS在絕大部分現代瀏覽器中都是支援的:

cross-domain-cors

CORS標準定義了一個規範的HTTP Headers來使得瀏覽器與服務端之間可以進行協商來確定某個資源是否可以由其他域的客戶端請求獲得。儘管很多的驗證與鑑權是由服務端完成,但是本質上大部分的檢查和限制還是應該由瀏覽器完成。一般來說CORS會分為Simple Request,簡單請求與Preflight,需要預檢的請求兩大類。其基本的流程如下:

2433232090-5798e12f2ab41_articlex

預檢請求

當瀏覽器的請求方式是HEAD、GET或者POST,並且HTTP的頭資訊中不會超出以下欄位:

  • Accept
  • Accept-Language
  • Content-Language
  • Last-Event-ID
  • Content-Type:只限於三個值application/x-www-form-urlencoded、multipart/form-data、text/plain

時,瀏覽器會將該請求定義為簡單請求,否則就是預檢請求。預檢請求會在正式通訊之前,增加一次HTTP查詢請求。瀏覽器先詢問伺服器,當前網頁所在的域名是否在伺服器的許可名單之中,以及可以使用哪些HTTP動詞和頭資訊欄位。只有得到肯定答覆,瀏覽器才會發出正式的XMLHttpRequest請求,否則就報錯。預檢請求的傳送請求:

“預檢”請求用的請求方法是OPTIONS,表示這個請求是用來詢問的。頭資訊裡面,關鍵欄位是Origin,表示請求來自哪個源。
除了Origin欄位,”預檢”請求的頭資訊包括兩個特殊欄位:

  • Access-Control-Request-Method:該欄位是必須的,用來列出瀏覽器的CORS請求會用到哪些HTTP方法,上例是PUT。
  • Access-Control-Request-Headers:該欄位是一個逗號分隔的字串,指定瀏覽器CORS請求會額外傳送的頭資訊欄位,上例是X-Custom-Header。

預檢請求的返回:

  • Access-Control-Allow-Methods:必需,它的值是逗號分隔的一個字串,表明伺服器支援的所有跨域請求的方法。注意,返回的是所有支援的方法,而不單是瀏覽器請求的那個方法。這是為了避免多次”預檢”請求。
  • Access-Control-Allow-Headers:如果瀏覽器請求包括Access-Control-Request-Headers欄位,則Access-Control-Allow-Headers欄位是必需的。它也是一個逗號分隔的字串,表明伺服器支援的所有頭資訊欄位,不限於瀏覽器在”預檢”中請求的欄位。
  • Access-Control-Max-Age:該欄位可選,用來指定本次預檢請求的有效期,單位為秒。上面結果中,有效期是20天(1728000秒),即允許快取該條回應1728000秒(即20天),在此期間,不用發出另一條預檢請求。

一旦伺服器通過了”預檢”請求,以後每次瀏覽器正常的CORS請求,就都跟簡單請求一樣,會有一個Origin頭資訊欄位。伺服器的回應,也都會有一個Access-Control-Allow-Origin頭資訊欄位。

簡單請求

對於簡單的跨域請求或者通過了預檢的請求,瀏覽器會自動在請求的頭資訊加上Origin欄位,表示本次請求來自哪個源(協議 + 域名 + 埠),服務端會獲取到這個值,然後判斷是否同意這次請求並返回。典型的請求頭尾:

如果服務端允許,在返回的頭資訊中會多出幾個欄位:

  • Access-Control-Allow-Origin:必須。它的值是請求時Origin欄位的值或者 *,表示接受任意域名的請求。
  • Access-Control-Allow-Credentials:可選。它的值是一個布林值,表示是否允許傳送Cookie。預設情況下,Cookie不包括在CORS請求之中。設為true,即表示伺服器明確許可,Cookie可以包含在請求中,一起發給伺服器。

再需要傳送cookie的時候還需要注意要在AJAX請求中開啟withCredentials屬性:var xhr = new XMLHttpRequest(); xhr.withCredentials = true;

需要注意的是,如果要傳送Cookie,Access-Control-Allow-Origin就不能設為*,必須指定明確的、與請求網頁一致的域名。同時,Cookie依然遵循同源政策,只有用伺服器域名設定的Cookie才會上傳,其他域名的Cookie並不會上傳,且原網頁程式碼中的document.cookie也無法讀取伺服器域名下的Cookie。

  • Access-Control-Expose-Headers:可選。CORS請求時,XMLHttpRequest物件的getResponseHeader()

法只能拿到6個基本欄位:Cache-Control、Content-Language、Content-Type、Expires、Last-

Modified、Pragma。如果想拿到其他欄位,就必須在Access-Control-Expose-Headers裡面指定。上面的例子指定,getResponseHeader('Info')可以返回Info欄位的值。

如果服務端拒絕了呼叫,即不會帶上 Access-Control-Allow-Origin 欄位,瀏覽器發現這個跨域請求的返回頭資訊沒有該欄位,就會丟擲一個錯誤,會被 XMLHttpRequestonerror 回撥捕獲到。這種錯誤無法通過 HTTP 狀態碼判斷,因為回應的狀態碼有可能是200。

postMessage

cross-domain-postmessage

window.postMessage 是一個用於安全的使用跨源通訊的方法。通常,不同頁面上的指令碼當且僅當執行它們的頁面所處的位置使用相同的協議(通常都是 http)、相同的埠(http預設使用80埠)和相同的主機(兩個頁面的 document.domain 的值相同)只在這種情況下被允許互相訪問。 而window.postMessage 提供了一個受控的機制來安全地繞過這一限制。其函式原型如下:

  • windowObj: 接受訊息的 Window 物件。
  • message: 在最新的瀏覽器中可以是物件。
  • targetOrigin: 目標的源,* 表示任意。

呼叫postMessage方法的window物件是指要接收訊息的那一個window物件,該方法的第一個引數message為要傳送的訊息,型別只能為字串;第二個引數targetOrigin用來限定接收訊息的那個window物件所在的域,如果不想限定域,可以使用萬用字元 * 。需要接收訊息的window物件,可是通過監聽自身的message事件來獲取傳過來的訊息,訊息內容儲存在該事件物件的data屬性中。上面所說的向其他window物件傳送訊息,其實就是指一個頁面有幾個框架的那種情況,因為每一個框架都有一個window物件。在討論第種方法的時候,我們說過,不同域的框架間是可以獲取到對方的window物件的,雖然沒什麼用,但是有一個方法是可用的-window.postMessage。下面看一個簡單的示例,有兩個頁面:

Proxy:服務端跨域

使用代理方式跨域更加直接,因為SOP的限制是瀏覽器實現的。如果請求不是從瀏覽器發起的,就不存在跨域問題了。使用本方法跨域步驟如下:

  • 把訪問其它域的請求替換為本域的請求
  • 本域的請求是伺服器端的動態指令碼負責轉發實際的請求

不過筆者在自己的開發實踐中發現目前服務端跨域還是很有意義的,特別當我們希望從不支援CORS或者JSONP的服務端獲取資料的時候,往往只能通過跨域請求。

Fetch

JavaScript 通過XMLHttpRequest(XHR)來執行非同步請求,這個方式已經存在了很長一段時間。雖說它很有用,但它不是最佳API。它在設計上不符合職責分離原則,將輸入、輸出和用事件來跟蹤的狀態混雜在一個物件裡。而且,基於事件的模型與最近JavaScript流行的Promise以及基於生成器的非同步程式設計模型不太搭。新的 Fetch API打算修正上面提到的那些缺陷。 它向JS中引入和HTTP協議中同樣的原語。具體而言,它引入一個實用的函式 fetch() 用來簡潔捕捉從網路上檢索一個資源的意圖。Fetch 規範 的API明確了使用者代理獲取資源的語義。它結合ServiceWorkers,嘗試達到以下優化:

  • 改善離線體驗
  • 保持可擴充套件性

而與jQuery相比, fetch 方法與 jQuery.ajax() 的主要區別在於:

  • fetch()方法返回的Promise物件並不會在HTTP狀態碼為404或者500的時候自動丟擲異常,而需要使用者進行手動處理
  • 預設情況下,fetch並不會傳送任何的本地的cookie到服務端,注意,如果服務端依靠Session進行使用者控制的話要預設開啟Cookie

Installation & Polyfill

window.fetch是基於XMLHttpRequest的瀏覽器的統一的封裝,針對老的瀏覽器可以使用Github的這個polypill。fetch基於ES6的Promise,在舊的瀏覽器中首先需要引入Promise的polypill,可以用這個:

對於fetch的引入,可以用bower或者npm:

如果是基於Webpack的專案,可以直接在Webpack的config檔案中引入這種polyfill:

這個外掛的配置主要依靠imports-loaderexports-loader,因此也需要匯入它們:

如果感覺這種方式比較麻煩,也可以使用 isomorphic-fetch

使用的時候也非常方便:

從筆者自己的體驗中,還是非常推薦使用isomorphic-fetch,其一大優勢在於能夠在node裡直接進行單元測試與介面可用性測試。老實說筆者之前用Mocha進行帶真實網路請求的測試時還是比較不方便的,往往需要在瀏覽器或者phatomjs中進行,並且需要額外的HTML程式碼。而在筆者的model.test.js檔案中,只需要直接使用babel-node model.test.js 即可以獲取真實的網路請求,這樣可以將網路測試部分與UI相剝離。

Basic Usage:基本使用

假設fetch已經被掛載到了全域性的window目錄下。

Request:請求構造

Request物件代表了一次fetch請求中的請求體部分,你可以自定義Request物件:

A Request instance represents the request piece of a fetch call. By passingfetch a Request you can make advanced and customized requests:

  • method – 使用的HTTP動詞,GET, POST, PUT, DELETE, HEAD
  • url – 請求地址,URL of the request
  • headers – 關聯的Header物件
  • referrer – referrer
  • mode – 請求的模式,主要用於跨域設定,cors, no-cors, same-origin
  • credentials – 是否傳送Cookie omit, same-origin
  • redirect – 收到重定向請求之後的操作,follow, error, manual
  • integrity – 完整性校驗
  • cache – 快取模式(default, reload, no-cache)

URI Encode

注意,fetch方法是自動會將URI中的雙引號進行編碼的,如果在URI中存入了部分JSON,有時候會出現意想不到的問題,譬如我們以GET方法訪問如下的URI:

那麼fetch會自動將雙引號編碼,變成:

那麼這樣一個請求傳入到Spring MVC中時是會引發錯誤的,即URI物件構造失敗這個很噁心的錯誤。筆者沒有看過原始碼,不過猜想會不會是Spring MVC看到{這個字元沒有被編碼,因此預設沒有進行解碼,結果沒想到後面的雙引號被編碼了,為了避免這個無厘頭的錯誤,筆者建議是對URI的Query Parameter部分進行統一的URI編碼:

Headers:自定義請求頭

常見的請求方法有: append, has, get, set以及 delete

POST & body:POST請求

File Upload:檔案上傳

Cookies

如果需要設定fetch自動地傳送本地的Cookie,需要將credentials設定為same-origin:

該選項會以類似於XMLHttpRequest的方式來處理Cookie,否則,可能因為沒有傳送Cookie而導致基於Session的認證出錯。可以將credentials的值設定為include來在CORS情況下傳送請求。

Response:響應處理

fetchthen函式中提供了一個Response物件,即代表著對於服務端返回值的封裝,你也可以在Mock的時候自定義Response物件,譬如在你需要使用Service Workers的情況下,在Response中,你可以作如下配置:

  • typebasic, cors
  • url
  • useFinalURL – 是否為最終地址
  • status – 狀態碼 (ex: 200, 404, etc.)
  • ok – 是否成功響應 (status in the range 200-299)
  • statusText – status code (ex: OK)
  • headers – 響應頭

The Response also provides the following methods:

  • clone() – Creates a clone of a Response object.
  • error() – Returns a new Response object associated with a network error.
  • redirect() – Creates a new response with a different URL.
  • arrayBuffer() – Returns a promise that resolves with an ArrayBuffer.
  • blob() – Returns a promise that resolves with a Blob.
  • formData() – Returns a promise that resolves with a FormData object.
  • json() – Returns a promise that resolves with a JSON object.
  • text() – Returns a promise that resolves with a USVString (text).

Handling HTTP error statuses:處理HTTP錯誤狀態

Handling JSON:處理JSON響應

Handling Basic Text/HTML Response:處理文字響應

Blob Responses

如果你希望通過fetch方法來載入一些類似於圖片等資源:

blob()方法會接入一個響應流並且一直讀入到結束。

Best Practice

筆者在自己的專案中封裝了一個基於ES6 Class的基本的模型請求類,程式碼地址

相關文章