切换语言
切换主题

Next.js 实时聊天应用:WebSocket 和 SSE 的正确打开方式

凌晨一点,我盯着电脑屏幕上那个闪烁的光标,第三次尝试让 Next.js 支持 WebSocket。页面报错:“WebSocket is not supported in this environment”。我揉了揉眼睛——刚才聊天功能明明在本地跑得好好的,一部署到 Vercel 就翻车了。

那天晚上,我才意识到一个残酷的事实:Next.js 的实时通信,远没有想象中那么简单。

这篇文章不会教你怎么把 WebSocket 硬塞进 Next.js(那条路我走过,不通)。我会分享真实踩过的坑:为什么 Vercel 不支持 WebSocket、SSE 是不是救命稻草、Socket.io 怎么和 App Router 和平共处。如果你也在头疼 Next.js 的实时功能,希望这篇文章能让你少走点弯路。

三种实时通信方案对比

聊天室、协同编辑、实时通知——这些功能背后都需要服务端主动推送数据给客户端。HTTP 的请求-响应模式解决不了这个问题,所以我们有三种主流方案。

WebSocket:全双工的梦想

WebSocket 是最理想的方案——建立一次连接,客户端和服务端就能随时互发消息。你可能会想,那还犹豫啥?

我当时也这么想。直到部署的那一刻。

Vercel、Netlify 这些 Serverless 平台不支持长连接。你的 Next.js 应用跑在云函数上,请求一结束,函数就被回收了——哪里维持得了 WebSocket 连接?我试过用第三方 WebSocket 服务(比如 Pusher、Ably),但月费劝退了我。

话说回来,WebSocket 也不是不能用。自己租服务器部署,或者用单独的 Node.js 后端跑 WebSocket 服务——这两条路都能走通。只是成本高一些,架构也复杂了。

SSE:单向通道救急

Server-Sent Events,听名字就知道:单向的,只能服务端推送给客户端。

这听起来有点寒酸——发消息还得走 HTTP POST,收消息用 SSE。不过换个角度想,这恰好适合很多场景:聊天室里你发消息是主动的,收消息是被动的;实时通知更是只需要服务端推送。

最重要的是:SSE 在 Vercel 上能跑

我之前做了个简单的通知系统,用 SSE 解决了 Vercel 部署的大问题。虽然有些限制(Vercel 的 Edge Function 有 25 秒超时),但对于消息推送已经够用。

Long Polling:老派但管用

这是最古老的方案:客户端发请求,服务端等到有新消息才返回,然后客户端立刻发下一个请求。

性能不好,流量也浪费。不过如果你的用户就几百人,消息也不频繁,Long Polling 其实挺好使——代码简单,没有兼容性问题,Serverless 平台也不拒绝。

说实话,我见过不少小项目就用 Long Polling 撑着,用户体验也没啥问题。别被”技术债”这个词吓住,能解决问题就是好方案。

对比表格:一目了然

WebSocket 连接开销
一次握手,持久连接
简单
SSE 实现复杂度
浏览器原生支持 EventSource
全部
Long Polling 兼容性
支持所有浏览器
SSE 优先
Vercel 部署友好度
WebSocket 不支持
特性WebSocketSSELong Polling
双向通信❌(需配合POST)
Vercel支持✅(有超时限制)
浏览器兼容现代浏览器现代浏览器全部
连接开销
实现复杂度中等简单非常简单
适合场景聊天、游戏、协同编辑通知、实时更新低频消息、兼容性要求高

你可能注意到了:在 Next.js + Vercel 的组合下,SSE 和 Long Polling 才是主流。这不是技术倒退,而是在平台限制下找到的务实方案。

Next.js 环境下的实时通信方案选型

知道三种方案是一回事,选哪个又是另一回事。这里我总结了几个决策点——都是踩过坑之后才明白的。

部署平台是第一道分水岭

如果你用 Vercel、Netlify、Cloudflare Pages 这些 Serverless 平台,WebSocket 基本没戏。你有三个选择:

  1. SSE 方案:适合单向推送场景(通知、实时更新、聊天室消息接收)
  2. Long Polling:适合低频双向通信
  3. 独立 WebSocket 服务:租一台小服务器单独跑 WebSocket,Next.js 应用还是部署在 Serverless 平台上

