エンジニアを目指す日常ブログ

日々勉強したことのメモ。独学ですので間違っていたらコメント等で教えてください。

React+Node.jsとsocket.ioでチャットアプリの基礎を作成する

新しいエントリを履歴スタックにプッシュします React Router: Declarative Routing for React.js

https://tomiko0404.hatenablog.com/entry/%E2%96%A0

はじめに

今回の記事の目的

ReactとExpressを利用して簡易チャットアプリを作成した。 実施した内容をメモ/紹介する。 (躓いた点は別記事に切り出す。)

仕様

  • メッセージ入力エリアと送信ボタンがある。
  • 送信ボタンを押すとメッセージ入力エリアに入力した値を全員に送信する。
  • これまでに全員が送信したメッセージの一覧が表示されている。

アプリのイメージ。録画の都合上、1画面分しか撮れていませんが2つのブラウザを開いて交互に操作している。

前提

基本的には以下ここまでにやったことを辿って環境構築+サーバ作成を実施している。

利用している技術・モジュールは以下。

  • Node.js
  • Express
  • React(create-react-app)×Typescript
  • socket.io
  • socket.io-client

ここまでにやったこと

Step1 dockerを利用して画面サーバ用コンテナとWebサーバ用コンテナを起動した。

tomiko0404.hatenablog.com

Step2 Node.jsでWebHTTPサーバを作成して動かした。

tomiko0404.hatenablog.com

Step3 Reactで画面サーバを作成した。

tomiko0404.hatenablog.com

Step4 CORSを勉強して、画面サーバとWebサーバを接続できるようにした。

tomiko0404.hatenablog.com

Step5 socket.ioを利用して、画面サーバとWebサーバで相互通信できるようにした。

tomiko0404.hatenablog.com

チャットアプリ画面の見た目作成

画面の見た目を作成する。

Chakra UIの利用準備

コンポーネントライブラリのchakra-uiを利用する。 コンポーネントライブラリを使わずにHTMLを書いても問題ないが、簡単にそれなりの見た目が作れるため今回利用する。

Chakra UIのインストール

モジュールをインストールする。

$ yarn add @chakra-ui/react @emotion/react@^11 @emotion/styled@^11 framer-motion@^4

公式ページ:Getting Started - Chakra UI

Providerの利用を宣言する

  • Chakraproviderをインポートする。
  • App.tsxから返却する値をすべて<ChakraProvider>で囲む。
import React from "react";
import "./App.css";
import { io } from "socket.io-client";
import { ChakraProvider } from "@chakra-ui/react";

function App() {
    // (過去記事参照)Webサーバとの接続を確立
    const socket = io("http://localhost:3000");

    //(過去記事参照) サーバに接続できた場合のイベント処理を定義する(過去記事参照)
    socket.on("connect", () => {
        console.log(`socket.connectを出力`);
        console.log(socket.connect()); // サーバに接続できたかどうかを表示
    });

    return (
        <ChakraProvider>
            <>
                <h1>画面サーバ</h1>
            </>
        </ChakraProvider>
    );
}
export default App;

見た目の作成

見た目を以下のように作成する。

