Svelte + WebSocketでシンプルなチャットアプリを作る

7 min read読了の目安(約6700字

Svelte と WebSocket の勉強のため簡単なチャットアプリを作ったので備忘録としてまとめます。

基本

Svelte とは?

Svelte は React や Vue.js のように宣言的 UI で Web アプリケーションを作れるツールです。Vue.js の SFC のように、.svelteファイルで HTML、JavaScript、CSS を単一コンポーネントとして管理し、それを組み合わせてアプリケーションを構築していきます。

React や Vue.js との大きな違いは、事前にアプリケーションコードを純粋な JavaScript ファイルとしてコンパイルすることで、クライアントではネイティブの DOM スクリプトとして実行される点です。仮想 DOM を使用しないため、ビルド後のコードには専用のランタイムライブラリが含まれず軽量です。

https://svelte.dev/

WebSocket とは?

WebSocket は単一の TCP コネクション上で双方向通信を実現するための通信プロトコルです。WebSocket を使うことで、一度通信を確立すれば通常の HTTP 通信では実現できないサーバー起点のデータ送信が可能になります。そのため従来は pooling などで実現していたリロードなしのリアルタイムのデータ更新が容易に実装できます。

https://livelibrary.osisoft.com/LiveLibrary/content/en/web-api-v8/GUID-EA5DCD55-A957-4B47-9556-22A7920BD82F#addHistory=true&filename=GUID-021731EE-C9B6-4236-9BE5-1ED92C43B319.xml&docid=GUID-EA5DCD55-A957-4B47-9556-22A7920BD82F&inner_id=&tid=&query=&scope=&resource=&toc=false&eventType=lcContent.loadDocGUID-EA5DCD55-A957-4B47-9556-22A7920BD82F

チャットアプリの作成

以下のように別ブラウザで開いていても、リアルタイムで同期的に動くチャットアプリを作りました。

ソースコードはこちらにあります。

https://github.com/kawamataryo/svelte-simple-chat

以下ポイントだけメモします。

サーバー側の実装

サーバー側の実装はとても簡単です。
Node.js で WebSocket を扱うためのライブラリであるwsを使って実装しています。

https://github.com/websockets/ws
import WebSocket from "ws"

const wss = new WebSocket.Server({ port: 8082 });

type Chat = {
  userId: string,
  message: string
}

// インメモリでデータを保持
const chatBoard: Chat[] = [];

wss.on('connection', (ws) => {
  ws.on('message', (payload) => {
    if(typeof payload === 'string') {
      chatBoard.push(JSON.parse(payload));
    }

    // 全ての接続先に送信
    wss.clients.forEach((client) => {
      client.send(JSON.stringify(chatBoard));
    })
  });

  ws.send(JSON.stringify(chatBoard));
});

new WebSocket.Serverでサーバーのインスタンスを作り、wss.on('connect', callback)で WebSocket 接続時の処理を。接続時にコールバックで受け取るwsを使って、ws.on('message', callback)でリクエスト受信時の処理を書いています。

リクエスト受信時には、インメモリで保持しているチャットのメッセージ配列の更新を行うとともに、wss.clientsで全ての接続中のクライアントを取得し、それぞれのクライアントに対してsendで新しいチャットメッセージの配列を送っています。ws.sendを使った場合、リクエストを送信したクライアントだけにメッセージを送ることになり、別ブラウザでの同期的な表示はできないので注意です。

Client側の実装

Client 側では Svelte の TypeScript テンプレートを元にチャットアプリを作っています。
TypeScript でのプロジェクト作成はこちらを参考にしました。

https://svelte.dev/blog/svelte-and-typescript

コンポーネントはボード全体を表示するApp.svelteと、ひとつひとつのメッセージを表示するMessage.svelteと、メッセージの入力フォームを表示するMessageForm.svelteの 3 つです。

まずMessage.svelteです。
Svelte での Props は、script タグの中でexportしている変数となります。このコンポーネントでは、currentUserIduserIdmessageの 3 つの Props を受け取り、その内容をもとにメッセージを表示しています。
その他、class:mine="{currentUserId === userId}"の部分で、currentUserId と userId が一致する場合のみmineという class を付与しています。
アバターの画像は、Avatarsという文字列からアバター画像を生成してくれる API を利用しています。

https://avatars.dicebear.com/
client/src/components/Message.svelte
<script lang="ts">
  export let currentUserId: string;
  export let userId: string;
  export let message: string;
</script>

<div class="message-container" class:mine="{currentUserId === userId}">
  <img width="40px" src="{`https://avatars.dicebear.com/api/bottts/` + userId + '.svg'}" alt="Avatar" />
  <div class="balloon">{message}</div>
</div>

<style>
/* ... */
</style>

次にMessageForm.svelteです。
このコンポーネントでは親コンポーネントにクリックイベントを送るために、createEventDispatcherを使って送信ボタンのクリックで submit イベントをディスパッチしています。createEventDispatcherは Vue でいう emit と同様ですね。また、親コンポーネントから受け取ったmessageProps の内容をbind:valueで input 要素に割当ています。親コンポーネント側で MessageForm コンポーネントの定義時にbind:messageとすることで、透過的に message の変更を親コンポーネントに反映できます。

※ フォーム要素のコンポーネント間のデータのやり取りはこちらを参考にしました。

https://svelte.dev/tutorial/component-bindings
client/src/components/MessageForm.svelte
<script lang="ts">
  import { createEventDispatcher } from 'svelte';

  export let message;

  const dispatch = createEventDispatcher();
  const handleSubmit = () => {
    dispatch('submit');
  };
</script>

<input bind:value="{message}" class="input" type="text" placeholder="Your message" />
<button class="button is-link" on:click="{handleSubmit}">submit</button>

最後にApp.svelteです。
ライフサイクルフックのonMountで WebSocket の接続とメッセージ受信時のイベントを定義しています。メッセージ受信時には、内部で保持するリアクティブな変数であるmessagesにデータを代入して、チャットデータの更新を行っています。
他、socket.onopensocket.onmessageで WebSocket 接続時、メッセージ受信時の処理を登録しています。メッセージ受信時に受信データをJSON.parseでパースしてmessagesに代入しています。

また、handleSubmitという関数で送信ボタン押下時に、WebSocket に対して追加のメッセージを送っています。先にmessagesの値を更新しているのは、自分起因のデータ更新は WebSocket の通信を待たず反映させるためです。

client/src/App.svelte
<script lang="ts">
  import { onMount, tick } from 'svelte';
  import Message from './components/Message.svelte';
  import MessageForm from './components/MessageForm.svelte';
  import { getUserId } from './lib/getUserId';

  let message = '';
  let messages: { userId: string; message: string }[] = [];
  let socket: null | WebSocket = null;
  let board: null | Element;

  const currentUserId = getUserId();

  const scrollToBottom = async () => {
    await tick();
    board.scrollTop = board.scrollHeight;
  };

  const handleSubmit = () => {
    if (!message) {
      return;
    }
    messages = [...messages, { userId: currentUserId, message }];
    socket.send(JSON.stringify({ userId: currentUserId, message }));
    message = '';
    scrollToBottom();
  };

  onMount(() => {
    socket = new WebSocket('ws://localhost:8082');
    socket.onopen = () => {
      console.log('socket connected');
    };
    socket.onmessage = (event) => {
      if(!event.data) { return }
      messages = JSON.parse(event.data);
      scrollToBottom();
    };
  });
</script>

<main>
  <div class="wrapper">
    <h1>Svelte simple chat</h1>
    <div class="board" bind:this="{board}">
      {#each messages as { userId, message: mg }}
        <Message currentUserId="{currentUserId}" userId="{userId}" message="{mg}" />
      {/each}
    </div>
    <div class="message-form-wrapper">
      <MessageForm bind:message on:submit="{handleSubmit}" />
    </div>
  </div>
</main>

<style>
/* ... */
</style>

参考

終わりに

以上「Svelte + WebSocketでシンプルなチャットアプリを作る」でした。
Svelte も WebSocket も触りの触りですが、思いのほか使いやすかったです。また折りを見て書いていきたいです。