Đang tải...

Ếch Trendy

Cùng chú ếch nhỏ khám phá thế giới Web đầy biến động. Cập nhật trending nhanh như cách ếch đớp mồi! ⌨️🌿


0

Xây realtime chat app với Socket.io 4.x: Rooms và broadcasting cho dự án Việt

Ếch Trendy
Ếch Trendy

3 tháng trước · 8 phút đọc

WebSocket và Socket.io: Tại sao cần phân biệt?

Nhiều bạn mới học hay nhầm Socket.io = WebSocket. Thực ra không phải vậy.

WebSocket là giao thức - định nghĩa cách client và server giao tiếp hai chiều liên tục qua một connection duy nhất. Socket.io là thư viện xây trên WebSocket, nhưng thêm vào đó một đống tính năng mà native WebSocket không có: auto-reconnect, rooms, namespaces, fallback transport.

Nghe có vẻ Socket.io ngon hơn hoàn toàn. Nhưng có trade-off: bundle size ~200KB so với WebSocket gần như 0KB. Nếu bạn chỉ cần stream dữ liệu đơn giản tốc độ cao (ví dụ financial tick data), native WebSocket hợp lý hơn. Còn làm chat app với nhiều phòng, nhiều user, cần reconnect tự động - Socket.io là lựa chọn thực tế.

345716 Socket.io thêm rooms, reconnect, fallback - native WebSocket không có

Bài này dùng Socket.io 4.x (phiên bản ổn định mới nhất, 4.8.3 tính đến thời điểm viết). Nếu bạn thấy đâu đó có nói "Socket.io 5.0" - phiên bản đó chưa được phát hành chính thức, đừng tin.

Mình cũng giả định bạn đã quen JavaScript cơ bản và biết Node.js một chút. Nếu chưa, khóa Node & ExpressJS của F8 cover đúng nền tảng bạn cần trước khi đọc tiếp.


Cài đặt và khởi động server trong 5 phút

Mình bắt đầu với một Express server đơn giản tích hợp Socket.io.

mkdir chat-app && cd chat-app
npm init -y
npm install express socket.io

Tạo file server.js:

const express = require('express');
const { createServer } = require('http');
const { Server } = require('socket.io');

const app = express();
// Tạo HTTP server từ Express app
const httpServer = createServer(app);

// Khởi tạo Socket.io server với CORS cho dev
const io = new Server(httpServer, {
  cors: {
    origin: 'http://localhost:3000',
    methods: ['GET', 'POST']
  }
});

io.on('connection', (socket) => {
  console.log('User kết nối:', socket.id);

  socket.on('disconnect', () => {
    console.log('User ngắt kết nối:', socket.id);
  });
});

httpServer.listen(4000, () => {
  console.log('Server đang chạy tại http://localhost:4000');
});

345717 Luồng kết nối: client gọi io() → Socket.io chọn transport → WebSocket hoặc fallback

Một điểm cần chú ý: Socket.io không attach trực tiếp vào Express app, mà attach vào HTTP server. Đây là lỗi phổ biến khi mới bắt đầu. Bạn tạo httpServer từ createServer(app), rồi truyền httpServer vào new Server().

CORS config cũng quan trọng nếu frontend chạy ở port khác. Trong production, thay origin bằng domain thật.


Rooms: Chia người dùng vào phòng riêng

Đây là tính năng mình thấy hữu ích nhất của Socket.io khi làm dự án Việt - từ app chat nhóm đến hệ thống thông báo real-time.

Mỗi room trong Socket.io là một kênh ảo. Socket có thể join nhiều room cùng lúc. Khi server emit vào một room, chỉ những socket trong room đó nhận được message.

io.on('connection', (socket) => {
  // Client gửi yêu cầu vào phòng chat
  socket.on('join_room', (roomId) => {
    socket.join(roomId);
    console.log(`${socket.id} đã vào phòng ${roomId}`);

    // Thông báo cho CẢ PHÒNG (kể cả người vừa join)
    io.to(roomId).emit('system_message', {
      text: `Có thành viên mới tham gia phòng ${roomId}`
    });
  });

  // Nhận và broadcast tin nhắn trong phòng
  socket.on('send_message', ({ roomId, message, username }) => {
    // Gửi đến TẤT CẢ trong room, kể cả sender
    io.to(roomId).emit('receive_message', {
      username,
      message,
      timestamp: new Date().toISOString()
    });
  });

  // Rời phòng
  socket.on('leave_room', (roomId) => {
    socket.leave(roomId);
    socket.to(roomId).emit('system_message', {
      text: `Một thành viên đã rời phòng ${roomId}`
    });
  });
});

345718 Mỗi room là kênh độc lập - message phòng A không lọt sang phòng B

Có 2 cách broadcast quan trọng cần phân biệt:

  • io.to(roomId).emit(...) - gửi đến tất cả trong room, bao gồm sender
  • socket.to(roomId).emit(...) - gửi đến tất cả trong room trừ sender

Dùng cái nào? Khi người dùng gửi tin nhắn và bạn muốn họ thấy tin của chính mình ngay lập tức (optimistic UI), dùng io.to(). Khi thông báo "ai đó đang gõ...", dùng socket.to() để người gõ không nhận notification của chính họ.


Broadcasting: Gửi tin đến nhiều người cùng lúc

Broadcasting là gửi message đến nhiều client một lúc. Socket.io hỗ trợ vài pattern broadcasting khác nhau, mỗi cái có use case riêng.

// 1. Gửi đến TẤT CẢ client đang kết nối (kể cả sender)
io.emit('announcement', { text: 'Server sắp bảo trì' });

// 2. Gửi đến tất cả TRỪ sender
socket.broadcast.emit('new_user', { username: 'Minh' });