`App.tsx(import文/return文の中身のみ記載)

import { ChakraProvider, Input, Button } from "@chakra-ui/react";
    return (
        <ChakraProvider>
            <>
                <ul>
                    <li>メッセージ1(仮)</li>
                    <li>メッセージ2(仮)</li>
                    <li>メッセージ3(仮)</li>
                </ul>
                <Input placeholder="なにか文字を入力してください" />
                <Button>送る</Button>
            </>
        </ChakraProvider>
    );

見た目が完成した。

チャットアプリの見た目
チャットアプリの見た目

クライアント側の処理を作成する

App.tsxへの記載内容

クライアントとサーバの相互通信を設定した際に記載したコードをベースに書き足す。

App.tsx は最終的に以下のようになる。

import React, { useState, useEffect } from "react";
import "./App.css";
import { io } from "socket.io-client";
import { ChakraProvider } from "@chakra-ui/react";
import { Input, Button } from "@chakra-ui/react";

// socketを接続する。引数にはWebサーバ側が待ち受けるドメインを指定する。
const socket = io("http://localhost:3000");
socket.on("connect", () => {
    console.log(`socket.connectの中身:`);
    console.log(socket.connect());
});

function App() {
    // メッセージリスト(全員が送ったメッセージの一覧)をStateとして定義
    const [messageList, setMessageList] = useState<string[]>([]);
    // インプットエリアに入力するメッセージをStateとして定義
    const [message, setMessage] = useState("");

    // サーバから"chat"イベントが送信されたときの処理
    // messageListに、Webサーバから受け取ったメッセージを追加する。
    useEffect(() => {
        socket.on("chat", (msg) => {
            setMessageList((messageList) => [...messageList, msg]);
        });
    }, []);

    // インプットエリアの文字が変更されたときの処理。
    // messageの値を都度変更する。
    const onChangeMessage = (e: React.ChangeEvent<HTMLInputElement>) => {
        setMessage(e.target.value);
    };

    // 「送る」ボタンを押したときの処理。サーバにmessageを送信する。
    const onClickSend = (
        e: React.MouseEvent<HTMLButtonElement, MouseEvent>
    ) => {
        e.preventDefault();
        socket.emit("chat message", message);
        setMessage("");
    };


    return (
        <ChakraProvider>
            <>
                <ul>
                    {messageList.map((data, index) => {
                        return <li key={index}>{data}</li>;
                    })}
                </ul>
                <Input
                    placeholder="なにか文字を入力してください"
                    value={message}
                    onChange={onChangeMessage}
                />
                <Button onClick={onClickSend}>送る</Button>
            </>
        </ChakraProvider>
    );
}

export default App;

解説

socket接続部分

Webサーバとの接続を確立する、const socket = io("http://localhost:3000");以下の文については、画面がレンダリングされるたびに実行される必要はなく、最初の1回のみ実行されればよい。

そのため、App関数の外に出しておいた。

インプットエリアに入力した文字をサーバに送る機能

送るボタンの設定

<Button>コンポーネントのonClickイベントにonClickSend関数を設定する。

<Button onClick={onClickSend}>送る</Button>
送るボタンをクリックしたときに起動する関数

onClickSend関数の処理内容は以下のようになっている。

// 「送る」ボタンを押したときの処理。サーバにmessageを送信する。
    const onClickSend = (
        e: React.MouseEvent<HTMLButtonElement, MouseEvent>
    ) => {
        e.preventDefault();
        socket.emit("chat message", message);
        setMessage("");
    };
引数

引数になっている(e)は、一般的なJavaScriptと同じでbuttonコンポーネントから受け取るイベントである。TypeScriptではeの型も指定する必要があるため、以下の記事を参考に設定している。

any型で諦めない React.EventCallback - Qiita

e.preventDefault()

次にe.preventDefault();は、「クリックなどのイベントに対し、ブラウザが勝手に規定している処理を実行させない」という指定である。

こちらの記事がわかりやすかった。
JavaScriptのpreventDefault()って難しくない?preventDefault()を使うための前提知識 - Qiita

socket.emit

一番重要なのがsocket.emitである。

 socket.emit([イベント名], 引数);

の構文でサーバに対してイベントを発行できる。

公式ページ:Client API | Socket.IO

socket.emit("chat message", message);

今回は、chat message というイベント名で、messageに入っている値をWebサーバ側に送信する。

messageを管理するstate

ここで、messageはインプットエリアに表示する値であり、「送る」ボタンを押したときにWebサーバ側に送る値である。この値は画面がレンダリングされるたびに初期化されては困るので、state管理する。

    // インプットエリアに入力するメッセージをStateとして定義
    const [message, setMessage] = useState("");

初期値は空文字""としている。

useStateのインポートも必要なので注意する。

import React, { useState} from "react";

インプットエリアの表示制御

インプットエリアの値が変更されたらその値を新たにmessageステートに格納し、更にインプットエリアには常にmessageの値を表示する(一般的な入力フォームの書き方)。

                <Input
                    placeholder="なにか文字を入力してください"
                    value={message}
                    onChange={onChangeMessage}
                />
    // インプットエリアの文字が変更されたときの処理。
    // messageの値を都度変更する。
    const onChangeMessage = (e: React.ChangeEvent<HTMLInputElement>) => {
        setMessage(e.target.value);
    };

メッセージの一覧を表示する機能

メッセージリストの管理

メッセージリストもStateとして管理する。

    // メッセージリスト(全員が送ったメッセージの一覧)をStateとして定義
    const [messageList, setMessageList] = useState<string[]>([]);

メッセージの一覧なので、初期値は空配列[]となる。型定義は<string[]>と書くことでstring型の配列を指定する。

サーバから「誰かがメッセージを送信した」イベントを受け取ったときの処理
    // サーバから"chat"イベントが送信されたときの処理
    // messageListに、Webサーバから受け取ったメッセージを追加する。
    useEffect(() => {
        socket.on("chat", (msg) => {
            setMessageList((messageList) => [...messageList, msg]);
        });
    }, []);
socket.on

重要なのはsocket.on関数である。

socket.on(イベント名, 関数)

の構文で、Webサーバから指定のイベント名のイベント受け取ったら、関数で定義した処理を実行することができる。今回は、誰かがメッセージを送信したら全員がWebサーバからchatイベントを受け取る。

公式ページ: Client API | Socket.IO

イベントを受け取ったときの処理
 setMessageList((messageList) => [...messageList, msg]);

useStateで定義されたsetState関数(ここではsetMessageList)には、値または関数を設定することができる。

これまでのmessageListの値(イベントを受け取る前までのメッセージ一覧)に対し、Webサーバから受け取った新規の発言をmessageListの一番後ろに追加する。

useEffectについて

「イベントを受け取ったときに実行する関数」をレンダリングのたびに定義していると、レンダリング量が増えすぎてしまった。結果、画面表示が非常に低速になってしまった。

そこで、useEffectを利用することで、レンダリングの数を抑えることにした。

実際、setState関数に設定する値の中に、そのstateを含む場合(今回の場合、setMessageListに設定する値に、messageListを利用する)は処理が難しくなるようで、かなり苦労した部分なので、別記事にて解説する。

useStateの使い方で苦しんだ別記事:
【React/ useState】setState関数の書き方が下手で大量レンダリングが起こった話 - エンジニアを目指す日常ブログ

メッセージリストの表示

こちらはメッセージリストから、'map'関数を利用して1個ずつ要素を取り出し<li>要素に入れているだけ。keyはインデックス(配列の何個目の要素かを表す)にしてしまったが、実際は「名前+送信時間」などにすべきと考えている。

                <ul>
                    {messageList.map((data, index) => {
                        return <li key={index}>{data}</li>;
                    })}
                </ul>

サーバ側の処理の作成

クライアントとサーバの相互通信を設定した際に記載したコードをベースに書き足す。

あるブラウザからメッセージを受け取ったら、全員に送信する機能

追記するのは以下の部分だけである。

    socket.on("chat message", (msg) => {
        io.emit("chat", msg);
    });

クライアント側のsocket.emit("chat message", message);で指定されたchat messageイベントを受けて、クライアント全体にchatイベントを送信する。

公式ドキュメント: Server API | Socket.IO

index.jsの記載内容

最終的にサーバ側のindex.jsの記載は以下のようになる。

const express = require("express");
const portNumber = 3000; // ポート番号
const app = express(); // Expressを利用したサーバ
const server = require("http").createServer(app); // Expressを用いないserverも必要

app.get("/", (req, res) => {
    res.status(200).send("OK!");
});

// サーバーオブジェクトsocketioを作成する
const { Server } = require("socket.io");
const io = new Server(server, {
    cors: {
        // Server作成時の引数にCORSオプションを追加する
        origin: "*",
        methods: ["GET", "POST"],
    },
});

io.on("connection", (socket) => {
    // ブラウザから接続されたときの処理
    console.log("a user connected");

    // ブラウザが切断したときの処理
    socket.on("disconnect", () => {
        console.log("user disconnected");
    });

    socket.on("chat message", (msg) => {
        io.emit("chat", msg);
    });
});

// serverをPORT3000で待ち受ける。app.listenだとNG。
server.listen(portNumber);
console.log(`Web server is on. PortNumber is ${portNumber}.`);

アプリ動作確認

ブラウザの接続

2つのブラウザを画面サーバに接続すると、Webサーバ側とsocket接続が2件確認できた。

Webサーバ側のログ
Webサーバ側のログ

メッセージの送信

左側のブラウザにメッセージを入れて送信すると、2つのブラウザにメッセージが表示された。

ブラウザに表示されるメッセージ
ブラウザに表示されるメッセージ

おわりに

socket.ioを利用してクライアント(ブラウザ)とWebサーバの接続を維持して、イベントを送信/受信できるようになった。

関連記事

useStateの書き方で苦しんだ部分

tomiko0404.hatenablog.com

つづき

入室機能とユーザ名設定機能を実装する

チャットアプリの見た目を修正する