再也不學AJAX了!(三)跨域獲取資源 ③ - WebSocket & postMessage

libinfs發表於2017-12-11

讓我們先簡單回顧一下之前談到的內容,AJAX是一種無頁面重新整理的獲取伺服器資源的混合技術。而基於瀏覽器的“同源策略”,不同“域”之間不可以傳送AJAX請求。但是在某些情境下,我們需要“跨域獲取資源”,為了滿足這一需求,我們可以使用“JSONP”與“CORS”兩種技術。

現在,我們將要簡要了解“跨域共享資源”的另外兩種方式:WebSocket 和 postMessage。讓我們先大概看看他們是什麼,以及究竟是基於怎樣的原理,滿足了我們的需求 - “跨域獲取資源”。

一、WebSocket

基於維基百科的定義,WebSocket是一種在單個TCP連線上進行全雙工通訊的協議。在這裡我並不打算解釋“TCP連線”和“全雙工通訊”這兩個專業術語(這樣做會讓這篇文章變得很長,而且也偏離了我們的主題),讓我們聚焦這段定義的最後兩個字協議

說到協議,你是否聯想到“HTTP協議”?沒錯,HTML5標準之所以提出了一種新的網際網路通訊協議 - WebSocket,就是為了彌補在某些情景下使用HTTP協議通訊的一些不足。但是注意,這並不意味WebSocket協議就可以完全取代HTTP協議了,其實兩者的關係更像是兩兄弟,各自有著各自擅長的領域,而且時不時還一同協作解決難題。

那麼上面提到的某些情景具體是指什麼呢?答案是“服務端與客戶端的雙向通訊”。我們知道,當我們使用HTTP協議時,客戶端與服務端的通訊模式始終是由客戶端向服務端傳送請求,服務端只負責驗證請求並返回響應。

我們可以這樣想象,在HTTP協議下,服務端扮演著“守門人”的角色,而客戶端則是一個郵局,它每傳送一個請求就像是委託一個信使攜帶一封信(信裡註明自己的身份和需要獲取資源的名稱)到服務端,當信使到達時,“守門人”會拆開信封,檢查裡面的身份資訊,如果身份合法則開啟資源寶庫的大門,將相應的資源交給信使,令其返回給客戶端。

在這個故事裡,服務端的角色有些枯燥呆板對吧?不僅如此,故事中服務端扮演的“守門人”角色還患有嚴重的臉盲症,在工作中他只“認信不認人”,也就是說客戶端傳送的每一個請求,對於服務而言都是全新的,守門人不會因為信使上次來過,或是收到兩次相同的信而覺得眼熟,對信使有額外的寒暄。這也就是為什麼我們說HTTP協議是“無狀態的”。乍看起來,這似乎有些不合理,但是這種設計卻使伺服器的工作變得簡單可控,提升了伺服器的工作效率。

但是這樣的設計仍然存在兩個問題:

  1. 每一個請求都需要身份驗證,這對於使用者而言意味著需要在每一次傳送請求時輸入身份資訊;
  2. 當客戶端所請求的資源是動態生成的時,客戶端無法在資源生成時得到通知(還記得吧,伺服器只是一個原地不動的“守門人”);

如何解決這兩個問題呢?對於前者,答案是使用“Cookie”,而對於後者,則輪到我們今天的主角“WebSocket”大顯身手。

在討論WebSocket之前,讓我們先稍微繞點路,談談“Cookie”是如何解決“每一個請求都需要身份驗證”的問題的。

(一)為HTTP協議新增狀態 - Cookie

我們之前提到,HTTP協議下,客戶端與服務端的通訊是“無狀態”的,也就是說,如果伺服器中的某部分資源是由某個客戶專屬的,那麼每當這個客戶想要獲取資源時,都需要首先在瀏覽器中輸入賬號密碼,然後再傳送請求,並在被伺服器識別身份資訊成功後獲取請求的資源。我們當然不想每次傳送一個請求都要輸入一遍賬號密碼,因此我們需要Cookie,這個既可以儲存在瀏覽器,又會被瀏覽器傳送HTTP請求時預設傳送至服務端,並且還受瀏覽器“同源策略”保護的東西幫助我們提高發起一次請求的效率。

在有了Cookie之後,我們可以在一次會話中(從使用者登入到瀏覽器關閉)只輸入一次賬號密碼,然後將其儲存在Cookie中,在整個會話期間,Cookie都會伴隨著HTTP請求的傳送被伺服器識別,從而避免了我們重複的輸入身份資訊。

