CDN版のReactを使ってWebsocketのチャットクライアントを実装してみました

# はじめに

### 【欲望】
<dl>
    <dt>（１）インストール作業は極力したくない</dt>
    <dd>PC内で使うモジュールをガチガチにして重たくしたくない</dd>
    <dt>（２）オフラインでも動くようにしたい</dt>
    <dd>
        ブラウザへのドラッグ＆ドロップで使いたい<br />
        （つまりはWebサーバーレスでも使える）<br />
        （つまりはfileプロトコルでも使える）
    </dd>
</dl>

という願いを叶えられたら幸せだよねー。
そういう思いから以下のルールを決めました。

### 【ルール】
<dl>
    <dt>（１）CDN版を利用する</dt>
    <dd>インストール回避のため
    <dt>（２）CGIは使わない</dt>
    <dd>Webサーバー回避のため
</dl>

【ルール】の（１）に関しては、そのままだとオンラインでしか使えなくなるのでダウンロードしたモジュールを使います。
また、アクセス先のファイルが変更される事で発生する不具合の影響を小さくできるというメリットもあります。


# 事前準備

CDN版のファイルを以下のようなコマンドを使ってあらかじめダウンロードしておきます。

```console:wgetコマンド（Linuxの場合）
> wget https://unpkg.com/react@18/umd/react.production.min.js
> wget https://unpkg.com/react-dom@18/umd/react-dom.production.min.js
> wget https://unpkg.com/babel-standalone@6/babel.min.js
```
```console:Invoke-WebRequestコマンド（Windowsの場合）
> Invoke-WebRequest https://unpkg.com/react@18/umd/react.production.min.js -OutFile react.production.min.js
> Invoke-WebRequest https://unpkg.com/react-dom@18/umd/react-dom.production.min.js -OutFile react-dom.production.min.js
> Invoke-WebRequest https://unpkg.com/babel-standalone@6/babel.min.js -OutFile babel.min.js
```

# 画面レイアウト

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3619905/2b833404-741e-5b4a-700c-6e36e38b4480.png)

四角で囲んだ各フォーム毎にネームスペースを設け、コンポーネントを分けています。
ネームスペースを設ける事で以下のメリットがあります。

<dl>
    <dt>・フォーム間のコンポーネントシンボルの重複を防ぐ</dt>
    <dt>・フォーム部品を判別しやすい</dt>
</dl>


クライアントの元ネタはこちら↓

https://qiita.com/glasses_seven/items/7a490dc8694348505a53/


# HTMLファイル

画面全体のHTMLの内容は以下の通りです。

```html:chat.html
<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8" />
        <title>React版簡易チャット</title>

        <link type="text/css" rel="stylesheet" href="./chat.css" media="all" />

        <script src="./react.production.min.js"></script>
        <script src="./react-dom.production.min.js"></script>
        <script src="./babel.min.js"></script>

        <script src="./common.js"></script>
        <script src="./area_connection.js"></script>
        <script src="./area_history.js"></script>
        <script src="./area_comment.js"></script>
        <script src="./area_private.js"></script>
    </head>
    <body>
        <div id="root"></div>
        <script type="text/babel">

            function ChatScreen()
            {
                // エレメントの生成
                return(
                    <div>
                        <connection.Form />
                        <history.ParentBox />
                        <comment.Form />
                        <comment.Guide />
                        <private_comment.Form />
                        <private_comment.Guide />
                    </div>
                );
            }

            // 画面レンダリング
            const container = document.getElementById('root');
            const root = ReactDOM.createRoot(container);
            root.render(<ChatScreen />);

        </script>
    </body>
</html>
```

namespace毎に`area_`のプリフィックスを付けたjsファイルで分けています。
このHTMLファイル内ではJSXが使えますが、CDNを使ったこの作り方では別ファイルのソース内でJSXが使えません。
また、スコープがネストしているデータやコンポーネントを除いてほぼ全てのシンボルがグローバル扱いとなるので注意が必要です。

