はじめに
現代のWebアプリケーションでは、リアルタイム通信がますます重要になっています。チャット、通知、共同編集、ライブダッシュボードなど、ユーザーに即座にフィードバックを提供する機能は、優れたユーザー体験に欠かせません。
この記事では、Next.jsでWebSocketを使用してリアルタイム通信を実現する方法を解説します。具体的には以下の内容を扱います:
- WebSocketの基本概念と従来のHTTP通信との違い
- ネイティブWebSocket APIを使った実装方法
- Socket.ioを使ったより高機能な実装方法
- 実践的なチャットアプリケーションの構築例
WebSocketとは
従来のHTTP通信との違い
従来のHTTP通信はリクエスト-レスポンスモデルに基づいています。クライアントがサーバーにリクエストを送り、サーバーがレスポンスを返すという一方向の通信です。サーバーから能動的にデータを送ることはできません。
【HTTP通信】
クライアント → リクエスト → サーバー
クライアント ← レスポンス ← サーバー
(サーバーからプッシュ不可)
一方、WebSocketは双方向(全二重)通信を実現するプロトコルです。一度接続を確立すると、クライアントとサーバーの両方から自由にデータを送受信できます。
【WebSocket通信】
クライアント ⇄ 常時接続 ⇄ サーバー
(双方向でいつでもデータ送受信可能)
WebSocketの特徴
| 特徴 | 説明 |
|---|---|
| 双方向通信 | クライアントとサーバーの両方からデータを送信可能 |
| 低レイテンシ | 接続を維持するため、毎回のハンドシェイクが不要 |
| 軽量 | HTTPヘッダーのオーバーヘッドがない |
| リアルタイム | イベント駆動でデータを即座に配信 |
ネイティブWebSocket APIでの実装
サーバー側の設定
まず、Node.jsのwsライブラリを使用してWebSocketサーバーを構築します。
# wsライブラリのインストール
npm install ws
以下は基本的なWebSocketサーバーの例です:
// server.js
const WebSocket = require('ws');
// ポート8080でWebSocketサーバーを起動
const wss = new WebSocket.Server({ port: 8080 });
// 接続中のクライアントを管理
const clients = new Set();
// クライアントが接続したときの処理
wss.on('connection', (ws) => {
console.log('新しいクライアントが接続しました');
// クライアントをセットに追加
clients.add(ws);
// 接続成功メッセージを送信
ws.send(JSON.stringify({
type: 'connection',
message: '接続が確立されました'
}));
// クライアントからメッセージを受信したときの処理
ws.on('message', (data) => {
const message = JSON.parse(data);
console.log('受信したメッセージ:', message);
// 全クライアントにブロードキャスト
clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({
type: 'message',
content: message.content,
timestamp: new Date().toISOString()
}));
}
});
});
// 接続が閉じられたときの処理
ws.on('close', () => {
console.log('クライアントが切断しました');
clients.delete(ws);
});
// エラー処理
ws.on('error', (error) => {
console.error('WebSocketエラー:', error);
});
});
console.log('WebSocketサーバーがポート8080で起動しました');
クライアント側の実装(Next.js)
Next.jsのコンポーネントでWebSocketクライアントを実装します。useEffectフックを使用して、コンポーネントのライフサイクルに合わせた接続管理を行います。
// components/Chat.tsx
'use client';
import { useEffect, useState, useCallback, useRef } from 'react';
// メッセージの型定義
interface Message {
type: string;
content?: string;
message?: string;
timestamp?: string;
}
export default function Chat() {
// メッセージ一覧の状態
const [messages, setMessages] = useState<Message[]>([]);
// 入力中のメッセージ
const [inputMessage, setInputMessage] = useState('');
// 接続状態
const [isConnected, setIsConnected] = useState(false);
// WebSocketインスタンスをrefで保持
const socketRef = useRef<WebSocket | null>(null);
// WebSocket接続を確立
useEffect(() => {
// WebSocketサーバーに接続
const socket = new WebSocket('ws://localhost:8080');
socketRef.current = socket;
// 接続成功時
socket.onopen = () => {
console.log('WebSocket接続が確立されました');
setIsConnected(true);
};
// メッセージ受信時
socket.onmessage = (event) => {
const data: Message = JSON.parse(event.data);
setMessages((prev) => [...prev, data]);
};
// 接続が閉じられた時
socket.onclose = () => {
console.log('WebSocket接続が閉じられました');
setIsConnected(false);
};
// エラー発生時
socket.onerror = (error) => {
console.error('WebSocketエラー:', error);
};
// コンポーネントアンマウント時に接続を閉じる
return () => {
socket.close();
};
}, []);
// メッセージ送信処理
const sendMessage = useCallback(() => {
if (socketRef.current?.readyState === WebSocket.OPEN && inputMessage.trim()) {
socketRef.current.send(JSON.stringify({
content: inputMessage
}));
setInputMessage('');
}
}, [inputMessage]);
// Enterキーでの送信
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
return (
<div className="chat-container">
{/* 接続状態の表示 */}
<div className={`status ${isConnected ? 'connected' : 'disconnected'}`}>
{isConnected ? '接続中' : '切断中'}
</div>
{/* メッセージ一覧 */}
<div className="messages">
{messages.map((msg, index) => (
<div key={index} className="message">
{msg.type === 'connection' && (
<span className="system-message">{msg.message}</span>
)}
{msg.type === 'message' && (
<div>
<span className="content">{msg.content}</span>
<span className="timestamp">
{msg.timestamp && new Date(msg.timestamp).toLocaleTimeString()}
</span>
</div>
)}
</div>
))}
</div>
{/* メッセージ入力フォーム */}
<div className="input-area">
<input
type="text"
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="メッセージを入力..."
disabled={!isConnected}
/>
<button onClick={sendMessage} disabled={!isConnected}>
送信
</button>
</div>
</div>
);
}
Socket.ioを使った実装
Socket.ioは、WebSocketを基盤としながら、より高機能で使いやすいリアルタイム通信ライブラリです。
Socket.ioの特徴
| 機能 | 説明 |
|---|---|
| 自動再接続 | 接続が切れた場合に自動的に再接続を試みる |
| フォールバック | WebSocketが使えない環境でもポーリングで動作 |
| ルーム機能 | クライアントをグループ化してブロードキャスト可能 |
| イベント駆動 | カスタムイベントを定義して通信できる |
| 名前空間 | 用途別に接続を分離できる |
Socket.ioのインストール
# サーバー側
npm install socket.io
# クライアント側
npm install socket.io-client
サーバー側の実装
Next.jsでSocket.ioを使用する場合、カスタムサーバーを設定します。
// server.js
const { createServer } = require('http');
const { Server } = require('socket.io');
const next = require('next');
const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev });
const handle = app.getRequestHandler();
app.prepare().then(() => {
// HTTPサーバーを作成
const httpServer = createServer((req, res) => {
handle(req, res);
});
// Socket.ioサーバーを作成
const io = new Server(httpServer, {
cors: {
origin: 'http://localhost:3000',
methods: ['GET', 'POST']
}
});
// 接続数を管理
let connectionCount = 0;
// クライアント接続時の処理
io.on('connection', (socket) => {
connectionCount++;
console.log(`クライアント接続: ${socket.id} (接続数: ${connectionCount})`);
// 接続者数を全員に通知
io.emit('userCount', connectionCount);
// チャットメッセージの受信
socket.on('chatMessage', (data) => {
console.log('メッセージ受信:', data);
// 全クライアントにブロードキャスト
io.emit('chatMessage', {
id: socket.id,
message: data.message,
username: data.username,
timestamp: new Date().toISOString()
});
});
// ルームへの参加
socket.on('joinRoom', (roomName) => {
socket.join(roomName);
console.log(`${socket.id} がルーム ${roomName} に参加`);
// ルーム内のユーザーに通知
socket.to(roomName).emit('userJoined', {
message: '新しいユーザーが参加しました'
});
});
// ルーム内へのメッセージ送信
socket.on('roomMessage', (data) => {
io.to(data.room).emit('roomMessage', {
id: socket.id,
message: data.message,
room: data.room,
timestamp: new Date().toISOString()
});
});
// タイピング中の通知
socket.on('typing', (data) => {
socket.broadcast.emit('userTyping', {
username: data.username,
isTyping: data.isTyping
});
});
// 切断時の処理
socket.on('disconnect', () => {
connectionCount--;
console.log(`クライアント切断: ${socket.id} (接続数: ${connectionCount})`);
io.emit('userCount', connectionCount);
});
});
// サーバーを起動
httpServer.listen(3000, () => {
console.log('サーバーがポート3000で起動しました');
});
});
クライアント側の実装
// components/SocketChat.tsx
'use client';
import { useEffect, useState, useCallback } from 'react';
import { io, Socket } from 'socket.io-client';
// メッセージの型定義
interface ChatMessage {
id: string;
message: string;
username: string;
timestamp: string;
}
// ユーザータイピング状態の型定義
interface TypingUser {
username: string;
isTyping: boolean;
}
export default function SocketChat() {
const [socket, setSocket] = useState<Socket | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [inputMessage, setInputMessage] = useState('');
const [username, setUsername] = useState('');
const [isConnected, setIsConnected] = useState(false);
const [userCount, setUserCount] = useState(0);
const [typingUsers, setTypingUsers] = useState<string[]>([]);
// Socket.io接続の初期化
useEffect(() => {
// Socket.ioクライアントを作成
const socketInstance = io('http://localhost:3000', {
// 自動再接続の設定
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000
});
// 接続成功
socketInstance.on('connect', () => {
console.log('Socket.io接続成功:', socketInstance.id);
setIsConnected(true);
});
// 接続エラー
socketInstance.on('connect_error', (error) => {
console.error('接続エラー:', error);
setIsConnected(false);
});
// 切断
socketInstance.on('disconnect', (reason) => {
console.log('切断:', reason);
setIsConnected(false);
});
// チャットメッセージ受信
socketInstance.on('chatMessage', (data: ChatMessage) => {
setMessages((prev) => [...prev, data]);
});
// 接続ユーザー数の更新
socketInstance.on('userCount', (count: number) => {
setUserCount(count);
});
// タイピング状態の更新
socketInstance.on('userTyping', (data: TypingUser) => {
setTypingUsers((prev) => {
if (data.isTyping) {
return [...prev.filter((u) => u !== data.username), data.username];
} else {
return prev.filter((u) => u !== data.username);
}
});
});
setSocket(socketInstance);
// クリーンアップ
return () => {
socketInstance.disconnect();
};
}, []);
// メッセージ送信
const sendMessage = useCallback(() => {
if (socket && inputMessage.trim() && username.trim()) {
socket.emit('chatMessage', {
message: inputMessage,
username: username
});
setInputMessage('');
// タイピング終了を通知
socket.emit('typing', { username, isTyping: false });
}
}, [socket, inputMessage, username]);
// タイピング中の通知
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputMessage(e.target.value);
if (socket && username) {
socket.emit('typing', {
username,
isTyping: e.target.value.length > 0
});
}
};
return (
<div className="socket-chat">
{/* ヘッダー情報 */}
<div className="header">
<span className={`status ${isConnected ? 'connected' : 'disconnected'}`}>
{isConnected ? '接続中' : '切断中'}
</span>
<span className="user-count">接続ユーザー: {userCount}人</span>
</div>
{/* ユーザー名入力 */}
{!username ? (
<div className="username-form">
<input
type="text"
placeholder="ユーザー名を入力"
onKeyPress={(e) => {
if (e.key === 'Enter') {
setUsername((e.target as HTMLInputElement).value);
}
}}
/>
</div>
) : (
<>
{/* メッセージ一覧 */}
<div className="messages">
{messages.map((msg, index) => (
<div
key={index}
className={`message ${msg.id === socket?.id ? 'own' : ''}`}
>
<span className="username">{msg.username}</span>
<span className="content">{msg.message}</span>
<span className="timestamp">
{new Date(msg.timestamp).toLocaleTimeString()}
</span>
</div>
))}
</div>
{/* タイピング表示 */}
{typingUsers.length > 0 && (
<div className="typing-indicator">
{typingUsers.join(', ')} が入力中...
</div>
)}
{/* 入力フォーム */}
<div className="input-area">
<input
type="text"
value={inputMessage}
onChange={handleInputChange}
onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
placeholder="メッセージを入力..."
disabled={!isConnected}
/>
<button onClick={sendMessage} disabled={!isConnected}>
送信
</button>
</div>
</>
)}
</div>
);
}
WebSocketとSocket.ioの比較
どちらを選ぶかは、プロジェクトの要件によって異なります。
| 観点 | WebSocket | Socket.io |
|---|---|---|
| 学習コスト | 低い(ネイティブAPI) | やや高い(独自API) |
| 機能 | シンプル | 豊富(再接続、ルームなど) |
| オーバーヘッド | 最小限 | やや大きい |
| ブラウザ互換性 | モダンブラウザのみ | 幅広くサポート |
| ユースケース | シンプルな双方向通信 | 複雑なリアルタイムアプリ |
選択の指針
WebSocket(ネイティブAPI)を選ぶ場合:
- シンプルな双方向通信が必要
- パフォーマンスが最優先
- 依存関係を最小限に抑えたい
Socket.ioを選ぶ場合:
- 自動再接続が必要
- ルーム機能やブロードキャストが必要
- 古いブラウザもサポートしたい
- 開発効率を重視
実践的なユースケース
1. リアルタイムチャット
複数のユーザーが同時にメッセージを送受信できるチャットアプリケーション。
2. ライブ通知
新しいコメントや「いいね」などをリアルタイムで通知するシステム。
3. 共同編集
GoogleドキュメントやFigmaのような、複数人が同時に編集できるツール。
4. ライブダッシュボード
株価やセンサーデータなど、リアルタイムに更新されるダッシュボード。
5. オンラインゲーム
マルチプレイヤーゲームでのプレイヤー位置の同期や対戦。
まとめ
この記事では、Next.jsでWebSocketを使用してリアルタイム通信を実現する方法を解説しました。
ポイント:
- WebSocketは双方向通信を実現する軽量なプロトコル
- ネイティブWebSocket APIはシンプルで低レイテンシ
- Socket.ioは再接続やルーム機能など高度な機能を提供
- プロジェクトの要件に応じて適切な技術を選択することが重要
リアルタイム通信を活用することで、ユーザー体験を大幅に向上させることができます。ぜひ、あなたのNext.jsアプリケーションにも取り入れてみてください。
参考文献
- MDN WebSocket API - WebSocket APIの公式ドキュメント
- Socket.io 公式ドキュメント - Socket.ioの詳細なドキュメント
- ws - npm - Node.js用WebSocketライブラリ
- Next.js カスタムサーバー - Next.jsでのカスタムサーバー設定