不僅如此,基於Cookie的特性:可以儲存在瀏覽器內,還會在瀏覽器傳送HTTP請求時預設攜帶,服務端也可以操作Cookie。Cookie還可以幫助我們節省網路請求的發起數量。例如,當我們在製作一個購物網站時,我們當然不希望使用者在每新增一個商品到購物車就向伺服器傳送一個請求(請求數量越少,伺服器壓力就越小),此時,我們就可以將新增商品所導致的資料變動儲存在Cookie內,然後等待下次傳送請求時,一併傳送給伺服器處理。

現在我們可以說,Cookie的出現,為無狀態的HTTP協議通訊新增了狀態。

最後需要注意,Cookie大多數情況下,都儲存著使用者的身份資訊,因此各種惡意攻擊者對於Cookie的攻擊便花樣百出,層出不窮。其本質上就是想要獲得使用者的Cookie,再利用其中的身份資訊偽裝成使用者獲取相應資源,而瀏覽器的“同源策略”本質上就是保護使用者的Cookie資訊不會洩露。

(二)讓伺服器也動起來 - WebSocket

繞了一個小彎,現在可以回過頭來繼續談談我們的主角WebSocket了。再讓我們回憶一下WebSocket要解決的問題:

客戶端無法獲知請求的動態資源何時到位“,讓我們描述的更詳細一點,有時候客戶端想要請求的資源,伺服器需要一定時間後才能返回(比如該資源依賴於其他伺服器的計算返回結果),由於在HTTP協議下,網路通訊是單向的,因此伺服器並不具備當資源準備就緒時,通知瀏覽器的功能(因為我們要保障伺服器的工作效率)。因此,基於HTTP協議通常的做法是,設定一個定時器,每隔一定時間由瀏覽器向伺服器傳送一次請求以探測資源是否到位。

這種做法顯然浪費了很多請求,換句話說,浪費了很多頻寬(我們每個請求都要攜帶Cookie和報頭,這些都會佔用頻寬傳輸),不僅低效率,而且也不夠優雅。

理所當然的,在這種情況下,我們希望當伺服器資源到位時,能夠主動通知瀏覽器並返回相應資源。而為了實現這一點,HTML5標準推出了WebSocket協議,使瀏覽器和伺服器實現了雙向通訊,更妙的是,除了IE9及以下的IE瀏覽器,所有的瀏覽器都支援WebSocket協議。

讓我們也同樣構建一個基於WebSocket協議的心智模型,在這個心智模型中,服務端扮演的角色發生了一些改變,服務端不再只是一個“守門人”,同時它也運營著一個和客戶端一樣的“郵局”,也就是說,他也擁有了可以向客戶端傳送資料的能力。至此一個完整的基於WebSocket協議的通訊流程為:

客戶端派發一個信使向伺服器送信,伺服器扮演的“守門人”檢查信件,發現信件中寫到“讓我們用更加潮流的WebSocket方式交流吧”,伺服器在在信件末尾新增上一句“沒問題,瀏覽器夥計”,讓信使原路返回告知瀏覽器。當瀏覽器再次向伺服器告知收到訊息時(第三次握手),伺服器就開始運轉“郵局”,向客戶端派發信使與瀏覽器互發資訊,轉發資源。

讓我們看看這個模型的具體實現:

下面是客戶端告知服務端要升級為WebSocket協議的報頭:

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
Origin: http://example.com
複製程式碼

下面是服務端向客戶端返回的響應報頭:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
Sec-WebSocket-Protocol: chat
複製程式碼

想知道這些報頭中的欄位中代表什麼?可以參考維基百科下的說明。

(三)客戶端發起WebSocket請求

既然我們已經為了解釋“什麼是WebSocket”,“WebSocket的意義”花了那麼多篇幅,那麼不妨新增上最後一個環節,讓這個主題變得更加完整,接下來我們將要簡單講解一下客戶端如何發起一個WebSocket請求。

像發起AJAX請求一樣,發起WebSocket請求需要藉助瀏覽器提供的WebSocket物件,該物件提供了用於建立和管理WebSocket連線,以及通過該連線收發資料的API。所有的瀏覽器都預設提供了WebSocket物件。讓我們看看該物件的用法:

和使用XHRHttpRequest物件一樣,我們首先要例項化一個WebSocket物件:

var ws = new WebSocket("wss://echo.websocket.org")
複製程式碼

傳入的引數為響應WebSocket請求的地址。

同樣類似AJAX的是,WebSocket物件也有一個readyState屬性,用來表示物件例項當前所處的連結狀態,有四個值:

  • 0:表示正在連線中(CONNECTING);
  • 1:表示連線成功,可以通訊(OPEN);
  • 2:表示連線正在關閉(CLOSING);
  • 3:表示連線已經關閉或開啟連線失敗(CLOSED);

