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 | Long Polling |
|---|---|---|---|
| 双向通信 | ✅ | ❌(需配合POST) | ✅ |
| Vercel支持 | ❌ | ✅(有超时限制) | ✅ |
| 浏览器兼容 | 现代浏览器 | 现代浏览器 | 全部 |
| 连接开销 | 低 | 低 | 高 |
| 实现复杂度 | 中等 | 简单 | 非常简单 |
| 适合场景 | 聊天、游戏、协同编辑 | 通知、实时更新 | 低频消息、兼容性要求高 |
你可能注意到了:在 Next.js + Vercel 的组合下,SSE 和 Long Polling 才是主流。这不是技术倒退,而是在平台限制下找到的务实方案。
Next.js 环境下的实时通信方案选型
知道三种方案是一回事,选哪个又是另一回事。这里我总结了几个决策点——都是踩过坑之后才明白的。
部署平台是第一道分水岭
如果你用 Vercel、Netlify、Cloudflare Pages 这些 Serverless 平台,WebSocket 基本没戏。你有三个选择:
- SSE 方案:适合单向推送场景(通知、实时更新、聊天室消息接收)
- Long Polling:适合低频双向通信
- 独立 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>
);
}我踩过的坑
热重载问题:开发时每次保存代码,Socket 连接会断开。这是 Next.js 热重载的副作用,没法完全避免,适应就好。
CORS 错误:如果客户端和服务端端口不一样,记得在 Socket.io 配置里加
cors选项。TypeScript 类型:要安装
@types/socket.io-client,不然类型提示会很惨。部署注意: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 不是完美方案。我在用的时候发现了几个问题:
Vercel 有 25 秒超时:Edge Function 超过 25 秒会被强制断开。我的做法是让客户端检测断开后自动重连。
浏览器连接数限制:同一个域名最多 6 个 HTTP/1.1 连接。如果你开了多个标签页,可能会卡住。解决办法是用 HTTP/2(Vercel 默认支持)或者做单标签页检测。
消息广播问题:上面的代码在单实例下能工作,但 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)
消息状态与数据同步
实时通信不只是收发消息这么简单。用户会问:我的消息发出去了吗?对方看到了吗?网断了消息会丢吗?
这些问题背后都是消息状态管理和数据同步。我自己做聊天功能时,在这上面卡了好几天。
消息的四种状态
参考微信的设计,消息至少有四种状态:
- 发送中:用户点击发送,还没收到服务器响应
- 已发送:服务器收到了,但对方可能还没收到
- 已送达:对方的客户端收到了
- 发送失败:网络错误或者服务器拒绝
我们用一个简单的状态机管理:
// 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);
});
}我的实战经验
- 不要一开始就追求完美:先实现基本收发,再加状态管理。
- 优先处理发送失败:用户最在意的是消息有没有发出去,已读功能反而不那么重要。
- IndexedDB 是救星:网络不好的时候,本地存储能救命。
- 测试断网场景:Chrome DevTools 的 Network 选项里可以模拟 Offline,多测几次。
生产环境部署与性能优化
开发环境跑得好好的,一上线就各种问题——这是实时通信最常见的遭遇。我整理了几个关键点,能帮你少踩坑。
部署平台选择与限制
前面提到过,Vercel 不支持 WebSocket。这里详细说说各平台的情况:
| 平台 | WebSocket | SSE | 特殊限制 |
|---|---|---|---|
| Vercel | ❌ | ✅ | Edge: 25s 超时;Serverless: 60s 超时 |
| Netlify | ❌ | ✅ | Function 10s 超时 |
| Railway | ✅ | ✅ | 无硬性超时,按流量计费 |
| Render | ✅ | ✅ | 免费版睡眠机制 |
| Cloudflare Pages | ❌ | ✅ | Workers 有 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();
}性能优化清单
消息去重:客户端可能收到重复消息,用
Set记录已收到的消息 ID。虚拟滚动:消息超过 100 条后,用
react-window或react-virtualized渲染。懒加载历史消息:不要一次加载所有历史,滚动到顶部时再加载。
限流保护:防止用户疯狂发消息,客户端和服务端都要做限制。
// 简单的客户端限流
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;
// ... 发送逻辑
};- 监控与日志:用 Sentry 或者 LogRocket 追踪 SSE 连接失败、消息发送失败等错误。
成本控制
实时功能很烧钱,特别是 WebSocket 服务。几个省钱的办法:
- 用 SSE 替代 WebSocket:能省下单独的 WebSocket 服务器成本
- 消息合并推送:不要每条消息单独推送,每秒合并一次批量推送
- Redis 用 Upstash:按请求计费,比自己搭 Redis 服务器便宜
- CDN 静态资源:Next.js 应用的静态资源用 CDN,减轻服务器压力
我做过一个 500 人在线的聊天室,用 Vercel + Upstash,每月成本不到 $15。关键是方案选对了。
总结
回到文章开头那个凌晨翻车的场景——如果当时我知道这些,就不会在 WebSocket 上死磕了。
Next.js 的实时通信,核心就是务实选择:
- Vercel 部署?用 SSE,不要硬上 WebSocket
- 预算有限?先用最简单的方案跑起来,别一开始就堆技术
- 用户体验优先?消息状态管理和断线重连比炫技重要得多
这篇文章的代码你可以直接拿去用,但更重要的是理解背后的权衡逻辑。技术选型没有银弹,适合你项目的才是最好的。
如果你也在做实时功能,希望这篇文章能让你少走点弯路。有问题欢迎留言,我会尽量回复。
下一步行动:
- 先在本地跑通一个最简单的 SSE 示例
- 部署到 Vercel 测试一下
- 如果需要 WebSocket,再考虑 Railway 或自托管
技术债不可怕,可怕的是一开始就背上不必要的债。加油!
常见问题
为什么 Vercel 不支持 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 秒超时限制?
• 服务端发送心跳包:每 15 秒发送一次心跳,防止连接被判定为空闲
• 客户端检测断开:EventSource 的 onerror 事件监听连接中断
• 自动重连机制:检测到断开后 3 秒自动重新连接
• Redis 存储消息:确保重连后能获取未读消息
实际使用中,用户几乎感觉不到重连过程,体验和持久连接无异。
多实例部署如何实现消息同步?
• 用户 A 发送消息 → 实例 1 写入 Redis
• 实例 1、2、3 都轮询 Redis 获取新消息
• 实例将消息推送给各自连接的用户
推荐使用 Upstash(无服务器 Redis),按请求计费,比自建 Redis 便宜。轮询间隔设置为 1 秒,平衡实时性和成本。
如何保证消息不丢失?
• 乐观更新:发送前立即在界面显示,状态标记为"发送中"
• IndexedDB 本地存储:未确认的消息保存到本地数据库
• 服务器响应确认:收到成功响应后更新状态为"已发送"
• 断线重连恢复:重连后从 IndexedDB 读取未确认消息,自动重发
• 重试机制:发送失败的消息显示重发按钮,用户手动重试
参考微信的消息状态设计:发送中、已发送、已送达、发送失败四种状态。
Socket.io Custom Server 能部署到 Vercel 吗?
替代方案:
• 使用支持 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 账号登录后即可评论