走進數據通信之 Websocket
一、寫在前面
最近在做一個可視化拖拽搭建 H5 頁面的項目,整個項目分為 后臺配置應用 和 前臺渲染應用。其中一個業務場景是要求配置頁面的同時,前臺渲染應用能夠同步將配置渲染出來。換句話說,你在后臺配置這個頁面背景色是紅色,那么前臺應用無須刷新頁面等操作背景色就自動變為紅色。
這個需求的核心是保持兩個前端應用的數據實時同步,換句話說也就是兩個前端應用之間的通信。
乍一看這個需求,第一時間想到的是 iframe ,將應用2內嵌到應用1中,然后調用 window.postMessage() 進行數據通信。但是使用 iframe 存在兼容性問題,坑太多。另外對于不存在嵌套關系的兩個應用來說,強行進行嵌套會讓后續開發者無法理解。
到了這里,我淺薄的知識量不知道還有什么其他方法,選擇了直接看答案!之前開發的同事選擇了 WebSocket 實現前后臺之間的數據同步。彷佛一道閃電,擊碎了桎梏,劈開了新世界的大門。(是我知識積累太淺薄了,嗚嗚嗚)。
二、WebSocket是什么
WebSocket 這個關鍵詞,我在平時的工作和學習中已經聽過了很多次了,但是對于它的認知還是停留在淺顯的表面。所以很有必要重新系統的認識一下它!首先需要弄清楚的就是 WebSocket 是什么。
WebSocket 是一種網絡通信協議,和 HTTP 同處于應用層。它的出現解決了 HTTP 的一個缺陷:服務器只能被動發送數據給客戶端,不能進行主動推送。WebSocket 使得客戶端和服務器之間的數據交換變得更加簡單,允許服務端主動向客戶端推送數據。
在WebSocket API中,瀏覽器和服務器只需要完成一次握手,兩者之間就可以建立持久性的連接,并進行雙向數據傳輸。這對于需要連續數據交換的服務,例如網絡游戲,實時交易系統等,WebSocket 尤其有用。
這個特點別看簡單,但是解決了很多復雜的業務難題,比如刷微博。我們都知道刷微博是一個沒有盡頭的事情,因為每當你刷完當前的東西,頁面 header 區域又出現“你的關注博主有n條新微博”的提示。而且即使你在刷微博的過程中沒有刷新頁面,這個未讀微博的提醒也會出現。這說明了微博服務器在沒有收到客戶端請求時,主動向客戶端推送了一些數據。
下面我們解放一下我們的大腦,做一個假設。如果 WebSocket 不存在的話,我們如果使用 HTTP 實現微博的這個需求呢?
如果使用 HTTP 實現這個需求,想要做到及時獲取到服務器上的新數據,無非就是輪詢。但是不管是普通輪詢還是變種的長輪詢(Long Polling),都加重了服務器資源的消耗。因為服務器需要在一定時間內持續地處理客戶端的 http 請求。
當數據頻繁的時候,這種方式會拖垮服務器,造成后端應用的崩潰,所以不適合作為解決方案。
這個時候再讓我們的目光回到 WebSokcet 的身上,它就可以很好解決這個問題。WebSocket 允許服務端主動向客戶端推送消息,并且沒有同源限制。
三、如何使用 WebSocket
上面介紹了 WebSocket 是什么,以及它的應用場景:服務器主動推送消息。那么下面需要介紹一下 WebSokcet 的使用。
要打開一個 WebSocket 連接,我們需要在 url 中使用特殊的協議 ws 創建 new WebSocket:
- let socket = new WebSocket("ws://javascript.info");
同樣也有一個加密的 wss:// 協議。類似于 WebSocket 中的 HTTPS。
一旦 socket 被建立,我們就應該監聽 socket 上的事件。一共有 4 個事件:
- open — 連接已建立
- message — 接收到數據
- error — WebSocket 錯誤
- close — 連接已關閉
……如果我們想發送一些東西,那么可以使用 socket.send(data),這是一個示例:
- let socket = new WebSocket("wss://javascript.info/article/websocket/demo/hello");
- socket.onopen = function(e) {
- alert("[open] Connection established");
- alert("Sending to server");
- socket.send("My name is John");
- };
- socket.onmessage = function(event) {
- alert(`[message] Data received from server: ${event.data}`);
- };
- socket.onclose = function(event) {
- if (event.wasClean) {
- alert(`[close] Connection closed cleanly, code=${event.code} reason=${event.reason}`);
- } else {
- // 例如服務器進程被殺死或網絡中斷
- // 在這種情況下,event.code 通常為 1006
- alert('[close] Connection died');
- }
- };
- socket.onerror = function(error) {
- alert(`[error] ${error.message}`);
- };
出于演示目的,在上面的示例中,運行著一個用 Node.js 寫的小型服務器 server.js(https://zh.javascript.info/article/websocket/demo/server.js)。它響應為 “Hello from server, John”,然后等待 5 秒,關閉連接。
所以你看到的事件順序為:open → message → close。
這就是 WebSocket,我們已經可以使用 WebSocket 通信了。很簡單,不是嗎?
四、前后臺通訊
1. 應用角色模型
當我們需要在前臺應用和后臺應用之間進行數據通訊的時候,我們首先需要知道它們兩個的角色是什么。
最開始我想當然認為一個是「服務端」,另一個是「客戶端」。那么如何確定哪個是服務端呢?另外如果多個應用需要保持數據同步呢?這些問題讓我需要重新定位它們的角色。
從更加通用的角度來思考,我們需要一個獨立的進程充當服務端的角色,其余的前端應用作為客戶端。這樣的話無論客戶端的數量多少,都可以通過這個服務端為中介來進行應用間的數據同步。架構圖如下:
從上圖我們可以知道,socket 進程需要在啟動其中一個客戶端應用時一起啟動。然后其余的客戶端應用都與這個 socket 進程建立連接,并進行數據的交換。
2. 新的問題:如何啟動一個 socket 進程
在知道了架構邏輯之后,新的問題出現了。我們從 socket 示例知道建立一個 socket 連接,需要一個 ws/wss 協議開頭的 url 字符串。這個字符串需要從哪里得到呢?
我們首先回歸到問題的本質,如何建立一個 socket 連接?
- let socket = new WebSocket("ws://javascript.info");
上面的這行代碼就是最簡單的建立 socket 連接的方式。但這行代碼背后存在了一個隱藏的角色:服務端。
那么我們需要怎么才能啟動一個 socket 服務,并且客戶端與這個后端服務建立連接呢?這個問題冒出來了之后,我第一時間想找找有沒有建好的輪子。果然已經有前輩封裝好了包:socket.io(服務端)和 socket.io.client(客戶端)。
3. 服務端代碼
- // ws/index.js
- const server = require('http').createServer()
- const io = require('socket.io')(server)
- // 綁定事件
- io.on('connect', socket => {
- console.log('服務端建立連接成功!')
- socket.on('disconnet', () => {
- console.log('服務端監測到已斷開連接')
- })
- socket.on('transportData', (data) => {
- console.log('服務端監測到數據傳輸', data)
- io.sockets.emit('updateData', data)
- console.log('服務端emit事件updateData')
- })
- })
- // 啟動服務器程序進行監聽
- server.listen(8020, () => {
- console.log('==== socket server is running at 1234 port ====')
- })
以上就是服務端 socket 程序的簡單代碼,要注意的是這里我們使用的是 Socket.io。Socket.io 將 Websocket 和輪詢(Polling)機制以及其它的實時通信方式封裝成了通用的接口,并且在服務端實現了這些實時機制的相應代碼。也就是說,Websocket 僅僅是 Socket.io實現實時通信的一個子集。它很好的解決了不同瀏覽器的兼容性問題,我們可以將更多精力放在業務邏輯上。
這段代碼的核心就是監聽(on)和拋出(emit)事件。它 emit 出的事件會被連接的各個客戶端監聽到的,前提是每個客戶端都做了事件監聽。
4. 客戶端代碼
- // client1.vue
- <script>
- import io from 'socket.io-client'
- export default {
- ...
- mounted () {
- const socket = io('http://127.0.0.1:8020')
- socket.on('connect', () => {
- console.log('client1建立連接成功')
- console.log('client1傳輸數據')
- socket.emit('transportData', {name: 'xuhx'})
- })
- socket.on('updateData', (data) => {
- console.log('client1監聽到數據更新', data)
- })
- }
- }
- </script>
上面這段代碼是客戶端1的代碼,主要做的是與 socket 服務端建立連接,然后監聽和拋出相應的事件。
- // client2.vue
- <script>
- import io from 'socket.io-client'
- export default {
- ...
- mounted () {
- const socket = io('http://127.0.0.1:8020')
- socket.on('connect', () => {
- console.log('client2建立連接成功')
- })
- socket.on('updateData', (data) => {
- console.log('client2監聽到數據更新', data)
- })
- }
- }
- </script>
當 socket 進程拋出了 updateData 事件后,client1 與 client2 都可以監聽到了這個事件,并且拿到了傳遞的數據。
那么通過上面的數據傳輸圖,我們就可以知道客戶端可以通過 emit 自定義事件,經過 socket 中轉然后被其他客戶端監聽到事件,從而保持客戶端應用之間數據的同步。
五、其他通訊方式
沒有哪種解決方案是十全十美的,都是更合適某種業務場景。針對前端不同的業務場景,需要采用不同的解決方案來實現需求。那么除了 WebSocket 之外,還有哪些方案可以用于前端應用之間通訊呢?下面我結合實際開發工作列舉一下。
那么首先需要對業務場景進行分類:前端跨頁面之間通訊是否是同源頁面。這個分類方式很好理解,就是兩個通訊的頁面是不是屬于同一個前端項目。同源頁面的通訊有很多方式,我們主要解決非同源頁面之間的通信。
1. Cookie
如果應用 A 和應用 B 的根域名相同,只不過子級域名不同,例如:tieba.baidu.com 和 waimai.baidu.com。那么如何在這兩個應用頁面之間進行數據的傳輸呢?有些同學會想到 sessionStorage 和 LocalStorage,但是它們的存儲的基礎是同源的。應用 A 無法訪問到應用 B 下的 sessionStorage的。但是對應的 Cookie 就能滿足我們的需求,那是因為 Cookie 可以設置存儲的 Domain。如果將其 domain 設為根域名,那么應用 A 就可以訪問應用 B 下的 Cookie 數據。
Cookie 這種解決方案不適合存儲大量數據,因為每次請求都會攜帶 cookie,容易造成帶寬資源的浪費。它很合適某種狀態的傳遞,例如用戶在應用 A 中是否領取了會員卡,應用 B 通過 Cookie 讀取領取狀態進行對應業務邏輯的處理。
2. iframe + window.postMessage()
當應用 A 和應用 B 的根域名相同時,我們可以采用 Cookie 的方式。然而有時候,我們有兩個不同域名的產品線,也希望它們下面的所有頁面之間能無障礙地通信。那該怎么辦呢?
要實現該功能,可以使用一個用戶不可見的 iframe 作為“橋”。由于 iframe 與父頁面間可以通過指定origin來忽略同源限制,因此可以在頁面中嵌入一個 iframe (例如:http://sample.com/bridge.html),而這些 iframe 由于使用的是一個 url,因此屬于同源頁面。然后使用 window.postMessage() 進行外層應用和內嵌應用之間的通信。
3. Server Sent Events
Server-Sent Events 規范描述了一個內建的類 EventSource,它能保持與服務器的連接,并允許從中接收事件。與 WebSocket 類似,其連接是持久的。
但是兩者之間有幾個重要的區別:
WebSocket | EventSource |
---|---|
雙向:客戶端和服務端都能交換消息 | 單向:僅服務端能發送消息 |
二進制和文本數據 | 僅文本數據 |
WebSocket 協議 | 常規 HTTP 協議 |
與 WebSocket 相比,EventSource 是與服務器通信的一種不那么強大的方式。
我們為什么要使用它?
主要原因:簡單。在很多應用中,WebSocket 有點大材小用。
我們需要從服務器接收一個數據流:可能是聊天消息或者市場價格等。這正是 EventSource 所擅長的。它還支持自動重新連接,而在 WebSocket 中這個功能需要我們手動實現。此外,它是一個普通的舊的 HTTP,不是一個新協議。
4. 微前端
微前端作為前端的一個前沿技術,我是聽說了很久但是一直沒有機會去使用。并且聽同事的介紹,現在微前端的使用還有不少坑。但是它確實是一個應用間通信、協作的探索方向之一。具體的使用和思路就需要大家自行探索了啊。
上面我列舉了3 個其他非同源頁面之間的通訊方式。實際上在我搜尋相關資料的時候,發現了同行梳理的很多方式。但我發現很難記住這么多的方式,其中一些方式甚至基本不可能使用,徒增記憶的負擔。所以我在這里僅列舉了主流好用的方式,一招鮮吃遍天~~
六、小結
兩個前端工程之間需要進行數據通訊,可以采取 WebSocket 的方式。項目單獨啟動一個進程,作為 socket 服務端。需要數據通訊的前端應用在頁面中與 socket 進程建立連接,通過 emit 自定義事件和監聽相應的事件來實現兩個不同應用之間的數據同步。本篇文章寫的其實是 WebSocket 的基本使用,結合到具體的業務開發中總結了一下。希望能夠對遇到相關需求的朋友有所幫助。
許浩星,微醫前端技術部前端工程師。一個認為人生的樂趣一半在靜,一半在動的有志青年!