React×TypeScript×Socket.ioで簡単なチャットアプリを作成してみた

## はじめに
WebSocketおよびSocket.IOの初学者向けの記事になります。
ReactとTypeScriptと、WebSocketによる双方向通信が簡単に記述できるSocket.IOを用いて、簡易的なアプリを実装しました。

### 公式サイト
https://socket.io/
>  It requires almost no basic prior knowledge of Node.JS or Socket.IO, so it’s ideal for users of all knowledge levels.

事前知識がなくてもできると公式にはありますが、知識としては知っておいても損はないです。本記事ではアプリの実装だけでなく、冒頭に事前知識の説明もしていきます。

## 目次

- [WebSocketとは？](#websocket%E3%81%A8%E3%81%AF)
  - [Socket.IOとは？](#socketio%E3%81%A8%E3%81%AF)
- [チャットアプリ作成](#%E3%83%81%E3%83%A3%E3%83%83%E3%83%88%E3%82%A2%E3%83%97%E3%83%AA%E4%BD%9C%E6%88%90)
    - [事前準備](#%E4%BA%8B%E5%89%8D%E6%BA%96%E5%82%99)
    - [環境構築](#%E7%92%B0%E5%A2%83%E6%A7%8B%E7%AF%89)
    - [サーバ構築](#%E3%82%B5%E3%83%BC%E3%83%90%E6%A7%8B%E7%AF%89)
    - [クライアント側との接続前準備](#%E3%82%AF%E3%83%A9%E3%82%A4%E3%82%A2%E3%83%B3%E3%83%88%E5%81%B4%E3%81%A8%E3%81%AE%E6%8E%A5%E7%B6%9A%E5%89%8D%E6%BA%96%E5%82%99)
    - [クライアントアプリの作成](#%E3%82%AF%E3%83%A9%E3%82%A4%E3%82%A2%E3%83%B3%E3%83%88%E3%82%A2%E3%83%97%E3%83%AA%E3%81%AE%E4%BD%9C%E6%88%90)
    - [アプリケーションの起動](#%E3%82%A2%E3%83%97%E3%83%AA%E3%82%B1%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3%E3%81%AE%E8%B5%B7%E5%8B%95)
- [最後に](#%E6%9C%80%E5%BE%8C%E3%81%AB)
- [参考資料](t#%E5%8F%82%E8%80%83%E8%B3%87%E6%96%99)

## WebSocketとは？
Web上でサーバとクライアント間を**常時接続にしておき、双方向の通信を低コストで実現する**ための通信プロトコルです。リロード無しでリアルタイムに更新されるようなチャットアプリなどに適しています。

聞きなじみのあるであろうHTTPは、クライアントが何らかのリクエストを送らないとサーバはレスポンスを返せないプロトコルです。そのため、基本的には双方向の通信と実現することができません。
![3_way_handShake.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4044112/1eae14d0-767c-4714-8cd0-d1fa7cfab2b9.png)

HTTTPのコネクションを張りっぱなしにしておき、クライアントから一定間隔でサーバに問い合わせをすることで、疑似的に双方向通信をすることはできます。
しかしながらこの方法では実装を行うと、以下のような理由で無駄や通信量が多いといった問題点が発生します。
- ブラウザのHTTPのコネクションにはタイムアウトがあるため、接続し直す処理を実装する必要がある
- データ転送を行う前のコネクションの確立を、通信発生の度に行う必要がある
- HTTPのコネクションを張りっぱなしにするため、その間他のアプリケーションの動作に影響を及ぼす可能性がある

WebSocketは、**サーバとクライアントが一度コネクションの確立をした後は、必要な通信をすべてその確立したコネクションを用いる**特徴をもつため、HTTPの問題を解決することができます。

### Socket.IOとは？
基本的にWebSocketを使いますが、WebSocketがサポートされていない場合でも上記のような他の転送メカニズムが自動で行われることで、双方向の通信を実現するJavaScriptライブラリのことです。

## チャットアプリ作成

### 事前準備
#### Node.jsのインストール
Node.jsの[公式サイト](https://nodejs.org/ja)よりインストーラーをダウンロードして、インストールしてください

### 環境構築

#### プロジェクトフォルダ作成
今回はビルド・モジュールバンドラーにviteを用います。

```powershell
npm create vite@latest
```
開発の場としてのプロジェクトフォルダ指定する必要があります。名前は自由ですが、今回は`project`としておきます。
frameworkには`React`を、variantには`TypeScript + SWC`を選択します。
![vite_build.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4044112/2316b4d6-dfe0-42bb-a52e-f3c6d399ec2f.png)

`Done.`と表示された場合、ビルド成功です。

#### Socket.IO利用に必要なライブラリのインストール

以下のコマンドを実行します。

```powershell
npm install express socket.io socket.io-client ts-node
npm install @types/express
```

ライブラリの利用用途については以下の通りです。

- express：サーバとして機能させるために利用
- socket.io：今回の主役その１、サーバー側の双方向通信に必要
- socket.io-client：今回の主役その２、クライアント側の双方向通信に必要
- ts-node：TypeScriptのままテスト実行するために利用

### サーバ構築

#### server/index.ts
[Socket.IOのドキュメント](https://socket.io/docs/v4/server-initialization/#with-express)の例を参考に``express``と``http``を組み合わせ、scrディレクトリ配下に``server/index.ts``を作成

```typescript: server/index.ts
import { createServer } from "http";
import { Server } from "socket.io";
import express from "express";

const app = express();
const PORT = 3000;
const httpServer = createServer(app);
const io = new Server(httpServer, {path: "/socket/"});

io.on("connection", (socket) => {
    // ...
});

httpServer.listen(PORT, () => {
    console.log(`Chat server listening on port ${PORT}`)
});
```
#### tsconfig.json
以下のように変更する。

```diff_json: tsconfig.json
{
  "files": [],
  "references": [
    { "path": "./tsconfig.app.json" },
    { "path": "./tsconfig.node.json" }
  ],
+ "compilerOptions": {
+   "forceConsistentCasingInFileNames": true,
+   "strict": true,
+   "esModuleInterop": true,
+ } 
}
```
#### package.json
- ``"type": "module"``を削除する
- ``"scripts"``に以下を追加

```diff_json: package.json
{
-   "type": "module", 
    "scripts": {
+       "server": "ts-node ./src/server/index.ts",
    }
}
```

ターミナルで``npm run server``を実行し、``Chat server listening on port 3000``が出力されればサーバ構築が成功しています。

### クライアント側との接続前準備

クライアント側と接続する上で一点問題があります。
それは、クライアントとサーバ側で異なるオリジンを設定すると、**CORSエラー**が発生してしまいクライアントとサーバ間の通信をすることができなくなることです。

#### オリジン・CORS (Cross-origin resource sharing)とは？
オリジンとは以下のことを指します。

![origin.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4044112/c20ec203-e4d4-4e2e-aa52-c036ea91ce76.png)

オリジンには**同一オリジンポリシー**とよばれる、リソースは別のオリジンからはアクセスできず、同一のオリジンのみがアクセスできる決まりがあります。
これが**CORSエラー**として出ます。

この **「同一オリジンポリシー」という制限を無くして、異なるオリジンでもリソースへアクセスできるようにする仕組みのこと**をCORSと呼びます。

#### 対処法

以下コマンドを実行します。

```powershell
npm install @types/cors
```
次に、``server/index.ts``を以下のように修正します。
```diff_typescript: server/index.ts
import { createServer } from "http";
import { Server } from "socket.io";
import express from "express";
+ import cors from "cors";

const app = express();
const PORT = 3000;
+ app.use(cors());
const httpServer = createServer(app);
const io = new Server(httpServer, {path: "/socket/", 
+   cors: {
+       origin: "*"
+   }
});

io.on("connection", (socket) => {
    // ...
});

httpServer.listen(PORT, () => {
    console.log(`Chat server listening on port ${PORT}`)
});
```

### クライアントアプリの作成

デザインライブラリである[MUI](https://mui.com/?srsltid=AfmBOorftQYWn6ydvl98UNo5AvwVO4zz4a0xu5xqIkf3L6wpr4O9gNTO)を用いた実装を行うため、以下コマンドを実行します。

```powershell
npm install @mui/material @mui/icons-material @emotion/react @emotion/styled
```

#### src/App.tsx
``project``フォルダ内の``src/App.tsx``を以下のように書き換えます。
```typescript
import {
  Box,
  Button,
  Input,
  ListItem,
  ListItemText,
  Modal,
  Typography,
} from "@mui/material";
import React, { useEffect, useState } from "react";
import { io } from "socket.io-client";

// サーバとの接続に必要
const socket = io("http://localhost:3000", { path: "/socket/" });

socket.on("connect", () => {
  console.log(`サーバとの接続確認：`);
  console.log(socket.connect());
});

interface MessageWithTime {
  message: string;
  time: string;
  userName: string;
  sender: "self" | "other";
};

const style = {
  position: "absolute" as "absolute",
  top: "50%",
  left: "50%",
  transform: "translate(-50%, -50%)",
  width: 400,
  bgcolor: "background.paper",
  border: "2px solid #000",
  boxShadow: 24,
  p: 4,
};

function App() {
  const [messageList, setMessageList] = useState<MessageWithTime[]>([]);
  const [message, setMessage] = useState("");
  const [userName, setUserName] = useState<string>("匿名");
  const [modalOpen, setModalOpen] = useState(true);

  useEffect(() => {
    const handleChatMessage = (msg: MessageWithTime) => {
      setMessageList((prevList) => [...prevList, { ...msg, sender: "other" }]);
    };

    socket.on("chat", handleChatMessage);

    return () => {
      socket.off("chat", handleChatMessage);
    };
  }, [userName]);

  const onChangeMessage = (e: React.ChangeEvent<HTMLInputElement>) => {
    setMessage(e.target.value);
  };

  const onClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
    e.preventDefault();

    const currentTime = new Date().toLocaleTimeString();

    const newMessageWithTime: MessageWithTime = {
      message: message,
      time: currentTime,
      userName: userName,
      sender: "self",
    };

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

    setMessageList((prevList) => [...prevList, newMessageWithTime]);
    setMessage("");
  };

  const onReset = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
    e.preventDefault();
    setMessageList([]);
  };

  const handleUserNameSubmit = () => {
    setModalOpen(false);
  };

  return (
    <div className="App">
    
    {/* ユーザ名作成モーダルの表示 */}
      <Modal open={modalOpen} onClose={() => setModalOpen(false)}>
        <Box sx={style}>
          <Typography variant="h6" component={"h2"}>
            ログイン名を入力してください
          </Typography>
          <Input
            placeholder="ユーザ名"
            value={userName}
            onChange={(e) => setUserName(e.target.value)}
          />
          <Button onClick={handleUserNameSubmit}>OK</Button>
        </Box>
      </Modal>
      
      {/* 
          チャットスペースの作成
          送信元と受信元でメッセージ位置が変わるようにする
      */}
      <>
        {messageList.map((item, index) => (
          <ListItem
            key={index}
            sx={{
              justifyContent:
                item.sender === "self" ? "flex-end" : "flex-start",
            }}
          >
            <ListItemText
              primary={`${item.time} ${item.userName}：${item.message}`}
              sx={{
                textAlign: item.sender === "self" ? "right" : "left",
                margin: item.sender === "self" ? "0 0 0 auto" : "0 auto 0 0",
              }}
            />
          </ListItem>
        ))}

        <Input
          placeholder="文字を入力してください"
          value={message}
          onChange={onChangeMessage}
        />
        <Button onClick={onClick}>送る</Button>
        <Button onClick={onReset}>リセット</Button>
      </>
    </div>
  );
}

export default App;
```

#### server/index.ts
クライアントとの接続の中身を以下のように記述する。
```diff_typescript
import express from "express";
import { createServer } from "http";
import { Server } from "socket.io";
import cors from "cors";

const app = express();
const PORT = 3000;
app.use(cors());
const httpServer = createServer(app);
const io = new Server(httpServer, {path: "/socket/", 
    cors: {
        origin: "*"
    }
});

io.on("connection", (socket) => {
+   console.log("connected!");
+   socket.on("disconnect", () => {
+       console.log("disconnected!");
+   });
+
+   // 送信元以外の全クライアントに送信
+   socket.on("chat message", (msg) => {
+       socket.broadcast.emit("chat", msg)
+   })
});

httpServer.listen(PORT, () => {
    console.log(`Chat server listening on port ${PORT}`)
});
```

### アプリケーションの起動

ターミナルにて、``npm run server``でサーバを起動した後に、``npm run dev``でクライアントアプリを起動してください。
Viteのデフォルトポートは``5173``なので、``http://localhost:5173/``にアクセスします。
その後、サーバ側のターミナルを確認してみましょう。``connected!``が表示されていれば接続ができています。
別タブで同じURLにアクセスし、双方向のメッセージ送信・受信ができるかをためしてみましょう！
送信側が右に、受信側が左にメッセージが表示されていれば成功です！

## 最後に

今回は『WebSocketやHTTPに詳しくないけど、チャットアプリを作成したい』初学者向けの解説と、アプリの実装を紹介しました。解説ではあえて言及しませんでしたが、WebSocketとHTTPの話には**TCP/IP**が密接に関係しています。TCP/IPの話をすると記事が長くなりそうなので、ここで締めさせていただきますが、興味が出てきた方は調べてみると新たな気づきを得るかもしれませんね。

## 参考資料

- [Socket.IO (公式サイト)](https://socket.io/)
- [WebSocket API (MDN)](https://developer.mozilla.org/ja/docs/Web/API/WebSockets_API)
- [HTTP (MDN)](https://developer.mozilla.org/ja/docs/Web/HTTP)
- [TCP handshake (MDN)](https://developer.mozilla.org/ja/docs/Glossary/TCP_handshake)
- [Cross-Origin Resource Sharing (CORS) (MDN)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS)
- [Socket.ioのよく使う関数とか (Qiita)](https://qiita.com/uranesu/items/8ee0dbe4e472f9fffa49)