// 3. Gửi đến room cụ thể
io.to('room-123').emit('room_message', data);

// 4. Gửi đến NHIỀU room cùng lúc
io.to('room-A').to('room-B').emit('urgent_message', data);

// 5. Gửi đến 1 socket cụ thể (private message)
io.to(targetSocketId).emit('private_message', data);

5 pattern broadcasting - chọn đúng pattern tránh gửi nhầm message

Use case thực tế cho dự án Việt:

Chat nhóm nội bộ công ty: Mỗi team = 1 room. Dùng io.to(teamRoom).emit() để broadcast.

Live sale / TikTok Shop clone: Room live-{sessionId} cho mỗi phiên. Người xem join room, host broadcast số lượng tồn kho, giá flash sale, đơn hàng mới.

Hệ thống thông báo: Tạo room riêng cho từng user: user-{userId}. Khi có sự kiện (đơn hàng mới, tin nhắn mới), server emit vào room đó. User login từ nhiều thiết bị vẫn nhận được notification đầy đủ.

Game online đơn giản: Room game-{gameId} cho mỗi bàn chơi. Broadcast trạng thái game đến tất cả người chơi trong bàn.


Xây client React kết nối vào server

Bây giờ mình làm phía frontend. Mình dùng React vì đa số dự án Việt hiện nay chạy React.

npm install socket.io-client
// src/socket.js - Tạo socket instance dùng chung
import { io } from 'socket.io-client';

// Tạo 1 lần, dùng nhiều nơi
export const socket = io('http://localhost:4000', {
  autoConnect: false // Kết nối thủ công sau khi user đăng nhập
});
// src/components/ChatRoom.jsx
import { useEffect, useState } from 'react';
import { socket } from '../socket';

export default function ChatRoom({ roomId, username }) {
  const [messages, setMessages] = useState([]);
  const [input, setInput] = useState('');

  useEffect(() => {
    // Kết nối và vào phòng
    socket.connect();
    socket.emit('join_room', roomId);

    // Nhận tin nhắn
    socket.on('receive_message', (data) => {
      setMessages((prev) => [...prev, data]);
    });

    // Cleanup khi component unmount
    return () => {
      socket.emit('leave_room', roomId);
      socket.off('receive_message');
      socket.disconnect();
    };
  }, [roomId]);

  const sendMessage = () => {
    if (!input.trim()) return;
    socket.emit('send_message', { roomId, message: input, username });
    setInput('');
  };

  return (
    <div>
      <div className="messages">
        {messages.map((msg, i) => (
          <div key={i}>
            <strong>{msg.username}:</strong> {msg.message}
          </div>
        ))}
      </div>
      <input
        value={input}
        onChange={(e) => setInput(e.target.value)}
        onKeyDown={(e) => e.key === 'Enter' && sendMessage()}
      />
      <button onClick={sendMessage}>Gửi</button>
    </div>
  );
}

345719 Tách socket instance ra file riêng - tránh tạo nhiều connection dư thừa

Hai lỗi phổ biến mình hay thấy khi review code:

  1. Tạo socket trong component - mỗi lần re-render tạo thêm connection mới. Giải pháp: export socket từ file riêng như trên.
  2. Quên cleanup - socket.off('receive_message') trong return của useEffect. Nếu quên, event listener chồng chất, mỗi message nhận được sẽ trigger nhiều lần.

Nếu bạn muốn tìm hiểu sâu hơn về React hooks và patterns như thế này, khóa Xây Dựng Website với ReactJS của F8 cover phần useEffect và custom hooks khá kỹ.


Auto-reconnect và xử lý mất kết nối

Đây là phần nhiều tutorial bỏ qua, nhưng lại là thứ người dùng Việt cần nhất - mạng 3G chập chờn, WiFi yếu, chuyển mạng giữa chừng.

Socket.io 4.x có auto-reconnect mặc định với exponential backoff: thử lại sau 1s, 2s, 4s, 8s... đến khi kết nối được hoặc đạt reconnectionAttempts.

const socket = io('http://localhost:4000', {
  reconnection: true,          // Bật auto-reconnect (mặc định true)
  reconnectionAttempts: 5,     // Thử tối đa 5 lần
  reconnectionDelay: 1000,     // Bắt đầu sau 1 giây
  reconnectionDelayMax: 5000,  // Tối đa 5 giây giữa các lần thử
});

// Lắng nghe các sự kiện connection
socket.on('connect', () => {
  console.log('Kết nối thành công:', socket.id);
});

socket.on('disconnect', (reason) => {
  console.log('Mất kết nối:', reason);
  // reason có thể là: 'io server disconnect', 'transport close', 'ping timeout'
});

socket.on('connect_error', (error) => {
  console.log('Lỗi kết nối:', error.message);
});

socket.on('reconnect', (attemptNumber) => {
  console.log(`Kết nối lại thành công sau ${attemptNumber} lần thử`);
  // Nên join lại room ở đây vì socket.id thay đổi sau reconnect
  socket.emit('join_room', currentRoomId);
});

345720 Reconnect thất bại 5 lần → thông báo cho user, tránh chờ đợi vô tận

Một lưu ý quan trọng: sau khi reconnect, socket.id thay đổi. Điều này có nghĩa là server coi đây là connection hoàn toàn mới. Bạn cần emit join_room lại trong event reconnect để user không bị "mất phòng" sau khi mạng ổn định.

Về phía server, có thể bật packet buffering để giữ lại những message gửi đến một socket đang offline và deliver khi họ reconnect - nhưng đây là advanced topic phù hợp hơn khi bạn đã có bản chat chạy được.

Trang web dùng cookie để hoạt động và đo hiệu quả quảng cáo. Xem chính sách bảo mật