我自己做项目的时候,除非是真的高频双向通信(比如多人协同编辑),否则都选 SSE。部署简单,钱包也安全。

如果你是自己租服务器(VPS、Docker 部署),那 WebSocket 随便用——不过这样的话,你也要自己操心负载均衡、进程管理这些事。

用户量决定架构复杂度

你的应用有多少用户同时在线?

  • < 100 人:Long Polling 足够用,代码简单到你可以一个小时写完
  • 100-1000 人:SSE 或者简单的 WebSocket 方案
  • > 1000 人:需要消息队列(Redis Pub/Sub)、负载均衡、多实例部署

我见过一个创业团队,产品上线第一版用的 Long Polling,后来用户涨到 500 才换成 SSE。这样挺好——前期快速验证想法,后期再优化性能。

消息频率影响方案选择

如果是每分钟才几条消息的通知系统,Long Polling 完全够用。如果是聊天室,几十个人同时发消息,SSE 或 WebSocket 才合适。

还有个容易忽略的点:浏览器对同一域名的 HTTP 连接数有限制(通常是 6 个)。如果你用 Long Polling 或 SSE,打开多个标签页可能会卡住。这时候要么用 WebSocket,要么做单标签页检测。

预算也是关键因素

第三方 WebSocket 服务(Pusher、Ably、PubNub)确实方便,但按消息量计费。我之前算过,一个 500 人在线的聊天室,一个月光 WebSocket 费用就要 $49-$99。

自己部署的话:

  • Vercel + SSE:免费额度很大,小项目不花钱
  • VPS + WebSocket:$5/月起步(Vultr、DigitalOcean)
  • Railway/Render:支持 WebSocket,$5-$10/月

我的决策树(供参考)

部署在 Vercel?
├─ 是 → 用户 > 1000?
│   ├─ 是 → SSE + Redis Pub/Sub
│   └─ 否 → 简单 SSE 或 Long Polling
└─ 否(自托管)→ 需要双向高频通信?
    ├─ 是 → WebSocket + Socket.io
    └─ 否 → SSE

说到底,技术选型没有绝对的对错。我自己的经验是:先用最简单的方案上线,等到真的扛不住了再优化。那些一开始就上 WebSocket 集群的项目,很多最后连 100 个用户都没有。

Socket.io 集成实战

如果你决定用 WebSocket(比如自己租服务器部署),Socket.io 是最成熟的库。它会自动降级到 Long Polling,还内置了断线重连、房间管理这些功能。

不过在 Next.js 里集成 Socket.io,坑比想象中多。App Router 的 Route Handler 不支持 res.socket,你得用 Custom Server。

第一步:创建 Custom Server

Next.js 默认的启动方式不支持 WebSocket,我们需要自定义服务器。在项目根目录创建 server.js

// server.js
const { createServer } = require('http');
const { parse } = require('url');
const next = require('next');
const { Server } = require('socket.io');

const dev = process.env.NODE_ENV !== 'production';
const hostname = 'localhost';
const port = 3000;

const app = next({ dev, hostname, port });
const handle = app.getRequestHandler();

app.prepare().then(() => {
  const httpServer = createServer((req, res) => {
    const parsedUrl = parse(req.url, true);
    handle(req, res, parsedUrl);
  });

  // 初始化 Socket.io
  const io = new Server(httpServer, {
    cors: {
      origin: dev ? 'http://localhost:3000' : 'https://yourdomain.com',
      methods: ['GET', 'POST']
    }
  });

  // 连接处理
  io.on('connection', (socket) => {
    console.log('用户连接:', socket.id);

    // 加入房间
    socket.on('join_room', (roomId) => {
      socket.join(roomId);
      console.log(`用户 ${socket.id} 加入房间 ${roomId}`);
    });

    // 接收消息
    socket.on('send_message', (data) => {
      // 发送给房间内所有人(包括自己)
      io.to(data.room).emit('receive_message', {
        id: Date.now(),
        user: data.user,
        message: data.message,
        timestamp: new Date().toISOString()
      });
    });

    socket.on('disconnect', () => {
      console.log('用户断开:', socket.id);
    });
  });

  httpServer.listen(port, (err) => {
    if (err) throw err;
    console.log(`> Ready on http://${hostname}:${port}`);
  });
});