我們可以通過判斷這個值來執行我們相應的程式碼。

除此之外,WebSocket物件還提供給我們一系列事件屬性,使我們控制連線過程中的通訊行為:

  • onopen:用於指定連線成功後的回撥函式;
  • onclose:用於指定連線關閉後的回撥函式;
  • onmessage:用於指定收到伺服器資料後的回撥函式;
  • onerror:用於指定報錯時的回撥函式;

通過.send()方法,我們擁有了向伺服器傳送資料的能力(WebSocket還允許我們傳送二進位制資料):

ws.send('Hi, server!')
複製程式碼

如何知道何時我們的資料傳送完畢呢?我們需要使用WebSocket物件的bufferedAmount屬性,該屬性的返回值表示了還有多少位元組的二進位制資料沒有傳送出去,所以我們可以通過判斷該值是否為0而確定資料是否傳送結束。

var data = new ArrayBuffer(1000000)
ws.send(data)

if (socket.bufferedAmount === 0) {
    // 傳送完畢
} else {
    // 還在傳送
}
複製程式碼

OK,目前為止我們花了大量篇幅解釋了WebSocket協議是什麼,它能夠幫助我們做什麼,以及客戶端傳送WebSocket請求的方式。但是目前為止,我們還是沒有談論一丁點關於WebSocket是如何幫助我們繞過瀏覽器的“同源策略”讓我們實現“跨域資源共享”,你是否已經有點等的不耐煩了?

但是別急,當你清楚的瞭解到WebSocket是什麼之後,答案就呼之欲出了,那就是當客戶端與服務端建立WebSocket連線後,本身就可以天然的實現跨域資源共享,WebSocket協議本身就不受瀏覽器“同源策略”的限制(還記得吧,同源策略只是限制了跨域的AJAX請求?),所以問題本身就不成立(有點賴皮是吧?)。

但是你可能又會問,如果沒有瀏覽器“同源策略”的限制,那麼使用者的Cookie安全又由誰來保護呢?問得好,看來你有認真閱讀上面的文字,為了解答這個問題,讓我們換一種角度思考,我們說過Cookie的存在就是為了給無狀態的HTTP協議通訊新增狀態,因為Cookie是明文傳輸的,且通常包含使用者的身份資訊,所以非常受到網路攻擊者的“關注”。但是想想WebSocket協議下的通訊機制,客戶端和服務端一旦建立連線,就可以順暢的互發資料,因此WebSocket協議本身就是“有狀態的”,不需要Cookie的幫忙,既然沒有Cookie,自然也不需要“同源策略”去保護,因此其實這個問題也不成立。

至此,已經將關於WebSocket的所有內容都大致講述了一遍,真沒想到是如此巨大的工作量。看來本篇文章不應該叫做“再也不學AJAX了”,而是“再也不學AJAX,JSONP,CORS,WebSocket..”。

真是了不起。


二、postMessage

回頭一看,我們已經在“跨域”這個主題上整整停留了三篇文章,涉及的技術包括JSONP,CORS與WebSocket。需要注意的是,以上這些跨域技術都只適用於客戶端請求異域服務端資源的情景。而除此之外,有時候我們還需要在異域的兩個客戶端之間共享資料,例如頁面與內嵌iframe視窗通訊,頁面與新開啟異域頁面通訊。

這就是使用HTML5提供的新API -- postMessage的時候了。

使用postMessage技術實現跨域的原理非常簡單,一方面,主視窗通過postMessageAPI向異域的視窗傳送資料,另一方面我們在異域的頁面指令碼中始終監聽message事件,當獲取主視窗資料時處理資料或者以同樣的方式返回資料從而實現跨視窗的異域通訊。

讓我們用具體的業務場景與程式碼進一步說明,假如我們的頁面現在有兩個視窗,視窗1命名為“window_1”, 視窗2命名為“window_2”,當然,視窗1與視窗2的“域”是不同的,我們的需求是由視窗1向視窗2傳送資料,而當視窗2接收到資料時,將資料再返回給視窗1。先讓我們看看視窗1script標籤內的程式碼:

// window_1 域名為 http://winodow1.com:8080
window.postMessage("Hi, How are you!", "http://window2.com:8080")
複製程式碼

可以看到,postMessage函式接收兩個引數,第一個為要傳送的資訊(可以是任何JavaScript型別資料,但部分瀏覽器只支援字串格式),第二個為資訊傳送的目標地址。讓我們再看看視窗2script標籤內的程式碼:

// window_2 域名為 http://window2.com:8080
window.addEventListener("message", receiveMessage, false)

