Documentation Next.js

はじめに

現代の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の比較

どちらを選ぶかは、プロジェクトの要件によって異なります。

観点WebSocketSocket.io
学習コスト低い(ネイティブAPI)やや高い(独自API)
機能シンプル豊富(再接続、ルームなど)
オーバーヘッド最小限やや大きい
ブラウザ互換性モダンブラウザのみ幅広くサポート
ユースケースシンプルな双方向通信複雑なリアルタイムアプリ

選択の指針

WebSocket(ネイティブAPI)を選ぶ場合:

  • シンプルな双方向通信が必要
  • パフォーマンスが最優先
  • 依存関係を最小限に抑えたい

Socket.ioを選ぶ場合:

  • 自動再接続が必要
  • ルーム機能やブロードキャストが必要
  • 古いブラウザもサポートしたい
  • 開発効率を重視

実践的なユースケース

1. リアルタイムチャット

複数のユーザーが同時にメッセージを送受信できるチャットアプリケーション。

2. ライブ通知

新しいコメントや「いいね」などをリアルタイムで通知するシステム。

3. 共同編集

GoogleドキュメントやFigmaのような、複数人が同時に編集できるツール。

4. ライブダッシュボード

株価やセンサーデータなど、リアルタイムに更新されるダッシュボード。

5. オンラインゲーム

マルチプレイヤーゲームでのプレイヤー位置の同期や対戦。

まとめ

この記事では、Next.jsでWebSocketを使用してリアルタイム通信を実現する方法を解説しました。

ポイント:

  • WebSocketは双方向通信を実現する軽量なプロトコル
  • ネイティブWebSocket APIはシンプルで低レイテンシ
  • Socket.ioは再接続やルーム機能など高度な機能を提供
  • プロジェクトの要件に応じて適切な技術を選択することが重要

リアルタイム通信を活用することで、ユーザー体験を大幅に向上させることができます。ぜひ、あなたのNext.jsアプリケーションにも取り入れてみてください。

参考文献

円