修改 package.json

{
  "scripts": {
    "dev": "node server.js",
    "build": "next build",
    "start": "NODE_ENV=production node server.js"
  }
}

第二步:客户端连接

创建一个 Hook 封装 Socket.io 客户端逻辑:

// hooks/useSocket.ts
'use client';

import { useEffect, useState } from 'react';
import io, { Socket } from 'socket.io-client';

export function useSocket() {
  const [socket, setSocket] = useState<Socket | null>(null);
  const [isConnected, setIsConnected] = useState(false);

  useEffect(() => {
    const socketInstance = io('http://localhost:3000', {
      transports: ['websocket', 'polling'] // 优先 WebSocket,降级到 polling
    });

    socketInstance.on('connect', () => {
      console.log('Socket 已连接');
      setIsConnected(true);
    });

    socketInstance.on('disconnect', () => {
      console.log('Socket 已断开');
      setIsConnected(false);
    });

    setSocket(socketInstance);

    return () => {
      socketInstance.disconnect();
    };
  }, []);

  return { socket, isConnected };
}

第三步:聊天组件

// app/chat/page.tsx
'use client';

import { useState, useEffect } from 'react';
import { useSocket } from '@/hooks/useSocket';

interface Message {
  id: number;
  user: string;
  message: string;
  timestamp: string;
}