function receiveMessage(event) {
    // 對於Chorme,origin屬性為originalEvent.origin屬性
    var origin = event.origin || event.originalEvent.origin
    if (origin !== "http://window1.com:8080") {
        return 
    }
    window.postMessage("I\'m ok", "http://window1.com:8080")
}
複製程式碼

看到了嗎,我們在window上繫結了一個事件監聽函式,監聽message事件。一旦我們接收到其他域通過postMessage傳送的資訊,就會觸發我們的receiveMessage回撥函式。該函式會首先檢查傳送資訊的域是否是我們想要的(之後我們會對此詳細說明),如果驗證成功則會像視窗1傳送一條訊息。

看起來很好懂不是嗎,一方傳送資訊,一方捕捉資訊。但是,我需要格外提醒你的是所有“跨域”技術都需要關注的“安全問題”。讓我們想想postMessage技術之所以能實現跨域資源共享,本質上是要依賴於客戶端指令碼設定了相應的message監聽事件。因此只要有訊息通過postMessage傳送過來,我們的指令碼都會接收並進行處理。由於任何域都可以通過postMessage傳送跨域資訊,因此對於設定了事件監聽器的頁面來說,判斷到達頁面的資訊是否是安全的是非常重要的事,因為我們並不想要執行有危險的資料。

那麼接下來的問題便是,如何鑑別傳送至頁面的資訊呢?答案是通過 message事件監聽函式的事件物件,我們稱它為event,該物件有三個屬性:

  • data:值為其他window傳遞過來的物件;
  • origin:值為訊息傳送方視窗的域名;
  • source:值為對傳送訊息的視窗物件的引用;

很顯然的,我們應該著重檢測event物件的origin屬性,建立一個白名單對origin屬性進行檢測通常是一個明智的做法。

最後,再讓我們談談postMessage物件的瀏覽器相容性,這方面到是很幸運,除了IE8以下的IE瀏覽器,所有的瀏覽器都支援postMessage方法!


至此,我們終於完全講完了“跨域共享資源”這一主題。花了不少力氣是吧?希望這是值得的。





? Hey!到這裡《再也不學AJAX了!》這個專題系列就完全結束了,還記得我們的初心嗎?我希望你能通過閱讀這個系列的文章,以較為輕鬆的方式,系統完整地掌握AJAX技術,從此再也不用刻意學習零散的AJAX知識。希望我達成了我的目標,也希望你在閱讀學習的過程中感到愉快。

關於AJAX技術這個專題,其實我還想講述的兩個話題是:更優雅的資源獲取方式:fetch API 以及 深入jQuery:AJAX的實現,但是鑑於我個人時間精力有限(完成一個系列文章真的比我想的要付出更多時間!),就決定暫時先放下,等將來有機會再以這個系列的番外篇的形式補充上去,希望你們可以理解和接受:)。

這是我第一次在技術平臺中以“系列”的方式發表技術文章,我個人覺得這樣的方式更容易令人在整體上把握和理解一個技術,從而做到更靈活熟練的使用。希望你們也認同這一點並在閱讀過程中感到愉快。之後,我也會繼續在專欄中發表關於Web開發技術的系列文章,希望得到你們的認可和支援。

最後,再談談我在技術平臺發表文章的初心:之所以開始在各平臺(目前為稀土掘金和segmentfault)發表技術文章,主要是為了幫助我消化知識,鍛鍊寫作的文筆,驗證我對某個技術的理解是否正確,以及積攢人氣滿足虛榮心。在這個過程中,也希望讀者能夠通過閱讀我的文章,加深對某一技術的理解。我認為這是一件雙贏的事情,因此我十分歡迎,甚至是期待你在閱讀我任何文章的過程中都能夠:

  1. 如果覺得有所收穫,毫不猶豫的點選讚賞按鈕(我真的真的會很開心?);
  2. 如果想到了其他相關知識,或發現我對某個技術的理解不正確,毫不猶豫的在評論區留言與我交流
  3. 如果對於我講述中的某個概念還是不懂,毫不猶豫的在留言區告知我你的困惑,我會思考怎麼樣把這個概念講述的更加清楚明白;
  4. 如果覺得我的文章不錯,毫不猶豫的將我的文章推薦給他人,邀請他們成為我的讀者;
  5. 如果你覺得閱讀我的文章所花費的時間很值得,對你有很大幫助並且也認可我的勞動成果,你大可以點選下方紅色的“讚賞支援”按鈕為這篇文章付費,同時表達你對我創作的認可與支援。寫作能夠對人有益又能獲得報酬,這著實令人倍感欣慰。

我的創作和成長需要你們的幫助和支援,作為報答,我會持續釋出優質的文章,陪同你們一起成長。關注我,一起加油吧! ?

相關文章