再也不學AJAX了!(三)跨域獲取資源 ② - JSONP & CORS

libinfs發表於2017-12-06

瀏覽器的“同源策略”固然保障了網際網路世界的資料隱私與資料安全,但是如果當我們需要使用AJAX跨域請求資源時,“同源策略”又會成為開發者的阻礙。在本文中,我們會簡單介紹需要跨域請求資源的兩種情景,然後,詳細解釋目前主流的四種跨域請求資源方案。

讓我們開始吧!

一、何時需要跨域

試想,當我們擁有多個站點,並且這些站點又經常共享相同的資料,那麼為每個站點儲存一份資料看起來就蠢透了。更好的方案是,我們建設一臺靜態資源儲存伺服器,然後讓我們的所有站點都從這一臺伺服器上獲取資源。很理想的方案,但是現實中,我們首要解決的問題便是瀏覽器的“同源策略”,別忘了,不同域之間無法通過AJAX技術獲取資源。這是需要跨域獲取資源的主要情景。

另外,站在網際網路“開放,平等,自由”精神的角度上講,如果所有人的資料都被設定為只有同域才能訪問,那麼網際網路世界未免也太無聊了,如果我就是想要與更多的人分享我的資料,難道不應該有辦法讓我做到這一點嗎?

當然有辦法,下面我們就將一一解釋當下主流的跨域請求資源方式。


二、跨域請求資源方案

我們將主要介紹以下四種跨域請求資源的方案,並逐一解釋他們的原理,實用方式以及優缺點,希望你和我一樣有耐心,耐心總是能帶來回報:

  1. 野路子出身卻好用的方式:JSONP;
  2. 官方推薦的跨域資源共享方案:CORS;
  3. 使用HTML5 API:postMessage;
  4. 拋棄HTTP,使用:Web Sockets;

在開始下面的內容之前,我們首先需要強調一點,無論是怎樣的跨域資源獲取方案,本質上都需要伺服器端的支援。跨域獲取資源之所以能夠成功,本質是伺服器默許了你有許可權獲取相應資源。下面我們所運用的種種方式,實際上是客戶端和服務端互相配合,繞過同源策略進行資料互動的工作,千萬不要誤以為掌握了下述技術後,我們就能成為一個黑客 ??‍♂️。

(一)野路子出身卻異常好用的方式:JSONP

正如標題所描述的那樣,JSONP技術是早期某個(些?)聰明的程式設計師發明的跨域資源獲取方式,由於該技術的簡單易用,逐漸變得越來越流行,最終成為經典的跨域獲取資源方案。

JSONP是“JSON with padding”的簡寫,我將其翻譯為“被包裹的JSON”,當你看完這個章節,你一定會覺得這個名字相當貼切。

讓我們模擬一下當初想到JSONP技術的高手程式設計師是如何推理的:

首先,我們應該清楚的認識到,瀏覽器的“同源策略”只是阻止了通過AJAX技術跨域獲取資源,而並沒有禁止跨域獲取資源這件事本身,正因如此,我們可以通過<link>標籤,<img>標籤以及<script>標籤中的href屬性或src屬性獲取異域的CSS,JS資源和圖片(雖然我們其實並不能讀取這些資源的內容)。

其次,我們知道(也許你不知道,但是,還記得嗎,我在模擬那個高手程式設計師?)<script>標籤通過src屬性載入的JS資源,實際上只是將JS檔案內容原封不動的放置在<scritp>的標籤內,並沒有什麼神奇之處!

也就是說,如果我們的sayHi.js檔案只有這樣一段程式碼:

// sayHi.js
alert('Hi')
複製程式碼

當我們在HTML檔案中,成功載入sayHi.js檔案時,瀏覽器只不過是做了如下操作:

<!-- 載入前 -->
<script src="sayHi.js"></script>

<!-- 載入後 (為了方便閱讀,我格式化了程式碼)-->
<script src="sayHi.js">
    alert('Hi')
</script>
複製程式碼

這意味著什麼呢?這意味著被載入的檔案與HTML檔案下的其他JS檔案共享一個全域性作用域。也就是說,<scritp>標籤載入到的資源是可以被全域性作用域下的函式所使用的!

但是慢著!如果<script>標籤載入到的一些資料並不符合JavaScript語法規定的資料型別,JavaScript就無法處理這些錯誤不是嗎?而且就算資料型別正常了,我們還應該將資料儲存於一個變數內,然後呼叫這個變數...

說的沒錯!不過我們其實已經離正確答案很近了。

還記的我們這一方案的名稱嗎?JSONP!,也就是說我們已經約定好了資料的格式為JSON,這是JavaScript可以處理的資料型別,並且JSON格式的資料可以承載大量資訊。那麼有關變數的問題呢?這個回答則更巧妙些,因為我們會通過向伺服器傳入一個函式的方式,將資料變為函式的引數,讓我們直接看看JSONP的使用方式:

1.    function handleResponse(response) {
2.        alert(`You get the data : ${response}`)
3.    }
4.    const script = document.createElement('script')
5.    script.src = 'http://somesite.com/json/?callback=handleResponse'
6.    document.body.insertBefore(script, document.body.firstChild)
複製程式碼

很容易看到,我們在1-3行中建立了一個函式,該函式用來處理我們將要獲得的資料,該函式的引數response即是伺服器響應的資料。在4-6行中我們所做的是利用JavaScript動態生成一個script標籤,並將其插入HTML文件。但是注意第5行我們制定的src值,在URL末尾,我們有這樣一段查詢引數callback=handleResponse,callback的值正是我們先前建立的函式。

事情開始變得有些令人困惑了,究竟發生了什麼呢?我們如何通過上述程式碼最終實現跨域獲取資源?

答案就藏在服務端的程式碼中,當服務端支援JSONP技術時,會做如下一些設定:

  1. 識別請求的URL,提取callback引數的值,並動態生成一個執行該引數值(一個函式)的JavaScript語句;
  2. 將需要返回的資料放入動態生成的函式中,等待其加在到頁面時被執行;

此時該檔案內容看起來就像這樣:

handleResponse(response) // response為被請求的JSON格式的資料
複製程式碼

因此,當資源載入到位,內容顯示在script標籤內時,瀏覽器引擎會執行這條語句,我們想要的資料就可以被我們以任何想要的方式處理了。真不可思議!

你現在知道為什麼這項技術被命名為JSONP了吧?那個“padding”指的就是我們的“callback”函式,真是恰如其名。

最後,我們還要對JSONP技術再強調兩點:

  1. JSONP技術與AJAX技術無關:雖然同樣牽扯到跨域獲取資源這個主題,但我們應該已經清楚的看到,JSONP的本質是繞過AJAX獲取資源的機制,使用原始的src屬性獲取異域資源;
  2. JSONP技術存在一下三點缺陷:
    • 無法傳送POST請求,也就是說JSONP技術只能用於請求異域資源,無法上傳資料或修改異域資料;
    • 無法監測JSONP請求是否失敗;
    • 可能存在安全隱患:別忘了,JSONP之所以能成功獲取異域伺服器資源,靠的是伺服器動態生成了回撥函式,並在頁面中執行,那麼如果伺服器在原有的回撥函式下再新增些別的惡意JavaScript程式碼會怎樣?當然也會被執行!所以在使用JSONP技術時,一定要確保請求資源的伺服器是值得信賴的;

雖然存在一些缺陷,但JSONP的瀏覽器相容性卻是非常好的,可以說是一種非常小巧高效的跨域資源獲取技術。


(二)官方推薦的跨域資源共享方案:CORS

CORS是W3C頒佈的一個瀏覽器技術規範,其全稱為“跨域資源共享”(Cross-origin resource sharing),它的意義在於,它是由W3C官方推廣的允許通過AJAX技術跨域獲取資源的規範,因此相較於JSONP而言,功能更加強大,使用起來也沒有了hack的味道。

關於CORS的具體細節,我建議你可以移步阮一峰的同主題部落格閱讀,我認為該文章已經將這個主題講解的十分透徹了。

你當然也可以選擇繼續向下閱讀,看看我是怎樣理解CORS技術並重新梳理CORS技術相關知識的,希望也能給你帶來幫助。

我們之前提到過,如果想要繞過瀏覽器“同源策略”,實現使用AJAX技術跨域獲取資源,需要服務端和客戶端的協同合作。而對於CORS標準而言,實現AJAX跨域獲取資源,重點還在於伺服器端返回的響應是否清楚的告知了瀏覽器此次跨域AJAX請求的合法性。

那麼?伺服器端該如何向瀏覽器傳達這一資訊呢?答案是要看AJAX請求的複雜程度,也就是說,對於簡單的AJAX請求,伺服器要向瀏覽器做出的“說明”就少,而如果是複雜的AJAX,伺服器則要向瀏覽器多“解釋”幾句。

那麼,如何區分AJAX請求的複雜度呢,標準在於簡單的AJAX請求只符合下面兩個條件:

  1. 請求方法只屬於HEADGETPOST請求的其中一種;
  2. HTTP的頭資訊只限於以下欄位:
    • Accept
    • Accept-Language
    • Content-Language
    • Last-Event-ID
    • Content-Type(只能為application/x-www-form-urlencodedmultipart/form-datatext/plain其中一種)

而當瀏覽器檢測到一個簡單的跨域AJAX請求,瀏覽器會首先為我們新增一個頭部資訊:Origin它的值為請求傳送程式碼所在的源(希望你還記得,一個由“協議”,“域名”組成)。類似這樣:

GET /cors HTTP/1.1
Origin: http://api.bob.com
Host: api.alice.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0 ...
複製程式碼

而當這樣的一條HTTP請求傳送到服務端時,服務端會檢測該請求報頭中的Origin欄位的值是否在許可範圍內,如果的確是服務端認可的域,那麼服務端會在響應報文中新增如下欄位:

  • Access-Control-Allow-Origin(必須):該欄位用來告知瀏覽器服務端接受的能夠傳送跨域AJAX請求的域,它的值要麼是該次AJAX請求報頭中由瀏覽器自動新增的Origin值,要麼還可以是一個*號,表示可以接受任意的域名請求;
  • Access-Control-Allow-Credentials(可選):該欄位用來告知瀏覽器是否允許客戶端向服務端傳送Cookie。預設情況下,CORS規範會阻止跨域AJAX向服務端傳送Cookie,因此該欄位預設值為false,當你顯式的將該欄位值設定為true時,則表示允許此次跨域AJAX向服務端傳送Cookie。
  • Access-Control-Expose-Headers(可選):該欄位用來向客戶端暴露可獲取的響應頭;

CORS規範規定,客戶端XMLHttpRequest物件的getResponseHeader()方法只能拿到6個基本的欄位: * Cache-Control:表示響應遵循的快取機制; * Content-Language:表示響應體的語言; * Content-Type:表示響應體的MIME型別; * Expires:表示文件的過期時間,到期不再快取; * Last-Modified:表示文件的最後改動時間; * Pragma:用來包含特定的指令; 但是當客戶端想要獲取額外的響應頭欄位時,就需要服務端通過在該欄位後定義相應的客戶端可獲取的響應頭欄位名稱。


以上就是簡單跨域AJAX請求,客戶端與服務端的互動,在繼續介紹複雜的跨域AJAX請求前,讓我們先停一停,回過頭來看看響應報頭的Access-Control-Allow-Origin欄位,談一談CORS規範中為什麼預設不允許跨域AJAX請求攜帶Cookie,以及如果客戶端需要傳送Cookie時,客戶端與服務端又該如何互動的問題。

首先,我們要知道,在客戶端與服務端資料傳輸的過程中,Cookie一直是以明文的形式伴隨著資料的傳輸,只要客戶端傳送了Cookie至服務端,服務端就會至少返回該段Cookie。而我們又提到過,大多數網站都使用Cookie短暫儲存使用者會話中的身份資訊,因此將Cookie暴露在外是存在安全隱患的,CSRF攻擊的目的便是獲取使用者的Cookie資訊,因此在跨域AJAX請求中,為了減少Cookie洩露的風險,CORS規範預設禁止跨域AJAX請求攜帶Cookie。

那麼如果客戶端實在需要攜帶Cookie資訊怎麼辦呢?正如上文提到過的,需要客戶端與服務端一起配合,讓我們看看具體細節:

  • 首先是客戶端:

開發者需要在建立XMLHttpRequest物件例項時,手動配置withCredentials屬性,將其值設定為true

var xhr = new XMLHttpRequest()
xhr.withCredentials = true
複製程式碼

某些瀏覽器會預設允許在跨域AJAX請求中傳送Cookie,此時如果不想要傳送Cookie,你只需要將其值設定為false

  • 其次是服務端:

對於服務端而言,除了像之前提到的要在響應報頭設定Access-Control-Allow-Credential欄位的值為true之外,還需要為Access-Control-Allow-Origin欄位設定一個明確的域,不可以再使用*號。

相信你也能明白,這一切都是為了保護客戶端與服務端Cookie的隱私和安全。


現在我們可以繼續我們的主題,一起看一看如果我們的跨域AJAX請求超出了“簡單”的標準,客戶端與服務端又應該如何相互配合,實現跨域的資源共享。

與簡單AJAX跨域請求不同,“複雜“的AJAX跨域請求一共會傳送兩次HTTP請求,其中第一次為”查詢請求“,第二次才是我們正式的”AJAX跨域請求“。為什麼多出了一次”查詢請求“呢?道理其實很簡單,我們想象一下當傳送”複雜“的AJAX跨域請求時,瀏覽器最先拿到請求開始識別,然後發現這個請求並不“單純”(不滿足簡單跨域AJAX請求標準),於是感到十分疑惑的瀏覽器會試探的沿著請求的地址向服務端發問,詢問服務端是否允許異域的客戶端向它傳送額外的請求資訊,這一次“發問”,即是第一次HTTP請求,即“查詢請求”。而服務端當然也會這次“發問”給出相應的回答,然後瀏覽器就會根據回答的結果決定是否繼續傳送該跨域AJAX請求。

讓我們看看具體的實現細節:

首先,讓我們創造出一個“複雜”的AJAX跨域請求:

var url = 'http://another.com/cors'
var xhr = new XMLHttpRequest()
xhr.open('put', url, true) // 這裡我們設定請求的方式為'put'
xhr.setRequestHeader('X-Custom-Header', 'Value') // 這裡我們自定義了一個請求頭欄位
xhr.send()
複製程式碼

當瀏覽器識別到該請求“並不簡單”時,就會自動向服務其傳送一個“查詢請求”,其報頭資訊大致如下:

OPTIONS /cors HTTP/1.1
Origin: http://thisOne.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom-Header
Host: another.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...
複製程式碼

注意這次“查詢請求”使用了“OPTIONS”的請求方法,表明了這是一個查詢請求。請求頭部的資訊說明了請求來源的域請求使用的HTTP方法以及請求額外傳送的頭部欄位

讓我們再轉換至伺服器視角,當服務端接收到瀏覽器發來的這樣一個查詢請求後,就可以判斷出是否應該接收該請求。如果想要向瀏覽器表示允許該請求,則會返回這樣的響應報文:

HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2.0.61(Unix)
Access-Control-Allow-Origin: http://thisOne.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header // 該欄位值為以“,”號分割的字串
Content-type: text/html; charset=utf-8
Content-Encoding: gzip
Content-Length: 0
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plain
複製程式碼

讀到這裡我們已經大概猜的出服務端向瀏覽器傳遞的資訊了:

  • 首先,Access-Control-Allow-Origin欄位向瀏覽器說明了發起AJAX請求的域是被伺服器認可的(注意這個欄位的值也可以為一個“*”號);
  • 其次,Access-Control-Allow-Methods欄位向瀏覽器說明了伺服器接收跨域AJAX的請求方式;
  • 最後,Access-Control-Allow-Headers欄位向瀏覽器說明了伺服器允許跨域AJAX額外傳送的報頭資訊;

當瀏覽器收到服務端這樣的表示同意請求的響應後,就會正常傳送接下來的跨域AJAX請求,而伺服器也會正常的回應。值的一提的是,在服務端與客戶端整個跨域AJAX請求的互動中,Access-Control-Allow-Origin頭資訊自始至終都是必須攜帶的。

而當伺服器在檢查“查詢請求”後,如果不同該請求,則會返回一個正常的HTTP響應,報文中包含任何與CORS規範有關的報頭欄位,此時,瀏覽器就會心領神會的明白伺服器拒絕接收發出的跨域AJAX請求,因此會返回一個錯誤狀態(可以被XML物件例項使用onerror回撥函式捕獲)並在控制檯列印一條錯誤資訊:

XMLHttpRequest cannot load http://another.com
Origin http://thisOne.com is not allowed by Access-Control-Allow-Origin
複製程式碼

至此,無論是“簡單”的跨域AJAX請求還是“複雜”的跨域AJAX請求,我們都已經清楚的知曉了他們的運作原理,這真是件了不起的事情。但是先彆著急慶祝,我們剛才還遺漏了一個話題沒有談到:“節約複雜AJAX跨域請求的HTTP請求數”。

相信你還記的,對於“複雜”的跨域AJAX請求,瀏覽器會向伺服器傳送兩次HTTP請求,雖然實際上兩次HTTP請求與一次HTTP請求所耗費的時間幾乎難以感知,但是如果我們有辦法一次搞定,又為什麼還要重複做兩次呢?

對於伺服器而言,“一次搞定”的方法就在於,在瀏覽器第一次傳送複雜的跨域AJAX查詢請求時,在響應報頭中新增Access-Control-Max-Age欄位,這是一個可選的欄位,它用來指定本次查詢請求的有效期,單位為秒。也就是說,通過該欄位,伺服器擁有了告知瀏覽器“這個請求我批准了,X秒以內不需要再向我確認”的能力。至此,我們成功的將接下來的跨域請求數由兩次節約為一次!

三、小結

一口氣看到這裡?真不容易! 希望這是值得的,讓我們總結一下我們在本文中都談到了些什麼。首先,我們談到了我們何時需要發起跨域AJAX請求的問題,做到了“知其然”。其次,我們深入探討了使用JSONP技術和CORS規範實現傳送跨域AJAX請求的細節,成功達到了我們“知其所以然”的目標。相信現在的你已經對向他人談論“跨域”這個主題充滿自信。真的很棒對吧?

如果你依然覺得意猶未盡,不妨接著和我繼續深入這個主題,看看實現跨域共享資源的另外兩種“時髦”的方式:使用 postMessage 和 webSocket。

感興趣嗎?休息一下,然後再回來,目前為止你表現的都非常出色!? 。




? Hey!喜歡這篇文章嗎?別忘了在下方? 點贊讓我知道。

相關文章