わかり易い例で言えば`connection`ネームスペース内のユーザー名入力フィールドのコンポーネントは以下のようになっています。

```javascript:ユーザー名の入力フィールドのコンポーネント
function InputUser()
{
    // ユーザー名のフック
    [global_data.connection.user, global_func.connection.setUser] = global_func.useState('');

    // 入力変更時のハンドラ
    function OnChange(e)
    {
        global_func.connection.setUser(e.target.value);
    }

    // エレメントの生成
    let ret = React.createElement
    (
        "input",
        {
            className: "user",
            type: "text",
            name: "user",
            disabled: global_data.connection.disabled,
            onChange: OnChange,
            defaultValue: global_data.connection.user,
            placeholder: "ユーザー名",
            maxLength: 8
        }
    );
    return ret;
}
```

エレメントはJSXではなく`React.createElement`で生成しています。
`useState`フックを含めたシンボルはnamespaceを付与して参照しています。

ちなみにグローバルデータや関数に関しても`common.js`で以下のようにnamespaceを定義していますのでフックデータの迷子になる事もないでしょう。

```javascript:common.js
/**
 * 定数定義用
 */
const const_data = {};

/**
 * WebsocketAPIのインスタンスやフックデータを含むグローバルデータ
 */
const global_data =
{
    // コネクションフォーム用
    connection: {},
    // チャット履歴フォーム用
    history: {},
    // コメント入力フォーム用
    comment: {},
    // プライベートコメント入力フォーム用
    private: {}
};

/**
 * WebsocketAPIのハンドラ登録やフック関数を含むグローバル関数
 */
const global_func =
{
    // コネクションフォーム用
    connection: {},
    // チャット履歴フォーム用
    history: {},
    // コメント入力フォーム用
    comment: {},
    // プライベートコメント入力フォーム用
    private: {}
};
```


# Websocketの処理

WebsocketAPIは元ネタで使用しているブラウザネイティブのものをそのまま利用しているので他のモジュールを使わなくても動作するようにしています。
処理をそのまま掲載すると長くなるので概要だけを以下にまとめておきます。

### ・onopenハンドラ（接続完了時）

WebsocketAPIの`send`メソッドを使って`entrance`（入室）コマンドを送信する。

### ・onmessageハンドラ（コマンド受信時）

受信した以下のコマンド種類によって処理を振り分けています。
処理結果のみを返す`private-reply`コマンド以外は日時／ユーザー名／コメント等のデータを返すので基本的にチャット履歴を残すための処理になります。

<dl>
    <dt>　－entranceコマンド－</dt>
    <dd>
        自身、または通信相手の入室を知らせるコマンド。<br />
        クライアントサイドで使用する文言セット（管理者名や定型文）や参加者一覧／参加人数等のデータもこの時にサーバーから受信する。
    </dd>
    <dt>　－exitコマンド－</dt>
    <dd>
        通信相手の退室を知らせるコマンド。<br />
        ユーザーリスト／参加人数等のデータも受信する。<br />
        ブラウザ⇒「退室する」ボタン押下時。<br />
        マインクラフト⇒「exit」コマンド送信時。
    </dd>
    <dt>　－closeコマンド－</dt>
    <dd>
        通信相手の切断を知らせるコマンド。<br />
        ユーザーリスト／参加人数等のデータも受信する。<br />
        ブラウザ⇒Ｘボタンで閉じた時。<br />
        マインクラフト⇒Ｘボタンで閉じた時。
    </dd>
    <dt>　－messageコマンド－</dt>
    <dd>
        自身、または通信相手からのチャットコメントを受信するコマンド。<br />
        参加人数や送信結果等のデータも受信する。<br />
        エラー発生時のコメントはコメント入力欄の下に表示する。
    </dd>
    <dt>　－privateコマンド－</dt>
    <dd>
        自身、または通信相手からのプライベートチャットコメントを受信するコマンド。<br />
        参加人数のデータも受信する。
    </dd>
    <dt>　－private-replyコマンド－</dt>
    <dd>
        自身が送信したプライベートチャットコメントに対する応答を受信するコマンド。<br />
        エラー発生時のコメントはコメント入力欄の下に表示する。<br />
    </dd>
