Next.js Route Handler 做 SSE 服务端推送:实时进度条、自动重连与什么时候别用 WebSocket
用户点了「导出报表」,后端要跑 30 秒。你不想让前端傻等一个转圈圈,想实时推「已处理 40%...70%...完成」。第一反应是上 WebSocket,但只是服务端单向推消息、不需要客户端反向发数据,WebSocket 就太重了:要单独的连接升级、要额外的服务、Serverless 平台还常常不支持长连接。
这种「服务端单向推、走标准 HTTP」的场景,SSE(Server-Sent Events)才是正解。Next.js 的 Route Handler 用 ReadableStream 就能实现,浏览器原生 EventSource 自带断线重连。这篇手把手写一个实时进度条。
先看错误示范:普通接口 + 前端轮询
很多人用轮询凑活:
typescript
// 前端每秒问一次进度 ------ 高延迟、请求量爆炸、还要在后端存进度状态
setInterval(async () => {
const res = await fetch('/api/progress?taskId=123')
const { percent } = await res.json()
setPercent(percent)
}, 1000)
问题一堆:延迟最高 1 秒;100 个用户就是每秒 100 个请求;后端还得把进度存进 Redis 供轮询查。而进度本来就是服务端主动知道的,应该由服务端推。
服务端:Route Handler 返回 SSE 流
SSE 的协议很简单:Content-Type: text/event-stream,每条消息是 data: <内容>\n\n(两个换行结尾)。用 ReadableStream 边算边推:
typescript
// app/api/export/route.ts
export const runtime = 'nodejs' // SSE 长连接别用默认可能被优化掉的配置
export const dynamic = 'force-dynamic' // 禁止这个路由被静态化/缓存
export async function GET() {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
// 封装一个发送函数,自动拼 SSE 格式
const send = (event: string, data: unknown) => {
controller.enqueue(
encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
)
}
for (let i = 1; i <= 10; i++) {
await doOneChunk(i) // 干一块真实的活
send('progress', { percent: i * 10 }) // 每完成一块就推进度
}
send('done', { url: '/files/report.xlsx' }) // 推最终结果
controller.close() // 关闭流,客户端会收到结束
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform', // no-transform 防代理压缩破坏流
Connection: 'keep-alive',
},
})
}
async function doOneChunk(i: number) {
await new Promise((r) => setTimeout(r, 500)) // 模拟耗时任务
}
三个 header 一个都不能少:text/event-stream 是协议要求;no-cache 防止被缓存;no-transform 尤其重要------Nginx/CDN 默认可能对响应做 gzip 缓冲,会把你的流「攒一批再发」,进度条就一次性跳到 100%。
客户端:EventSource 自动重连
浏览器原生 EventSource 天生为 SSE 而生,断线会自动重连,不用你写重连逻辑:
typescript
'use client'
import { useEffect, useState } from 'react'
export function ExportProgress() {
const [percent, setPercent] = useState(0)
const [url, setUrl] = useState<string>()
useEffect(() => {
const es = new EventSource('/api/export')
// 监听自定义 event 名(对应服务端的 event: progress)
es.addEventListener('progress', (e) => {
setPercent(JSON.parse(e.data).percent)
})
es.addEventListener('done', (e) => {
setUrl(JSON.parse(e.data).url)
es.close() // 拿到结果主动关,否则 EventSource 会自动重连再跑一遍!
})
es.onerror = () => {
// 连接断了 EventSource 会自己重连;这里只做提示
console.warn('SSE 连接中断,浏览器会自动重连')
}
return () => es.close() // 组件卸载务必关闭,防泄漏
}, [])
return url ? <a href={url}>下载完成</a> : <progress value={percent} max={100} />
}
最坑的一点:任务结束一定要 close
EventSource 的默认行为是------连接一断就重连。如果服务端 controller.close() 了但客户端没 es.close(),浏览器会认为「连接意外断开」,立刻重新发起请求,你的导出任务就被反复触发。所以收到 done 事件后,客户端必须主动 es.close()。这是 SSE 最容易让人半夜排查的坑。
进阶:客户端离开时,服务端要停下
用户关了页面,SSE 连接断开,但服务端的 for 循环可能还在空跑烧 CPU。用 request.signal 感知断开:
typescript
export async function GET(request: Request) {
const stream = new ReadableStream({
async start(controller) {
for (let i = 1; i <= 10; i++) {
if (request.signal.aborted) break // 客户端断了就停,别白干
await doOneChunk(i)
controller.enqueue(/* ...send progress... */)
}
controller.close()
},
})
// ...
}
什么时候别用 SSE,该上 WebSocket
- 需要客户端也频繁往服务端发消息(聊天、协同编辑、游戏)→ WebSocket。SSE 是单向的,反向只能靠另开 HTTP 请求。
- 传二进制(音视频)→ WebSocket。SSE 只能传 UTF-8 文本。
- 只是服务端单向推文本(通知、进度、日志流、AI 流式输出)→ SSE 更简单,走标准 HTTP、自带重连、Serverless 也基本支持。
顺带一提:同域下 HTTP/1.1 有每域 6 个连接的上限,SSE 会长期占一个;上 HTTP/2 后由多路复用解决,生产环境记得开 HTTP/2。
小结
- 服务端单向推、纯文本的场景(进度、通知、AI 流式输出)优先用 SSE,别动不动上 WebSocket。
- Next.js Route Handler 用
ReadableStream+Content-Type: text/event-stream实现,消息格式是data: ...\n\n。 - Header 三件套:
text/event-stream+no-cache+no-transform(防代理缓冲把流攒批)。 - 两个必踩的坑:客户端收到结束事件必须
es.close(),否则无限重连;服务端用request.signal.aborted感知客户端离开及时停手。 - 记忆点:SSE = 单向推 + 走 HTTP + 浏览器自带重连,轻量场景吊打 WebSocket。