export default function ChatPage() {
  const { socket, isConnected } = useSocket();
  const [messages, setMessages] = useState<Message[]>([]);
  const [inputMessage, setInputMessage] = useState('');
  const [username] = useState(`用户${Math.floor(Math.random() * 1000)}`);
  const roomId = 'general'; // 固定房间,实际项目可以动态生成

  useEffect(() => {
    if (!socket) return;

    // 加入房间
    socket.emit('join_room', roomId);

    // 监听新消息
    socket.on('receive_message', (data: Message) => {
      setMessages((prev) => [...prev, data]);
    });

    return () => {
      socket.off('receive_message');
    };
  }, [socket, roomId]);

  const sendMessage = () => {
    if (!socket || !inputMessage.trim()) return;

    socket.emit('send_message', {
      room: roomId,
      user: username,
      message: inputMessage
    });

    setInputMessage('');
  };

  return (
    <div className="max-w-2xl mx-auto p-4">
      <div className="mb-4">
        <span className={`inline-block w-3 h-3 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
        <span className="ml-2">{isConnected ? '已连接' : '未连接'}</span>
      </div>

      <div className="border rounded-lg p-4 h-96 overflow-y-auto mb-4 bg-gray-50">
        {messages.map((msg) => (
          <div key={msg.id} className="mb-2">
            <span className="font-semibold text-blue-600">{msg.user}:</span>
            <span className="ml-2">{msg.message}</span>
            <span className="ml-2 text-xs text-gray-500">
              {new Date(msg.timestamp).toLocaleTimeString()}
            </span>
          </div>
        ))}
      </div>

      <div className="flex gap-2">
        <input
          type="text"
          value={inputMessage}
          onChange={(e) => setInputMessage(e.target.value)}
          onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
          placeholder="输入消息..."
          className="flex-1 border rounded px-3 py-2"
        />
        <button
          onClick={sendMessage}
          disabled={!isConnected}
          className="bg-blue-500 text-white px-6 py-2 rounded disabled:bg-gray-300"
        >
          发送
        </button>
      </div>
    </div>
  );
}

我踩过的坑

  1. 热重载问题:开发时每次保存代码,Socket 连接会断开。这是 Next.js 热重载的副作用,没法完全避免,适应就好。

  2. CORS 错误:如果客户端和服务端端口不一样,记得在 Socket.io 配置里加 cors 选项。

  3. TypeScript 类型:要安装 @types/socket.io-client,不然类型提示会很惨。

  4. 部署注意:Custom Server 不能部署到 Vercel!你得用 VPS 或者支持 WebSocket 的平台(Railway、Render)。

SSE(Server-Sent Events)实现方案

SSE 是我在 Vercel 上做实时功能的救星。代码比 WebSocket 简单一大截,还不需要 Custom Server。

服务端:Route Handler 实现 SSE

App Router 的 Route Handler 可以返回 ReadableStream,这正好适合 SSE:

// app/api/sse/route.ts
import { NextRequest } from 'next/server';

// 模拟消息队列(实际项目用 Redis Pub/Sub)
const messageQueue: { id: string; message: string }[] = [];
const listeners = new Set<(message: any) => void>();

export async function GET(request: NextRequest) {
  // 创建可读流
  const stream = new ReadableStream({
    start(controller) {
      const encoder = new TextEncoder();

      // 发送初始连接消息
      controller.enqueue(
        encoder.encode(`data: ${JSON.stringify({ type: 'connected' })}\n\n`)
      );

      // 监听新消息
      const listener = (message: any) => {
        controller.enqueue(
          encoder.encode(`data: ${JSON.stringify(message)}\n\n`)
        );
      };

      listeners.add(listener);

      // 定期发送心跳,防止连接断开
      const heartbeat = setInterval(() => {
        controller.enqueue(encoder.encode(`: heartbeat\n\n`));
      }, 15000); // 每 15 秒一次

      // 清理函数
      request.signal.addEventListener('abort', () => {
        listeners.delete(listener);
        clearInterval(heartbeat);
        controller.close();
      });
    }
  });

  // 返回 SSE 响应
  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive'
    }
  });
}

// 发送消息的 POST 接口
export async function POST(request: NextRequest) {
  const body = await request.json();

  const message = {
    id: Date.now().toString(),
    user: body.user,
    message: body.message,
    timestamp: new Date().toISOString()
  };

  // 通知所有监听者
  listeners.forEach(listener => listener(message));

  return Response.json({ success: true });
}

客户端:EventSource 消费 SSE

// app/sse-chat/page.tsx
'use client';

import { useState, useEffect, useRef } from 'react';

interface Message {
  id: string;
  user: string;
  message: string;
  timestamp: string;
}

export default function SSEChatPage() {
  const [messages, setMessages] = useState<Message[]>([]);
  const [inputMessage, setInputMessage] = useState('');
  const [isConnected, setIsConnected] = useState(false);
  const [username] = useState(`用户${Math.floor(Math.random() * 1000)}`);
  const eventSourceRef = useRef<EventSource | null>(null);

  useEffect(() => {
    // 建立 SSE 连接
    const eventSource = new EventSource('/api/sse');
    eventSourceRef.current = eventSource;

    eventSource.onopen = () => {
      console.log('SSE 连接已建立');
      setIsConnected(true);
    };

    eventSource.onmessage = (event) => {
      const data = JSON.parse(event.data);

      if (data.type === 'connected') {
        console.log('收到服务端连接确认');
        return;
      }

      // 收到新消息
      setMessages((prev) => [...prev, data]);
    };

    eventSource.onerror = () => {
      console.error('SSE 连接错误');
      setIsConnected(false);
    };

    // 清理函数
    return () => {
      eventSource.close();
    };
  }, []);

  const sendMessage = async () => {
    if (!inputMessage.trim()) return;

    try {
      await fetch('/api/sse', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          user: username,
          message: inputMessage
        })
      });

      setInputMessage('');
    } catch (error) {
      console.error('发送消息失败:', error);
    }
  };

  return (
    <div className="max-w-2xl mx-auto p-4">
      <div className="mb-4">
        <span className={`inline-block w-3 h-3 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
        <span className="ml-2">{isConnected ? 'SSE 已连接' : 'SSE 未连接'}</span>
      </div>

      <div className="border rounded-lg p-4 h-96 overflow-y-auto mb-4 bg-gray-50">
        {messages.map((msg) => (
          <div key={msg.id} className="mb-2">
            <span className="font-semibold text-purple-600">{msg.user}:</span>
            <span className="ml-2">{msg.message}</span>
            <span className="ml-2 text-xs text-gray-500">
              {new Date(msg.timestamp).toLocaleTimeString()}
            </span>
          </div>
        ))}
      </div>

      <div className="flex gap-2">
        <input
          type="text"
          value={inputMessage}
          onChange={(e) => setInputMessage(e.target.value)}
          onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
          placeholder="输入消息..."
          className="flex-1 border rounded px-3 py-2"
        />
        <button
          onClick={sendMessage}
          disabled={!isConnected}
          className="bg-purple-500 text-white px-6 py-2 rounded disabled:bg-gray-300"
        >
          发送
        </button>
      </div>
    </div>
  );
}

SSE 的真实使用体验

坦白说,SSE 不是完美方案。我在用的时候发现了几个问题:

  1. Vercel 有 25 秒超时:Edge Function 超过 25 秒会被强制断开。我的做法是让客户端检测断开后自动重连。

  2. 浏览器连接数限制:同一个域名最多 6 个 HTTP/1.1 连接。如果你开了多个标签页,可能会卡住。解决办法是用 HTTP/2(Vercel 默认支持)或者做单标签页检测。

  3. 消息广播问题:上面的代码在单实例下能工作,但 Vercel 是多实例部署的。要实现真正的消息广播,你得用 Redis Pub/Sub 或者 Upstash。

用 Redis 实现跨实例消息广播

// lib/redis.ts
import { Redis } from '@upstash/redis';

export const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!
});

