React | 在 React 中使用 WebSocket - feat. Socket.io 基本教學
前言
這次的標題有點複雜和騙人,文章內主要是在 React
中搭配 Socket.io
做使用,而 Socket.io
是一個現成的 WebSocket
套件,儘管它不是真正的 webSocket
協定,但 socket.io
還是實現了 webSocket
及增加許多方便的功能。
如果還不了解 WebSocket
的讀者,可以先參考「JavaScript | WebSocket 讓前後端沒有距離」文章內解說的原生使用方式,當然也能直接選擇學習 Socket.io
就好,但文中的例子都會以 React
的Function Component
配合 Hooks
撰寫,就不再另外列舉在一般 HTML
的用法。
另外, Server 方面會以 Node.js
建置,還沒碰過 Node.js
的話也能參考「[筆記][node.js]第一次建置node.js開發環境和安裝npm就上手!」建置環境,有任何問題再麻煩留言告訴我,謝謝!
Socket.io
socket.io
分成兩個主要的部分,一個是負責在 Server 端啟動 WebSocket
服務的 socket.io
和在 Client 端做連結處理的 socket.io-client
,因此使用時便不需要再尋找 Server 及 Client 各需要哪些套件,一切都交給 socket.io
就行了。
基本溝通方式解說
下方會先從建立 Server 的 socket.io
開始敘述,再解釋兩邊的互動方式。
Server
以 npm init
建立一個專案後,透過下列指令安裝 express
和 socket.io
:
npm install express
npm install socket.io
安裝完後在專案內建立一個 server.js
,用來做專案的進入點運行 Server:
const express = require('express')
const app = express()
//將 express 放進 http 中開啟 Server 的 3000 port ,正確開啟後會在 console 中印出訊息
const server = require('http').Server(app)
.listen(3000,()=>{console.log('open server!')})
//將啟動的 Server 送給 socket.io 處理
const io = require('socket.io')(server)
/*上方為此寫法的簡寫:
const socket = require('socket.io')
const io = socket(server)
*/
//監聽 Server 連線後的所有事件,並捕捉事件 socket 執行
io.on('connection', socket => {
//經過連線後在 console 中印出訊息
console.log('success connect!')
//監聽透過 connection 傳進來的事件
socket.on('getMessage', message => {
//回傳 message 給發送訊息的 Client
socket.emit('getMessage', message)
})
})
server.js
內有幾點需要注意的地方:
- 如果是使用
socket.io
套件,因為它不是真正的webSocket
協定,所以還是得使用http
啟動 Server ,再把 Server 物件送給socket.io
,處理過後會得到一個物件io
,可以用它的on
監聽開啟連線後的設定。 - 如果
io
監聽到有訊息從 Client 傳到 Server 時,會將捕捉到的事件內容socket
物件傳至connection
後的function
中。 - 被傳入
connection
中的socket
也可以自訂監聽message
的類型,例如上方的getMessage
就是筆者自行設定的類型名稱,socket.io
只會觸發對應的監聽Function
。 socket.emit
為回覆訊息給 Client 的Function
,它第一個參數為訊息類型,這個和 Server 的監聽名稱一樣,在 Client 端也只有設定監聽類型為getMessage
的Function
才會觸發,而第二個為訊息內容。
如果上方有不了解的部分,可以先大略看過,配合 Client 的運作一起看會更清楚。
完成 Server 的部分後可以先輸入以下指令運行:
node server.js //server.js 為檔名
如果程式沒有問題,那應該會在運行後出現 open server!
:
Client
Client 端另外開一個專案, React
的環境就不再另外講解,可以參考「webpack&React開發環境篇(1)」和「webpack&React開發環境篇(2)」上下兩篇搭建,那除了 React
的部分還需要下載 socket.io-client
:
npm install socket.io-client
下載後,就能開始實做一個連結 webSocket
並能夠發送及接收訊息的 component
:
import React, { useState, useEffect } from 'react'
import ReactDom from 'react-dom'
import webSocket from 'socket.io-client'
const Main = () => {
const [ws,setWs] = useState(null)
const connectWebSocket = () => {
//開啟
setWs(webSocket('http://localhost:3000'))
}
useEffect(()=>{
if(ws){
//連線成功在 console 中打印訊息
console.log('success connect!')
//設定監聽
initWebSocket()
}
},[ws])
const initWebSocket = () => {
//對 getMessage 設定監聽,如果 server 有透過 getMessage 傳送訊息,將會在此被捕捉
ws.on('getMessage', message => {
console.log(message)
})
}
const sendMessage = () => {
//以 emit 送訊息,並以 getMessage 為名稱送給 server 捕捉
ws.emit('getMessage', '只回傳給發送訊息的 client')
}
return(
<div>
<input type='button' value='連線' onClick={connectWebSocket} />
<input type='button' value='送出訊息' onClick={sendMessage} />
</div>
)
}
ReactDom.render(<Main />, document.getElementById('root'))
關於此 component
的架構可以分為幾下幾塊:
- 使用
useState
在component
內建立一個state
ws
,當使用者點擊「連線」按鈕時觸發connectWebSocket
,觸發後透過從socket.io-client
套件中import
的webSocket
連線至剛剛執行server.js
的http://localhost:3000
,最後webSocket
連線成功後會藉由setWs
將WebSocket
物件寫到ws
。 - 連線後
component
的state
ws
就會改變,生命週期useEffect
也會被觸發,觸發時先判斷ws
內是否真的有值,因為useEffect
在組件第一次render
時就會先執行,但那時候ws
還沒有連線所以是null
,這時候還去執行initWebSocket
就會發生錯誤,所以如果ws
還是初始值null
就不執行接下來的initWebSocket
。 - 在
initWebSocket
中替ws
增加了監聽getMessage
的訊息,當 Server 有發送以getMessage
為名稱的訊息,就會在這裡被捕捉到,並在第二個參數中接收訊息,將訊息內容打印到console
中。 - 當我按下「送出訊息」按鈕時,
sendMessage
中會以ws.emit
送出訊息,而送出的除了訊息內容外,還有辨別他的名稱getMessage
。
在第一點中需要注意的是 ws://localhost:3000
是無法正確與 Server 做connection
的,因為 socket.io
本身是 http
協定而不是 WebSocket
,如果要看更多協議可以參考 socket.io
的協議。
看到這裡,就能夠將剛剛 Server 的程式碼拿來與 Client 一起講解兩邊溝通的流程了:
- 由 Client 以
ws.emit('getMessage','訊息內容')
將訊息送到 Server。 - Server 藉由
socket.on('getMessage',message=>{/*執行動作*/})
捕捉到訊息。 - Server 處理完訊息內容後再透過
socket.emit('getMessage','訊息內容')
將訊息傳給 Client 。 - 最後 Client 會從
ws.on('getMessage',message=>{/*執行動作*/})
的監聽取得 Server 傳來的訊息。
上面的過程都是以 getMessage
為該訊息取名字去送出及監聽捕捉,因此就不會像原生的 WebSocket
,如果要為訊息做分類就還得多做判斷,當然用 socket.io
的方便不只有這樣子而已,先看看上方程式碼的執行畫面,再講解更多有趣的地方:
進階用法 - 群發
就上方的例子而言,眼尖的讀者應該有發現,筆者在使用 socket.emit()
將訊息從 Server 傳給 Client 的時候,訊息內容為「只回傳給發送訊息的 client 」,這也代表這段訊息不會同時讓其他連接著該 WebSocket
的 Client 收到,那該怎麼做才能讓所有 Client 都收到訊息呢?
其實 socket.io
針對要發送的對象,提供了其他方式:
/*只回傳給發送訊息的 client*/
socket.emit('getMessage', message)
/*回傳給所有連結著的 client*/
io.sockets.emit('getMessageAll', message)
/*回傳給除了發送者外所有連結著的 client*/
socket.broadcast.emit('getMessageLess', message)
每種方式也都會固定帶著一個名稱被 Client 端監聽接收,如果沒有給名稱就不會被監聽給捕捉到,以下實作呈現這三種發送訊息的方式:
Server
在 Server 端要因應不同的名稱來判斷回傳的方式,因此為幾個不同的名稱做加入監聽:
io.on('connection', socket => {
/*只回傳給發送訊息的 client*/
socket.on('getMessage', message => {
socket.emit('getMessage', message)
})
/*回傳給所有連結著的 client*/
socket.on('getMessageAll', message => {
io.sockets.emit('getMessageAll', message)
})
/*回傳給除了發送者外所有連結著的 client*/
socket.on('getMessageLess', message => {
socket.broadcast.emit('getMessageLess', message)
})
})
Client
在 Client 中,下方更改了 sendMessage
的內容,在呼叫的時候傳入 name
作為發送訊息的名稱,並在 initWebSocket
中多為幾個名稱設定監聽,以捕獲 Server 發送的訊息,最後在 return
裡添加了幾個按鈕分別發送不同名稱的訊息:
const initWebSocket = () => {
//對 getMessage 設定監聽,如果 server 有透過 getMessage 傳送訊息,將會在此被捕捉
ws.on('getMessage', message => {
console.log(message)
})
ws.on('getMessageAll', message => {
console.log(message)
})
ws.on('getMessageLess', message => {
console.log(message)
})
}
const sendMessage = (name) => {
ws.emit(name, '收到訊息囉!')
}
return (
<div>
<input type='button' value='連線' onClick={connectWebSocket} />
<input type='button' value='送出訊息,只有自己收到回傳' onClick={() => { sendMessage('getMessage') }} />
<input type='button' value='送出訊息,讓所有人收到回傳' onClick={() => { sendMessage('getMessageAll') }} />
<input type='button' value='送出訊息,除了自己外所有人收到回傳' onClick={() => { sendMessage('getMessageLess') }} />
</div>
)
調整完兩邊的程式後,執行結果如下:
進階用法 - 分組 ( room )
分組的意思就是為 Client 設定聊天室,在遊戲裡就像「頻道」、「分流」的概念, socket.io
能讓 Server 可以針對某個 room
傳送訊息,也可以自由地將 Client 加入或移除某個 room
,而在一般沒有設定 room 的狀態下,都會為每個 Client 預設一個 id 作為 room
。
Client
這次從 Client 開始,在程式中加上一個下拉選單讓使用者選擇房間,選擇時會送出房間名稱給 Server ,並且為 webSocket
增加一個監聽,以接收從 Server 傳送過來的新訊息:
const initWebSocket = () => {
/*其餘程式碼省略*/
//增加監聽
ws.on('addRoom', message => {
console.log(message)
})
}
//選擇聊天室時觸發,如果有選擇那就將房間 id 送給 Server
const changeRoom = (event) => {
let room = event.target.value
if(room !== ''){
ws.emit('addRoom', room)
}
}
return (
<div>
//增加下拉選單選擇房間
<select onChange={changeRoom}>
<option value=''>請選擇房間</option>
<option value='room1'>房間一</option>
<option value='room2'>房間二</option>
</select>
{/*其餘程式碼省略*/}
</div>
)
Server
當 Server 端接收到訊息時,可以使用 socket.join
將房間 id 加到該 Client 的 room
物件中,並發送訊息給相同房間 id 的 Client ,而發送的訊息又有分成兩種:
io.on('connection', socket => {
/*其餘程式碼省略*/
socket.on('addRoom', room => {
socket.join(room)
//(1)發送給在同一個 room 中除了自己外的 Client
socket.to(room).emit('addRoom', '已有新人加入聊天室!')
//(2)發送給在 room 中所有的 Client
io.sockets.in(room).emit('addRoom', '已加入聊天室!')
})
})
以下分別展示兩種傳遞的結果:
發送給在同一個 room
中除了自己外的 Client :
room
中除了自己外的 Client
發送給在 room 中所有的 Client :
需要注意的是如上方所說,一開始連線的時候 socket.io
會為每個 Client 預設 id 在 room
裡,所以取出 room
的時候不會只有 socket.join
進去的房間 id ,還會有預設的 id ,因此下方的程式將對 room
物件所有的 key
做迴圈,取出原本 id 以外的值就是另外 socket.join
的房間 id 了:
//取得 rooms 物件,包含了預設 id 及另外 join 的資料
const rooms = socket.rooms
//將值取出來,尋找預設 id 外的值就能取到 join 的 id
let room = Object.keys(rooms).find(room =>{
return room !== socket.id
})
另外再補充 socket.join
是非同步的事件,因此如果要在確定 join
後再執行某些事得這麼做:
socket.join(room,()=>{
//do something...
})
最後,當 Client 更換或離開聊天室時,得使用 socket.leave
移除在 socket.rooms
的 id :
socket.on('addRoom', room => {
//加入前檢查是否已有所在房間
const nowRoom = Object.keys(socket.rooms).find(room =>{
return room !== socket.id
})
//有的話要先離開
if(nowRoom){
socket.leave(nowFoom)
}
//再加入新的
socket.join(room)
io.sockets.in(room).emit('addRoom', '已有新人加入聊天室!')
})
中斷連線
當 Client 要與 Server 中斷連線時,可以在 Client 端使用 .close()
這個函式,中斷後會觸發在 Server 端的 disconnect
這個名稱的事件,但是中斷後便無法再透過此連結送出訊息到其他 Client 通知「某使用者已離開」,因此下方先透過送出一個訊息到 Server ,等通知完其他 Client 後,再送訊息到提出中斷連線的 Client 執行 .close()
,以下實作:
Server
以 disConnection
監聽申請中斷的事件,再以 leaveRoom
通知 room
裡的所有 Client 及 disConnection
向提出中斷的 Client 送出訊息請它做 .close()
:
io.on('connection', socket => {
/*其餘程式碼省略*/
//送出中斷申請時先觸發此事件
socket.on('disConnection', message => {
const room = Object.keys(socket.rooms).find(room => {
return room !== socket.id
})
//先通知同一 room 的其他 Client
socket.to(room).emit('leaveRoom', `${message} 已離開聊天!`)
//再送訊息讓 Client 做 .close()
socket.emit('disConnection', '')
})
//中斷後觸發此監聽
socket.on('disconnect', () => {
console.log('disconnection')
})
})
Client
新增一個「斷線」的按鈕觸發 disConnectWebSocket
,讓它送訊息給 Server 告知要斷線, leaveRoom
及 disConnection
分別用來接收某個 Client 離開的通知及通知完後關閉連線:
const disConnectWebSocket = () =>{
//向 Server 送出申請中斷的訊息,讓它通知其他 Client
ws.emit('disConnection', 'XXX')
}
const initWebSocket = () => {
/*其餘程式碼省略*/
//以 leaveRoom 接收有使用者離開聊天的訊息
ws.on('leaveRoom', message => {
console.log(message)
})
// Server 通知完後再傳送 disConnection 通知關閉連線
ws.on('disConnection', () => {
ws.close()
})
}
return (
<div>
{/*其餘省略*/}
<input type='button' value='斷線' onClick={disConnectWebSocket} />
{/*其餘省略*/}
</div>
)
執行結果如下:
本文不知不覺就打了有點多,提到了 socket.io
的基本用法、群發、聊天室,這些都是在實務上都滿有機會會碰到的技術,也搭配了 React
做使用,希望這篇文章能夠讓各位對 socket.io
或搭配 React
時的使用方式有些概念。
如果文章中有任何問題,或是不理解的地方,都可以留言告訴我!謝謝大家!
參考文章