/React

React | 在 React 中使用 WebSocket - feat. Socket.io 基本教學

前言

這次的標題有點複雜和騙人,文章內主要是在 React 中搭配 Socket.io 做使用,而 Socket.io 是一個現成的 WebSocket 套件,儘管它不是真正的 webSocket 協定,但 socket.io 還是實現了 webSocket 及增加許多方便的功能。

如果還不了解 WebSocket 的讀者,可以先參考「JavaScript | WebSocket 讓前後端沒有距離」文章內解說的原生使用方式,當然也能直接選擇學習 Socket.io 就好,但文中的例子都會以 ReactFunction 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 建立一個專案後,透過下列指令安裝 expresssocket.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 內有幾點需要注意的地方:

  1. 如果是使用 socket.io 套件,因為它不是真正的 webSocket 協定,所以還是得使用 http 啟動 Server ,再把 Server 物件送給 socket.io ,處理過後會得到一個物件 io ,可以用它的 on 監聽開啟連線後的設定。
  2. 如果 io 監聽到有訊息從 Client 傳到 Server 時,會將捕捉到的事件內容 socket 物件傳至 connection 後的 function 中。
  3. 被傳入 connection 中的 socket 也可以自訂監聽 message 的類型,例如上方的 getMessage 就是筆者自行設定的類型名稱, socket.io 只會觸發對應的監聽 Function
  4. socket.emit 為回覆訊息給 Client 的 Function ,它第一個參數為訊息類型,這個和 Server 的監聽名稱一樣,在 Client 端也只有設定監聽類型為 getMessageFunction 才會觸發,而第二個為訊息內容。

如果上方有不了解的部分,可以先大略看過,配合 Client 的運作一起看會更清楚。

完成 Server 的部分後可以先輸入以下指令運行:

node server.js //server.js 為檔名

如果程式沒有問題,那應該會在運行後出現 open server!

在 3000 port 上執行 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 的架構可以分為幾下幾塊:

  1. 使用 useStatecomponent 內建立一個 state ws ,當使用者點擊「連線」按鈕時觸發 connectWebSocket ,觸發後透過從 socket.io-client 套件中 importwebSocket 連線至剛剛執行 server.jshttp://localhost:3000 ,最後 webSocket 連線成功後會藉由 setWsWebSocket 物件寫到 ws
  2. 連線後 componentstate ws 就會改變,生命週期 useEffect 也會被觸發,觸發時先判斷 ws 內是否真的有值,因為 useEffect 在組件第一次 render 時就會先執行,但那時候 ws 還沒有連線所以是 null ,這時候還去執行 initWebSocket 就會發生錯誤,所以如果 ws 還是初始值 null 就不執行接下來的 initWebSocket
  3. initWebSocket 中替 ws 增加了監聽 getMessage 的訊息,當 Server 有發送以 getMessage 為名稱的訊息,就會在這裡被捕捉到,並在第二個參數中接收訊息,將訊息內容打印到 console 中。
  4. 當我按下「送出訊息」按鈕時, sendMessage 中會以 ws.emit 送出訊息,而送出的除了訊息內容外,還有辨別他的名稱 getMessage

在第一點中需要注意的是 ws://localhost:3000 是無法正確與 Server 做connection 的,因為 socket.io 本身是 http 協定而不是 WebSocket ,如果要看更多協議可以參考 socket.io 的協議

看到這裡,就能夠將剛剛 Server 的程式碼拿來與 Client 一起講解兩邊溝通的流程了:

  1. 由 Client 以 ws.emit('getMessage','訊息內容') 將訊息送到 Server。
  2. Server 藉由 socket.on('getMessage',message=>{/*執行動作*/}) 捕捉到訊息。
  3. Server 處理完訊息內容後再透過 socket.emit('getMessage','訊息內容') 將訊息傳給 Client 。
  4. 最後 Client 會從 ws.on('getMessage',message=>{/*執行動作*/}) 的監聽取得 Server 傳來的訊息。

上面的過程都是以 getMessage 為該訊息取名字去送出及監聽捕捉,因此就不會像原生的 WebSocket ,如果要為訊息做分類就還得多做判斷,當然用 socket.io 的方便不只有這樣子而已,先看看上方程式碼的執行畫面,再講解更多有趣的地方:

Server 回傳從 Client 中收到的訊息

進階用法 - 群發

就上方的例子而言,眼尖的讀者應該有發現,筆者在使用 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 :

發送給在 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 告知要斷線, leaveRoomdisConnection 分別用來接收某個 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>
)

執行結果如下:

中斷連線後便無法再與 Server 溝通


本文不知不覺就打了有點多,提到了 socket.io 的基本用法、群發、聊天室,這些都是在實務上都滿有機會會碰到的技術,也搭配了 React 做使用,希望這篇文章能夠讓各位對 socket.io 或搭配 React 時的使用方式有些概念。

如果文章中有任何問題,或是不理解的地方,都可以留言告訴我!謝謝大家!

參考文章

  1. https://socket.io/docs/server-api/
  2. https://socket.io/docs/client-api/#socket-connected