// app/api/sse/route.ts(改进版)
import { redis } from '@/lib/redis';

export async function GET(request: NextRequest) {
  const stream = new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder();

      // Redis 订阅
      const channelName = 'chat_messages';

      // 轮询 Redis(Upstash 不支持原生 SUBSCRIBE)
      const interval = setInterval(async () => {
        const messages = await redis.lrange(channelName, 0, -1);
        // 处理消息...
      }, 1000);

      request.signal.addEventListener('abort', () => {
        clearInterval(interval);
        controller.close();
      });
    }
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache'
    }
  });
}

什么时候用 SSE?

我的建议是:

  • ✅ 实时通知系统
  • ✅ 股票价格推送
  • ✅ 日志实时查看
  • ✅ 进度条更新
  • ❌ 高频双向聊天(用 WebSocket)
  • ❌ 在线游戏(用 WebSocket)

消息状态与数据同步

实时通信不只是收发消息这么简单。用户会问:我的消息发出去了吗?对方看到了吗?网断了消息会丢吗?

这些问题背后都是消息状态管理和数据同步。我自己做聊天功能时,在这上面卡了好几天。

消息的四种状态

参考微信的设计,消息至少有四种状态:

  1. 发送中:用户点击发送,还没收到服务器响应
  2. 已发送:服务器收到了,但对方可能还没收到
  3. 已送达:对方的客户端收到了
  4. 发送失败:网络错误或者服务器拒绝

我们用一个简单的状态机管理:

// types/message.ts
export type MessageStatus = 'sending' | 'sent' | 'delivered' | 'failed';

export interface Message {
  id: string;
  localId: string; // 客户端生成的临时 ID
  user: string;
  content: string;
  timestamp: string;
  status: MessageStatus;
}

乐观更新 + 重试机制

不要等服务器响应才显示消息——这样用户体验很差。我们用乐观更新:发送前先显示”发送中”状态,服务器成功响应后更新状态。

// hooks/useChat.ts
'use client';

import { useState } from 'react';
import { Message, MessageStatus } from '@/types/message';

