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