</dl>

### ・oncloseハンドラ（切断フレーム受信時）

受信した切断コードによってチャット履歴に残す文言の振り分け処理を行い、システム終了処理を行う。
<dl>
    <dt>　－「退室する」ボタン押下時－</dt>
    <dd>
        該当する切断コード⇒10 or 3010。<br />
        「退室しました」のコメントをチャット履歴に残す。
    </dd>
    <dt>　－サーバーからの切断時－</dt>
    <dd>
        該当する切断コード⇒20。<br />
        「サーバーから切断されました」のコメントをチャット履歴に残す。
    </dd>
    <dt>　－ユーザー名重複時の切断－</dt>
    <dd>
        該当する切断コード⇒30。<br />
        「そのユーザー名は既に使用されています」のコメントをチャット履歴に残す。
    </dd>
    <dt>　－ユーザー名未入力時の切断－</dt>
    <dd>
        該当する切断コード⇒40。<br />
        「ユーザー名を入力してください」のコメントをチャット履歴に残す。
    </dd>
    <dt>　－認識していない切断コード－（フェイルセーフ）</dt>
    <dd>
        該当する切断コード⇒不明。<br />
        「予期せず切断されました」のコメントをチャット履歴に残す。
    </dd>
    <dt>　－切断フレームを介さない切断－</dt>
    <dd>
        該当する切断コード⇒1006。<br />
        「切断されました」のコメントをチャット履歴に残す。
    </dd>
</dl>

:::note info
切断フレームに関しての詳細は以下の記事でもご紹介しています。
:::

https://qiita.com/glasses_seven/items/0431f268a1f7c89b1976/

### ・onerrorハンドラ（通信失敗時）

「エラーが発生しました」のコメントをチャット履歴に残してシステム終了処理を行う。

### ・sendメソッド（コマンド送信時）

<dl>
    <dt>　－「退室する」ボタン押下時－</dt>
    <dd>
        exitコマンドを送信する。
    </dd>
    <dt>　－「ポチる」ボタン（チャット送信）押下時－</dt>
    <dd>
        messageコマンドを送信する。
    </dd>
    <dt>　－「ポチる」ボタン（プライベートチャット送信）押下時－</dt>
    <dd>
        privateコマンドを送信する。
    </dd>
</dl>

### ・closeメソッド（切断フレーム送信時）

<dl>
    <dt>　－「退室する」ボタン押下時－</dt>
    <dd>
        closeコマンドを送信する。
    </dd>
</dl>

:::note warn
「退室する」ボタン押下時はフラグメントをして`exit`と`close`コマンドを交互に送信するようにしています。
:::

※コマンドの詳細は以下のページをご覧ください。

https://socket-manager.github.io/document/extra-demo-command.html


# 動作確認

容量の都合上少し早送りにはなっていますが一応画面を載せておきます。

![React版簡易チャット](https://socket-manager.github.io/document/img/extra/react-chat.gif "React版簡易チャット")

どうやら動作に問題はなさそうです。


# おわりに

Reactはフックデータと連動して画面をレンダリングしてくれるので、双方向通信ができるWebsocketAPIと相性が良くて便利だと思いました。

その反面でJSXが使えるとはいえ画面部品がコンポーネント単位でバラバラに存在するので、デザイナーと連携しながら運用していくのはちょっと難しいかなと思いました。

今回のソースはjQuery版と一緒にこちらに反映しています↓
（jQuery版も機能は全く同じです）

https://github.com/socket-manager/demo-project/