export function useChat() {
  const [messages, setMessages] = useState<Message[]>([]);

  const sendMessage = async (content: string, username: string) => {
    // 生成临时 ID
    const localId = `local_${Date.now()}_${Math.random()}`;

    // 乐观更新:立刻显示消息
    const tempMessage: Message = {
      id: '',
      localId,
      user: username,
      content,
      timestamp: new Date().toISOString(),
      status: 'sending'
    };

    setMessages((prev) => [...prev, tempMessage]);

    try {
      // 发送到服务器
      const response = await fetch('/api/messages', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ user: username, message: content })
      });

      const data = await response.json();

      // 更新为"已发送"
      setMessages((prev) =>
        prev.map((msg) =>
          msg.localId === localId
            ? { ...msg, id: data.id, status: 'sent' }
            : msg
        )
      );
    } catch (error) {
      // 发送失败
      setMessages((prev) =>
        prev.map((msg) =>
          msg.localId === localId ? { ...msg, status: 'failed' } : msg
        )
      );
    }
  };

  const retryMessage = async (localId: string) => {
    const message = messages.find((msg) => msg.localId === localId);
    if (!message) return;

    // 重置状态为"发送中"
    setMessages((prev) =>
      prev.map((msg) =>
        msg.localId === localId ? { ...msg, status: 'sending' } : msg
      )
    );

    // 重新发送
    await sendMessage(message.content, message.user);
  };

  return { messages, sendMessage, retryMessage };
}

断线重连与消息持久化

网络不稳定时,连接会断开。重连后怎么保证消息不丢?

我的方案是:客户端用 IndexedDB 存储未确认的消息,重连后重新发送

// lib/indexedDB.ts
const DB_NAME = 'ChatDB';
const STORE_NAME = 'pendingMessages';

export async function openDB() {
  return new Promise<IDBDatabase>((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, 1);

    request.onerror = () => reject(request.error);
    request.onsuccess = () => resolve(request.result);

    request.onupgradeneeded = (event) => {
      const db = (event.target as IDBOpenDBRequest).result;
      if (!db.objectStoreNames.contains(STORE_NAME)) {
        db.createObjectStore(STORE_NAME, { keyPath: 'localId' });
      }
    };
  });
}

export async function savePendingMessage(message: Message) {
  const db = await openDB();
  const tx = db.transaction(STORE_NAME, 'readwrite');
  tx.objectStore(STORE_NAME).add(message);
}

export async function removePendingMessage(localId: string) {
  const db = await openDB();
  const tx = db.transaction(STORE_NAME, 'readwrite');
  tx.objectStore(STORE_NAME).delete(localId);
}

export async function getAllPendingMessages(): Promise<Message[]> {
  const db = await openDB();
  const tx = db.transaction(STORE_NAME, 'readonly');
  const store = tx.objectStore(STORE_NAME);

  return new Promise((resolve) => {
    const request = store.getAll();
    request.onsuccess = () => resolve(request.result);
  });
}

我的实战经验

  1. 不要一开始就追求完美:先实现基本收发,再加状态管理。
  2. 优先处理发送失败:用户最在意的是消息有没有发出去,已读功能反而不那么重要。
  3. IndexedDB 是救星:网络不好的时候,本地存储能救命。
  4. 测试断网场景:Chrome DevTools 的 Network 选项里可以模拟 Offline,多测几次。

生产环境部署与性能优化

开发环境跑得好好的,一上线就各种问题——这是实时通信最常见的遭遇。我整理了几个关键点,能帮你少踩坑。

部署平台选择与限制

前面提到过,Vercel 不支持 WebSocket。这里详细说说各平台的情况:

平台WebSocketSSE特殊限制
VercelEdge: 25s 超时;Serverless: 60s 超时
NetlifyFunction 10s 超时
Railway无硬性超时,按流量计费
Render免费版睡眠机制
Cloudflare PagesWorkers 有 CPU 时间限制
自托管VPS自己管理服务器

我的推荐

  • 预算紧张 + 低并发:Vercel + SSE(免费额度大)
  • 需要 WebSocket:Railway 或 Render($5-10/月)
  • 高并发 + 预算充足:自托管 VPS + Nginx 反向代理

Vercel 上的 SSE 优化

Vercel 的 Edge Function 只有 25 秒超时,怎么办?我的方案是自动重连 + 心跳保活

// hooks/useSSE.ts
'use client';

import { useEffect, useRef, useState } from 'react';

