こんにちは。ナナオです。
今回はReact + peer.jsを使ってグループチャットを実装しようと思います。
peer.jsとは?
Peer.jsはWebRTCを使ってP2Pを実現するためのツールです。
P2Pとは、サーバーを介さずにクライアント同士で直接データやり取りを行う方法のことです。
WebRTCは
とりあえず実装したもの
最初に作ったグループチャットページの実装は以下の通りです。
タイトルの通り、React + peer.jsと、CSSにはtailwindを使用しています。
また、グループの部屋IDはURLのハッシュから取得するようにしています。
グループは、グループの発行者とそれ以外のメンバーで構成されます。
(実装はほぼGeminiがやってくれました。便利すぎ。)
import { useState, useEffect, useRef } from 'react';
import Peer from 'peerjs';
import type { DataConnection } from 'peerjs';
const ChatApp = () => {
const [myId, setMyId] = useState('');
const [messages, setMessages] = useState<{ sender?: string; text: string; system?: boolean }[]>([]);
const [messageInput, setMessageInput] = useState('');
const [conns, setConns] = useState<{ [key: string]: DataConnection }>({}); // 接続中の全Peerを管理 {peerId: connection}
const [copyStatus, setCopyStatus] = useState('URLをコピー');
const peerRef = useRef<Peer>(null);
const scrollRef = useRef<HTMLDivElement>(null);
// 初期化処理
useEffect(() => {
const peer = new Peer();
peerRef.current = peer;
peer.on('open', (id) => {
setMyId(id);
// URLのハッシュ(#)からIDを取得
const targetId = window.location.hash.substring(1); // #を除いた文字列を取得
if (targetId) {
connectToPeer(targetId);
}
});
peer.on('connection', (conn) => {
setupConnection(conn);
});
return () => peer.destroy();
}, []);
// 常に最新メッセージまでスクロール
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages]);
const setupConnection = (conn: DataConnection) => {
conn.on('open', () => {
setConns(prev => ({ ...prev, [conn.peer]: conn }));
setMessages(prev => [...prev, { system: true, text: `${conn.peer.substring(0,5)}... が入室しました` }]);
});
conn.on('data', (data) => {
if (typeof data === 'string') {
// 複数人でメッセージやり取りをできるように
setMessages(prev => [...prev, { sender: conn.peer.substring(0,5), text: data }]);
}
});
conn.on('close', () => {
setMessages(prev => [...prev, { system: true, text: '誰かが退室しました' }]);
setConns(prev => {
const newConns = { ...prev };
delete newConns[conn.peer];
return newConns;
});
});
};
const connectToPeer = (id: string) => {
if (conns[id] || !peerRef.current) return; // 接続済みならスキップ
const conn = peerRef.current.connect(id);
setupConnection(conn);
};
const sendMessage = (e) => {
e.preventDefault();
// 接続している全員に送信(ブロードキャスト)
Object.values(conns).forEach(conn => {
if (conn.open) {
conn.send(messageInput);
}
});
setMessages(prev => [...prev, { sender: '自分', text: messageInput }]);
setMessageInput('');
};
// 招待用URLをコピーする関数
const copyInviteLink = () => {
const inviteUrl = `${window.location.origin}${window.location.pathname}#${myId}`;
navigator.clipboard.writeText(inviteUrl).then(() => {
setCopyStatus('コピー完了!');
setTimeout(() => setCopyStatus('URLをコピー'), 2000);
});
};
return (
<div className="flex flex-col h-screen bg-slate-900 p-4 text-slate-100">
<div className="max-w-2xl mx-auto w-full flex flex-col h-full bg-slate-800 rounded-xl shadow-2xl overflow-hidden">
{/* ヘッダーセクション */}
<div className="bg-slate-800 p-5 text-white">
<h1 className="text-xl font-bold mb-2 text-center text-indigo-400">P2P Chat</h1>
<p className="text-[10px] text-slate-400">参加人数: {Object.keys(conns).length + 1}名</p>
<div className="bg-slate-700 rounded-lg p-3 flex flex-col gap-2">
<div className="text-[10px] uppercase tracking-wider text-slate-400 font-semibold">Your ID</div>
<div className="text-sm font-mono break-all text-indigo-200">{myId || '発行中...'}</div>
{myId && (
<button
onClick={copyInviteLink}
className="mt-1 w-full bg-indigo-600 hover:bg-indigo-500 text-white text-xs py-2 rounded transition-colors font-bold"
>
{copyStatus}
</button>
)}
</div>
</div>
{/* チャットログ */}
<div ref={scrollRef} className="flex-1 overflow-y-auto p-4 space-y-4 bg-slate-900/50">
{messages.map((msg, i) => (
<div key={i} className={`flex flex-col ${msg.sender === '自分' ? 'items-end' : 'items-start'}`}>
{msg.system ? (
<span className="text-[10px] text-slate-500 italic mx-auto my-2 uppercase tracking-widest">{msg.text}</span>
) : (
<>
<span className="text-[10px] text-slate-400 mb-1 ml-1">{msg.sender}</span>
<div className={`px-4 py-2 rounded-2xl text-sm max-w-[80%] break-all ${
msg.sender === '自分' ? 'bg-indigo-600 text-white rounded-tr-none' : 'bg-slate-700 text-slate-100 rounded-tl-none border border-slate-600'
}`}>
{msg.text}
</div>
</>
)}
</div>
))}
</div>
{/* 入力エリア */}
<form onSubmit={sendMessage} className="p-4 bg-slate-800 border-t border-slate-700 flex gap-2">
<input
type="text"
className="flex-1 bg-slate-700 border-none rounded-lg px-4 py-3 text-sm text-white focus:ring-2 focus:ring-indigo-500 outline-none"
placeholder="全員にメッセージを送信..."
value={messageInput}
onChange={(e) => setMessageInput(e.target.value)}
/>
<button type="submit" className="bg-indigo-600 p-3 rounded-lg hover:bg-indigo-500 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 fill-current" viewBox="0 0 20 20">
<path d="M10.894 2.553a1 1 0 00-1.788 0l-7 14a1 1 0 001.169 1.409l5-1.429A1 1 0 009 15.571V11a1 1 0 112 0v4.571a1 1 0 00.725.962l5 1.428a1 1 0 001.17-1.408l-7-14z" />
</svg>
</button>
</form>
</div>
</div>
);
};
export default ChatApp;
ポイントとしては以下の通りです。
- 初期化処理
- Peerの初期化と破棄、コネクションの確立の処理を実装
- setupConnection関数
- 入室、メッセージの受信、退室の処理を実装
- connectToPeer関数
- 作成済みの部屋の入った時の処理を実装
- sendMessage関数
- メッセージ送信の処理を実装
ですが、この実装だとグループの発行者にはすべてのメッセージが届きますが、それ以外のメンバー同士でのやり取りができませんでした。
グループの発行者のIDは全員が知っていますが、それ以外のメンバーのIDはメンバー同士で知る術がないからですね。
ということで、実装を追加しました。
import { useState, useEffect, useRef } from 'react';
import Peer from 'peerjs';
import type { DataConnection } from 'peerjs';
const ChatApp = () => {
const [myId, setMyId] = useState('');
const [messages, setMessages] = useState<{ sender?: string; text: string; system?: boolean }[]>([]);
const [messageInput, setMessageInput] = useState('');
const [conns, setConns] = useState<{ [key: string]: DataConnection }>({}); // 接続中の全Peerを管理 {peerId: connection}
const [copyStatus, setCopyStatus] = useState('URLをコピー');
const peerRef = useRef<Peer>(null);
const connsRef = useRef<{ [key: string]: DataConnection }>({}); // レンダリングを跨いで最新の接続状態を保持
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const peer = new Peer();
peerRef.current = peer;
peer.on('open', (id) => {
setMyId(id);
// URLのハッシュ(#)からIDを取得
const targetId = window.location.hash.substring(1); // #を除いた文字列を取得
if (targetId) {
connectToPeer(targetId);
}
});
peer.on('connection', (conn) => {
setupConnection(conn);
});
return () => peer.destroy();
}, []);
// 常に最新メッセージまでスクロール
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages]);
const setupConnection = (conn: DataConnection) => {
conn.on('open', () => {
// 接続リストに追加
addConnection(conn);
setMessages(prev => [...prev, { system: true, text: `${conn.peer.substring(0,5)}... が入室しました` }]);
// 【リーダーの役割】新しい人が来たら、現在接続中の他の全員のIDを教える
const otherPeerIds = Object.keys(connsRef.current).filter(id => id !== conn.peer);
if (otherPeerIds.length > 0) {
conn.send({ type: 'member-list', ids: otherPeerIds });
}
});
conn.on('data', (data: { type: string; sender?: string; text?: string, ids?: string[] }) => {
if (data.type === 'chat') {
// メッセージの受信
setMessages(prev => [...prev, { sender: data.sender!, text: data.text! }]);
} else if (data.type === 'member-list') {
// 【新人の役割】リーダーから貰った名簿をもとに、全員に自分から接続する
data.ids!.forEach(id => {
if (!connsRef.current[id]) {
connectToPeer(id);
}
});
}
});
conn.on('close', () => {
delete connsRef.current[conn.peer];
setConns({ ...connsRef.current });
setMessages(prev => [...prev, { system: true, text: "誰かが退室しました" }]);
});
};
const addConnection = (conn: DataConnection) => {
connsRef.current[conn.peer] = conn;
setConns({ ...connsRef.current });
};
const connectToPeer = (id: string) => {
if (id === myId || connsRef.current[id] || !peerRef.current) return;
const conn = peerRef.current.connect(id);
setupConnection(conn);
};
const sendMessage = (e) => {
e.preventDefault();
if (!messageInput) return;
const payload = {
type: 'chat',
sender: myId.substring(0, 5),
text: messageInput
};
// 接続している全員に送信(ブロードキャスト)
Object.values(conns).forEach(conn => {
if (conn.open) {
conn.send(payload);
}
});
setMessages(prev => [...prev, { sender: '自分', text: messageInput }]);
setMessageInput('');
};
// 招待用URLをコピーする関数
const copyInviteLink = () => {
const inviteUrl = `${window.location.origin}${window.location.pathname}#${myId}`;
navigator.clipboard.writeText(inviteUrl).then(() => {
setCopyStatus('コピー完了!');
setTimeout(() => setCopyStatus('URLをコピー'), 2000);
});
};
ポイントはsetupConnection関数のopenハンドラとdataハンドラの部分です。
conn.on('open', () => {
// 接続リストに追加
addConnection(conn);
setMessages(prev => [...prev, { system: true, text: `${conn.peer.substring(0,5)}... が入室しました` }]);
// 【リーダーの役割】新しい人が来たら、現在接続中の他の全員のIDを教える
const otherPeerIds = Object.keys(connsRef.current).filter(id => id !== conn.peer);
if (otherPeerIds.length > 0) {
conn.send({ type: 'member-list', ids: otherPeerIds });
}
});
conn.on('data', (data: { type: string; sender?: string; text?: string, ids?: string[] }) => {
if (data.type === 'chat') {
// メッセージの受信
setMessages(prev => [...prev, { sender: data.sender!, text: data.text! }]);
} else if (data.type === 'member-list') {
// 【新人の役割】リーダーから貰った名簿をもとに、全員に自分から接続する
data.ids!.forEach(id => {
if (!connsRef.current[id]) {
connectToPeer(id);
}
});
}
});
リーダー(グループの発行者)が全員のIDを知っているのであれば、それを知らせてあげればよいという発想で実装しています。
(まぁこれも実装はほぼGeminiなわけですが)
これでやり取りが可能になりました。
感想
ということで、とりあえずチャットができました。
ほぼGemini頼りでしたが、ロジックの部分が理解できたのでよかったです。
公開しているので、良ければ見ていってください。