Next.js+Scaledroneでチャットアプリを作る
はじめに
今回はWebSocketをベースとしたリアルタイムメッセージサービス「Scaledrone」と、
Next.js(React)を使ってチャットアプリを作ってみたいと思います。
Scaledroneのチュートリアルに沿って、以下機能を作っていきます。
- ログイン中のユーザーを一覧表示します。
- ユーザー名は、自動で割り当てます。
- メッセージを入力し、送信します。
- メッセージをリアルタイムに受信し、表示します。
動作イメージは以下ページで確認できます。
【1】Next.jsのアプリを作成する
Scaledroneのチュートリアル用リポジトリをもとに、Next.jsの新規アプリを作成します。
- Node.jsをインストールします。
- Next.jsをインストールします。
npm install -g create-next-app
- 以下コマンドで新規アプリを作成します。
npx create-next-app --example https://github.com/ScaleDrone/react-chat-tutorial/tree/starter my-react-chat
- 作成したフォルダを、エディタで開きます。
cd my-react-chat
【2】メッセージ表示コンポーネントを作成する
受信したメッセージを表示するコンポーネントを作成します。
- メッセージは、送信したユーザーの名前とアイコン色で識別できるようにします。
- 自分の送信メッセージは画面右側、他ユーザーのメッセージは左側に表示します。
- メッセージを受信したら
useEffect
で、画面下へ自動でスクロールさせます。
Messages.jsの追加
「src/components/Messages.js」ファイルを作成し、以下コードを記述します。
Messages.js
import {useEffect, useRef} from 'react';
import React from 'react';
import styles from '@/styles/Home.module.css'
export default function Messages({messages, me}) {
const bottomRef = useRef(null);
useEffect(() => {
if (bottomRef && bottomRef.current) {
bottomRef.current.scrollIntoView({behavior: 'smooth'});
}
});
return (
<ul className={styles.messagesList}>
{messages.map(m => Message(m, me))}
<div ref={bottomRef}></div>
</ul>
);
}
function Message({member, data, id}, me) {
const {username, color} = member.clientData;
const messageFromMe = member.id === me.id;
const className = messageFromMe ?
`${styles.messagesMessage} ${styles.currentMember}` : styles.messagesMessage;
return (
<li key={id} className={className}>
<span
className={styles.avatar}
style={{backgroundColor: color}}
/>
<div className={styles.messageContent}>
<div className={styles.username}>
{username}
</div>
<div className={styles.text}>{data}</div>
</div>
</li>
);
}
index.jsの修正
次に「src/pages/index.js」に、以下処理を追加します。
- Messages.jsの読み込み処理を追加します。
- ユーザーにユーザー名とアイコン色を、ランダムで割り当てる処理を追加します。
- テスト用のメッセージを表示する処理を追加します。
index.js
import Head from 'next/head'
import styles from '@/styles/Home.module.css'
import Script from 'next/script';
import { useState, useEffect, useRef } from 'react';
import Messages from '@/components/Messages'
function randomName() {
const adjectives = [
'autumn', 'hidden', 'bitter', 'misty', 'silent', 'empty', 'dry', 'dark',
'summer', 'icy', 'delicate', 'quiet', 'white', 'cool', 'spring', 'winter',
'patient', 'twilight', 'dawn', 'crimson', 'wispy', 'weathered', 'blue',
'billowing', 'broken', 'cold', 'damp', 'falling', 'frosty', 'green', 'long',
'late', 'lingering', 'bold', 'little', 'morning', 'muddy', 'old', 'red',
'rough', 'still', 'small', 'sparkling', 'shy', 'wandering',
'withered', 'wild', 'black', 'young', 'holy', 'solitary', 'fragrant',
'aged', 'snowy', 'proud', 'floral', 'restless', 'divine', 'polished',
'ancient', 'purple', 'lively', 'nameless'
];
const nouns = [
'waterfall', 'river', 'breeze', 'moon', 'rain', 'wind', 'sea', 'morning',
'snow', 'lake', 'sunset', 'pine', 'shadow', 'leaf', 'dawn', 'glitter',
'forest', 'hill', 'cloud', 'meadow', 'sun', 'glade', 'bird', 'brook',
'butterfly', 'bush', 'dew', 'dust', 'field', 'fire', 'flower', 'firefly',
'feather', 'grass', 'haze', 'mountain', 'night', 'pond', 'darkness',
'snowflake', 'silence', 'sound', 'sky', 'shape', 'surf', 'thunder',
'violet', 'water', 'wildflower', 'wave', 'water', 'resonance', 'sun',
'wood', 'dream', 'cherry', 'tree', 'fog', 'frost', 'voice', 'paper', 'frog',
'smoke', 'star'
];
const adjective = adjectives[Math.floor(Math.random() * adjectives.length)];
const noun = nouns[Math.floor(Math.random() * nouns.length)];
return adjective + noun;
}
function randomColor() {
return '#' + Math.floor(Math.random() * 0xFFFFFF).toString(16);
}
export default function Home() {
const [messages, setMessages] = useState([{
id: '1',
data: 'This is a test message!',
member: {
id: '1',
clientData: {
color: 'blue',
username: 'bluemoon',
},
},
}]);
const [me, setMe] = useState({
username: randomName(),
color: randomColor(),
});
return (
<>
<Head>
<title>Scaledrone Chat App</title>
<meta name='description' content='Your brand-new chat app!' />
<meta name='viewport' content='width=device-width, initial-scale=1' />
<link rel='icon' href='/favicon.ico' />
</Head>
<main className={styles.app}>
<div className={styles.appContent}>
<Messages messages={messages} me={me}/>
</div>
</main>
</>
)
}
作成したMessageコンポーネントが動作するか、確認してみましょう。
npm run dev
でアプリを起動し、http://localhost:3000/
にアクセスします。
「This is a test message!」と画面に表示されたら、成功です!
【3】メッセージ入力コンポーネントを作成する
メッセージの入力欄と送信ボタンを表示するコンポーネントを作成します。
- メッセージを入力するテキストエリアを配置します。
- メッセージ送信用のSendボタンを配置します。
-
preventDefault()
でデフォルトのsubmit処理は無効化します。
Input.jsの追加
「src/components/Input.js」ファイルを作成し、以下コードを記述します。
Input.js
import React from 'react';
import { useEffect, useState } from 'react';
import styles from '@/styles/Home.module.css'
export default function Input({onSendMessage}) {
const [text, setText] = useState('');
function onChange(e) {
const text = e.target.value;
setText(text);
}
function onSubmit(e) {
e.preventDefault();
setText('');
onSendMessage(text);
}
return (
<div className={styles.input}>
<form onSubmit={e => onSubmit(e)}>
<input
onChange={e => onChange(e)}
value={text}
type='text'
placeholder='Enter your message and press ENTER'
autoFocus
/>
<button>Send</button>
</form>
</div>
);
}
index.jsの修正
次に「src/pages/index.js」に、以下処理を追加します。
- Input.jsの読み込み処理を追加します。
- メッセージ入力欄の表示処理を追加します。
- メッセージの送信処理(ローカル用の仮実装)を追加します。
チュートリアルのonSendMessage()
はclientData参照エラーが発生するため修正しました。
また、onSendMessage()
は後で、Scaledroneのサーバーに送信する処理に変更します。
index.js
import Input from '@/components/Input'
// Home関数内に追加
function onSendMessage(message) {
const newMessage = {
data: message,
member: {
clientData: me,
},
};
setMessages([...messages, newMessage]);
}
// <Messages>の下に追加
<Input
onSendMessage={onSendMessage}
/>
作成したInputコンポーネントが動作するか、確認してみましょう。
メッセージを入力し、送信してみます。
送信メッセージが画面右側に表示されたら、成功です!
【4】オンラインユーザー表示コンポーネントを作成する
チャット画面を開いているユーザーを一覧表示するコンポーネントを作成します。
- オンラインユーザー数の表示欄を生成します。
- ユーザー名の一覧表示欄を生成します。
- 自分のユーザー名の後ろには「(you)」を付加します。
Members.jsの追加
「src/components/Members.js」ファイルを作成し、以下コードを記述します。
Members.js
import React from 'react';
import styles from '@/styles/Home.module.css'
export default function Members({members, me}) {
return (
<div className={styles.members}>
<div className={styles.membersCount}>
{members.length} user{members.length === 1 ? '' : 's'} online
</div>
<div className={styles.membersList}>
{members.map(m => Member(m, m.id === me.id))}
</div>
</div>);
}
function Member({id, clientData}, isMe) {
const {username, color} = clientData;
return (
<div key={id} className={styles.member}>
<div className={styles.avatar} style={{backgroundColor: color}}/>
<div className={styles.username}>{username} {isMe ? ' (you)' : ''}</div>
</div>
);
}
index.jsの修正
次に「src/pages/index.js」に、以下処理を追加します。
- Members.jsの読み込み処理を追加します。
- オンラインユーザーの表示処理を追加します。
- テスト用ユーザーをオンライン中として表示する処理を追加します。
index.js
import Members from '@/components/Members'
// Home関数内に追加
const [members, setMembers] = useState([{
id: "1",
clientData: {
color: 'blue',
username: 'bluemoon',
},
}]);
// <Messages>の上に追加
<Members members={members} me={me}/>
作成したMembersコンポーネントが動作するか、確認してみましょう。
チャット画面の上部を確認します。
「1 user online」と、テスト用のユーザー名「bluemoon」が表示されたら、成功です!
【5】Scaledroneのチャンネルを作成する
Scaledroneのアカウントを作成し、メッセージ中継用のチャンネルを作成します。
- scaledrone.comにアクセスします。
- 「CREATE ACCOUNT」からアカウントを作成します。
- ログインし、Scaledroneのダッシュボードにアクセスします。
- 「+Create channel」ボタンをクリックします。
- 以下を入力して、チャンネルを作成します。
- チャンネル名(Choose a name): 任意
- 認証(Authentication): 不要(Never require authentication)
- メッセージ履歴(Message history): 無効(Disable message history)
- 「Channel overview」画面に表示されるチャンネルIDを、コピーして控えます。
【6】Scaledroneに接続し、メッセージを送信する
Scaledroneのサーバーを介して、オンラインでリアルタイムチャットできるようにします。
- テスト用に追加していた、ユーザーとメッセージの表示処理を削除します。
- scaledrone.min.js(ScaledroneのCDN)の読み込み処理を追加します。
- Scaledroneに接続する処理
connectToScaledrone()
を追加します。 - 前の章で取得したチャンネルIDを'
YOUR-CHANNEL-ID
'に設定します。
drone = new window.Scaledrone('YOUR-CHANNEL-ID', { ...
-
useRef()
で最新のユーザー情報、メッセージ情報を取得する処理を追加します。 - Scaledroneへ情報が送信されるよう、
onSendMessage()
を変更します。
index.jsの修正
「src/pages/index.js」を、以下コードに書き換えます。
index.js
import Head from 'next/head'
import styles from '@/styles/Home.module.css'
import Script from 'next/script';
import { useState, useEffect, useRef } from 'react';
import Messages from '@/components/Messages'
import Input from '@/components/Input'
import Members from '@/components/Members'
function randomName() {
const adjectives = [
'autumn', 'hidden', 'bitter', 'misty', 'silent', 'empty', 'dry', 'dark',
'summer', 'icy', 'delicate', 'quiet', 'white', 'cool', 'spring', 'winter',
'patient', 'twilight', 'dawn', 'crimson', 'wispy', 'weathered', 'blue',
'billowing', 'broken', 'cold', 'damp', 'falling', 'frosty', 'green', 'long',
'late', 'lingering', 'bold', 'little', 'morning', 'muddy', 'old', 'red',
'rough', 'still', 'small', 'sparkling', 'shy', 'wandering',
'withered', 'wild', 'black', 'young', 'holy', 'solitary', 'fragrant',
'aged', 'snowy', 'proud', 'floral', 'restless', 'divine', 'polished',
'ancient', 'purple', 'lively', 'nameless'
];
const nouns = [
'waterfall', 'river', 'breeze', 'moon', 'rain', 'wind', 'sea', 'morning',
'snow', 'lake', 'sunset', 'pine', 'shadow', 'leaf', 'dawn', 'glitter',
'forest', 'hill', 'cloud', 'meadow', 'sun', 'glade', 'bird', 'brook',
'butterfly', 'bush', 'dew', 'dust', 'field', 'fire', 'flower', 'firefly',
'feather', 'grass', 'haze', 'mountain', 'night', 'pond', 'darkness',
'snowflake', 'silence', 'sound', 'sky', 'shape', 'surf', 'thunder',
'violet', 'water', 'wildflower', 'wave', 'water', 'resonance', 'sun',
'wood', 'dream', 'cherry', 'tree', 'fog', 'frost', 'voice', 'paper', 'frog',
'smoke', 'star'
];
const adjective = adjectives[Math.floor(Math.random() * adjectives.length)];
const noun = nouns[Math.floor(Math.random() * nouns.length)];
return adjective + noun;
}
function randomColor() {
return '#' + Math.floor(Math.random() * 0xFFFFFF).toString(16);
}
function onSendMessage(message) {
const newMessage = {
data: message,
member: me
}
setMessages([...messages, newMessage])
}
let drone = null;
export default function Home() {
const [messages, setMessages] = useState([]);
const [members, setMembers] = useState([]);
const [me, setMe] = useState({
username: randomName(),
color: randomColor(),
});
const messagesRef = useRef();
messagesRef.current = messages;
const membersRef = useRef();
membersRef.current = members;
const meRef = useRef();
meRef.current = me;
function connectToScaledrone() {
drone = new window.Scaledrone('YOUR-CHANNEL-ID', {
data: meRef.current,
});
drone.on('open', error => {
if (error) {
return console.error(error);
}
meRef.current.id = drone.clientId;
setMe(meRef.current);
});
const room = drone.subscribe('observable-room');
room.on('message', message => {
const {data, member} = message;
setMessages([...messagesRef.current, message]);
});
room.on('members', members => {
setMembers(members);
});
room.on('member_join', member => {
setMembers([...membersRef.current, member]);
});
room.on('member_leave', ({id}) => {
const index = membersRef.current.findIndex(m => m.id === id);
const newMembers = [...membersRef.current];
newMembers.splice(index, 1);
setMembers(newMembers);
});
}
useEffect(() => {
if (drone === null) {
connectToScaledrone();
}
}, []);
function onSendMessage(message) {
drone.publish({
room: 'observable-room',
message
});
}
return (
<>
<Head>
<title>Scaledrone Chat App</title>
<meta name='description' content='Your brand-new chat app!' />
<meta name='viewport' content='width=device-width, initial-scale=1' />
<script type='text/javascript' src='https://cdn.scaledrone.com/scaledrone.min.js' />
<link rel='icon' href='/favicon.ico' />
</Head>
<main className={styles.app}>
<div className={styles.appContent}>
<Members members={members} me={me}/>
<Messages messages={messages} me={me}/>
<Input
onSendMessage={onSendMessage}
/>
</div>
</main>
</>
)
}
複数のブラウザでhttp://localhost:3000/
にアクセスし、
チャットができるか確認してみましょう。
リアルタイムにメッセージが送受信できたら、成功です!!
おわりに
WebSocket用のサーバーを自分で用意せず、簡単にチャットアプリを構築できました🎊
ですが、ユーザー名やアバター画像を任意に設定できるようにしたり、
ファイルを添付できるようにしたり、まだまだ拡張できる要素はありますね。
チュートリアルには、タイピング中の状況を表示する機能の追加手順も紹介されています。
WebSocketによるリアルタイムサービスは、チャット以外での利用も面白そうです!
Scaledroneの価格表
フリープランだと同時接続数が最大20で、1日のイベント数にも制限があります。
詳細は料金プランのページを参照ください。
※2023年12月時点
プラン | 月額料金 | 同時接続数 | 1日あたりのイベント数 | 優先サポート |
---|---|---|---|---|
FREE | 無料 | 20 | 10万 | いいえ |
HOBBY | $19 | 100 | 20万 | いいえ |
STARTUP | $29 | 500 | 100万 | いいえ |
PRO | $49 | 2,000 | 400万 | いいえ |
BUSINESS | $149 | 5,000 | 1,000万 | はい |
追記
関連情報として、JavaScriptでのWebSocket実装例を記載した記事
「JavaScript+ScaledroneでWebSocketの同期処理を作る」を公開しました。
Discussion