export function useSSE(url: string) {
  const [isConnected, setIsConnected] = useState(false);
  const eventSourceRef = useRef<EventSource | null>(null);
  const reconnectTimeoutRef = useRef<NodeJS.Timeout>();

  const connect = () => {
    const eventSource = new EventSource(url);
    eventSourceRef.current = eventSource;

    eventSource.onopen = () => {
      console.log('SSE 已连接');
      setIsConnected(true);
    };

    eventSource.onerror = () => {
      console.error('SSE 连接错误,3 秒后重连...');
      setIsConnected(false);
      eventSource.close();

      // 自动重连
      reconnectTimeoutRef.current = setTimeout(() => {
        connect();
      }, 3000);
    };

    return eventSource;
  };

  useEffect(() => {
    const eventSource = connect();

    return () => {
      if (reconnectTimeoutRef.current) {
        clearTimeout(reconnectTimeoutRef.current);
      }
      eventSource.close();
    };
  }, [url]);

  return { eventSource: eventSourceRef.current, isConnected };
}

多实例部署的消息同步

Vercel 会自动创建多个实例。用户 A 连接到实例 1,用户 B 连接到实例 2,他们怎么互发消息?

答案是:Redis Pub/Sub

我用 Upstash(无服务器 Redis)做过一个方案:

// lib/redis.ts
import { Redis } from '@upstash/redis';

export const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!
});

// 发送消息到 Redis
export async function publishMessage(channel: string, message: any) {
  await redis.lpush(channel, JSON.stringify(message));
  await redis.ltrim(channel, 0, 99); // 只保留最近 100 条
}

// 获取消息历史
export async function getRecentMessages(channel: string) {
  const messages = await redis.lrange(channel, 0, -1);
  return messages.map((m) => JSON.parse(m as string)).reverse();
}

性能优化清单

  1. 消息去重:客户端可能收到重复消息,用 Set 记录已收到的消息 ID。

  2. 虚拟滚动:消息超过 100 条后,用 react-windowreact-virtualized 渲染。

  3. 懒加载历史消息:不要一次加载所有历史,滚动到顶部时再加载。

  4. 限流保护:防止用户疯狂发消息,客户端和服务端都要做限制。

// 简单的客户端限流
let lastSendTime = 0;
const SEND_INTERVAL = 500; // 500ms 内只能发一条

const sendMessage = async (content: string) => {
  const now = Date.now();
  if (now - lastSendTime < SEND_INTERVAL) {
    alert('发送太快了,请稍后再试');
    return;
  }

  lastSendTime = now;
  // ... 发送逻辑
};
  1. 监控与日志:用 Sentry 或者 LogRocket 追踪 SSE 连接失败、消息发送失败等错误。

成本控制

实时功能很烧钱,特别是 WebSocket 服务。几个省钱的办法:

  • 用 SSE 替代 WebSocket:能省下单独的 WebSocket 服务器成本
  • 消息合并推送:不要每条消息单独推送,每秒合并一次批量推送
  • Redis 用 Upstash:按请求计费,比自己搭 Redis 服务器便宜
  • CDN 静态资源:Next.js 应用的静态资源用 CDN,减轻服务器压力

我做过一个 500 人在线的聊天室,用 Vercel + Upstash,每月成本不到 $15。关键是方案选对了。

总结

回到文章开头那个凌晨翻车的场景——如果当时我知道这些,就不会在 WebSocket 上死磕了。

Next.js 的实时通信,核心就是务实选择

  • Vercel 部署?用 SSE,不要硬上 WebSocket
  • 预算有限?先用最简单的方案跑起来,别一开始就堆技术
  • 用户体验优先?消息状态管理和断线重连比炫技重要得多

这篇文章的代码你可以直接拿去用,但更重要的是理解背后的权衡逻辑。技术选型没有银弹,适合你项目的才是最好的。

如果你也在做实时功能,希望这篇文章能让你少走点弯路。有问题欢迎留言,我会尽量回复。

下一步行动:

  1. 先在本地跑通一个最简单的 SSE 示例
  2. 部署到 Vercel 测试一下
  3. 如果需要 WebSocket,再考虑 Railway 或自托管

技术债不可怕,可怕的是一开始就背上不必要的债。加油!

常见问题

为什么 Vercel 不支持 WebSocket?
Vercel 使用 Serverless 架构,应用跑在云函数上。云函数的特点是请求结束后立即回收资源,无法维持 WebSocket 需要的长连接。

解决方案有三种:
• 使用 SSE(Server-Sent Events)替代,Vercel 原生支持
• 租用独立服务器(VPS)或使用支持 WebSocket 的平台(Railway、Render)
• 使用第三方 WebSocket 服务(Pusher、Ably),但成本较高($49-99/月)

对于大多数场景,SSE 已经足够,且成本更低。
SSE 和 WebSocket 哪个更适合聊天应用?
取决于你的部署平台和需求:

• SSE 适合:单向推送为主的场景(实时通知、聊天室消息接收),部署在 Vercel/Netlify 等 Serverless 平台,用户量在 1000 以下
• WebSocket 适合:高频双向通信(多人协同编辑、在线游戏),自托管服务器,需要低延迟

聊天应用通常可以用 SSE(接收消息)+ HTTP POST(发送消息)的组合,成本更低、部署更简单。我做过 500 人在线聊天室,用 SSE + Upstash Redis,每月成本不到 $15。
如何处理 Vercel 的 25 秒超时限制?
Vercel Edge Function 有 25 秒超时限制,解决方案是客户端自动重连:

• 服务端发送心跳包:每 15 秒发送一次心跳,防止连接被判定为空闲
• 客户端检测断开:EventSource 的 onerror 事件监听连接中断
• 自动重连机制:检测到断开后 3 秒自动重新连接
• Redis 存储消息:确保重连后能获取未读消息

实际使用中,用户几乎感觉不到重连过程,体验和持久连接无异。
多实例部署如何实现消息同步?
Vercel 会自动创建多个实例,不同用户可能连接到不同实例。解决方案是使用 Redis Pub/Sub:

• 用户 A 发送消息 → 实例 1 写入 Redis
• 实例 1、2、3 都轮询 Redis 获取新消息
• 实例将消息推送给各自连接的用户

推荐使用 Upstash(无服务器 Redis),按请求计费,比自建 Redis 便宜。轮询间隔设置为 1 秒,平衡实时性和成本。
如何保证消息不丢失?
消息可能因网络问题丢失,完整解决方案包含:

• 乐观更新:发送前立即在界面显示,状态标记为"发送中"
• IndexedDB 本地存储:未确认的消息保存到本地数据库
• 服务器响应确认:收到成功响应后更新状态为"已发送"
• 断线重连恢复:重连后从 IndexedDB 读取未确认消息,自动重发
• 重试机制:发送失败的消息显示重发按钮,用户手动重试

参考微信的消息状态设计:发送中、已发送、已送达、发送失败四种状态。
Socket.io Custom Server 能部署到 Vercel 吗?
不能。Custom Server 需要长时间运行的 Node.js 进程,而 Vercel 只支持 Serverless 函数。

替代方案:
• 使用支持 WebSocket 的平台:Railway($5/月起)、Render($7/月起)
• 自托管 VPS:Vultr、DigitalOcean($5/月起)
• 混合部署:Next.js 应用部署到 Vercel,WebSocket 服务单独部署

如果必须用 Vercel,建议改用 SSE 方案,功能上能满足大多数实时通信需求。
实时聊天应用的性能瓶颈在哪里?
常见性能瓶颈和优化方案:

• 消息列表渲染:超过 100 条消息用虚拟滚动(react-window)
• 历史消息加载:懒加载,滚动到顶部时分页加载
• 客户端限流:500ms 内只能发一条消息,防止恶意刷屏
• 服务端限流:API 路由加速率限制(Rate Limiting)
• 消息去重:用 Set 记录已收到的消息 ID,避免重复渲染
• 连接数限制:HTTP/1.1 每个域名最多 6 个连接,用 HTTP/2 或单标签页检测

监控工具推荐:Sentry(错误追踪)、LogRocket(会话回放)、Vercel Analytics(性能监控)。

13 分钟阅读 · 发布于: 2026年1月7日 · 修改于: 2026年1月15日

评论

使用 GitHub 账号登录后即可评论

相关文章