本文不赘述具体概念,通过具体案例效果,学习sse (Server-Sent Events)的具体实现,以react框架为例
SSE具体应用场景
SSE(Server-Sent Events,服务器推送事件)是一种基于 HTTP 的单向实时通信协议,核心特点是服务器主动向客户端推送数据,客户端仅被动接收,无需频繁轮询,且天然支持断线重连、事件标识等特性。其应用场景主要集中在 "服务器需主动向客户端推送实时数据,且客户端无需向服务器发送高频请求" 的场景
具体场景
- 服务器监控到系统异常如负载过高、数据库连接失败等,通过SSE将告警信息推送给运维人员的管理后台界面(物联网设备故障也可以通过SSE进行告警信息的推送)
- 审批类:业务系统中审批流程通过或者驳回时,向申请人推送审批结果通知
- 消息推送类:用户收到新私信、点赞、评论、关注请求时,服务器通过 SSE 将通知推送给对应用户的客户端,实现实时提醒
- 订单状态变更(如商家接单、快递揽收、派送中)时,通过 SSE 推送状态通知;商品降价、补货时,向订阅该商品的用户推送提醒
- 等等...
总而言之,就是及时推送消息,无需用户手动刷新获取最新数据
效果图
- 本文给到的效果示例

- 对应线上演示效果地址:ashuai.site/reactExampl...
- github仓库:github.com/shuirongshu...
需求场景逻辑流程
- 有一段文章,当用户点击开始接收按钮的时候
- 前端需要使用
new EventSource去向后端的sse接口建立链接 - 链接建立以后,后端就会读取逐字逐段地去扫描文章文本,然后开始发送message消息事件
- 假设我们的文章是
const article = 亮答曰:"自董卓以来,豪杰并起..." - 单纯的message消息事件有些笼统,我们可以把消息事件自定义细分成为消息开始start事件、消息传输chunk事件、消息发送完毕end事件
- 这样控制能够更好地让前端区分,做对应的UI操作
- 假设我们的文章是
- 每个消息中,会带着一段文章文本字符串,交给前端去处理
- 实际上,如果是单纯的文本回复需求,前端直接拿到
chunk事件中的文本字符串即可使用 - 但是,如果回复的内容中除了纯文本之外,还要回复链接、图片、甚至代码等类型的话
- 刚刚提到的
chunk事件就不够用了,所以就可以新增一些事件类型 - 比如
chunk事件里面存放纯文本 imgChunk事件里面存放图片linkChunk事件里面存放链接等- 大家可以根据自己的业务需求,自定义很多的事件类型
- 无论是什么事件类型,前端都可以通过
eventSource监听到
- 实际上,如果是单纯的文本回复需求,前端直接拿到
- 前端可以监听到对应事件后,不断地把信息渲染到UI视图上(实现光标跟随的打字机效果)
前端监听对应自定义事件类型比如:
js
// 创建SSE连接
const eventSource = new EventSource('https://ashuai.site/fastify-api/sse/article/66')
// 监听后端自定义的start事件
eventSource.addEventListener('start', (event) => {
console.log('SSE开始:', event)
})
// 监听后端自定义的chunk事件
eventSource.addEventListener('chunk', (event) => {
console.log('接收到的数据:', event)
})
// 监听后端自定义的end事件
eventSource.addEventListener('end', (event) => {
console.log('SSE结束:', event)
})
// 监听后端自定义的error事件
eventSource.addEventListener('error', (event) => {
console.error('SSE错误:', event)
})
因为原生事件有些笼统,不便于前端更精细化控制
js
const eventSource = new EventSource('https://ashuai.site/fastify-api/sse/article/66')
eventSource.onmessage = (event) => { }
eventSource.onerror = (event) => { }
这个接口https://ashuai.site/fastify-api/sse/article/66大家可以直接使用,可以直接将其复制粘贴到地址栏并回车,或者使用curl命令

后端代码
后端使用fastify框架,搭配fastify-sse-v2这个sse的包
Controller层
- Controller负责接收用户发送的http的Request请求
- 并处理请求参数与数据校验
- 校验通过后,会调用Service层进行业务逻辑处理,
- 处理Service层返回结果并构造响应Response异常处理
js
const createSseService = require('../services/sseService')
const ResponseUtils = require('../utils/response')
const { validateId } = require('../utils/validation')
// 根据ID获取对应文章(SSE分段输出)
const getArticleById = async (request, reply) => {
try {
const { id } = request.params
// 参数校验
const idValidation = validateId(id)
if (!idValidation.isValid) {
return ResponseUtils.sendValidationError(reply, 'ID参数无效', idValidation.errors)
}
const sseService = createSseService()
// 设置自定义响应头
reply.header('hello', 'world')
// 定义处理每个数据块的回调函数
const handleChunk = async (chunk, isLast) => {
reply.sse({
event: chunk.type,
data: JSON.stringify(chunk.data)
})
}
// 调用service层的流式传输方法
const result = await sseService.streamArticleWithSSE(
idValidation.data,
handleChunk,
{ chunkSize: 10, delayMs: 500 }
)
if (result === null) {
return ResponseUtils.sendNotFound(reply, '文章不存在')
}
} catch (error) {
request.log.error(error)
// SSE错误处理
reply.sse({
event: 'error',
data: JSON.stringify({
code: 500,
message: '查询失败',
error: error.message
})
})
}
}
Service层
- Service层主要是处理核心业务逻辑
- 协调数据访问,就是处理数据
- 比如调用DAO层(或Repository层) 来完成mysql数据查询
- 并进行数据处理与转换等
- 所以sse数据的核心加工处理在这一层
假设我们的文章是这个
txt
const article = `亮答曰:"自董卓以来,豪杰并起,跨州连郡者不可胜数。曹操比于袁绍,则名微而众寡。然操遂能克绍,以弱为强者,非惟天时,抑亦人谋也。
今操已拥百万之众,挟天子而令诸侯,此诚不可与争锋。孙权据有江东,已历三世,国险而民附,贤能为之用,此可以为援而不可图也。
荆州北据汉、沔,利尽南海,东连吴会,西通巴、蜀,此用武之国,而其主不能守,此殆天所以资将军,将军岂有意乎?
益州险塞,沃野千里,天府之土,高祖因之以成帝业。刘璋暗弱,张鲁在北,民殷国富而不知存恤,智能之士思得明君。
将军既帝室之胄,信义著于四海,总揽英雄,思贤如渴,若跨有荆、益,保其岩阻,西和诸戎,南抚夷越,外结好孙权,内修政理;
天下有变,则命一上将将荆州之军以向宛、洛,将军身率益州之众出于秦川,百姓孰敢不箪食壶浆以迎将军者乎?诚如是,则霸业可成,汉室可兴矣。"`
对应Service层代码
js
const createSseService = () => {
// 根据前端参数ID获取对应文章(这里模拟从数据库捞取数据)
const getArticleById = (id) => {
return { id, article, // 直接使用上述文章 }
}
/**
* SSE流式传输文章
* @param {number} id - 文章ID
* @param {Function} onChunk - 处理每个数据块的回调函数 (chunk, isLast) => void
* @param {Object} options - 配置选项
* @param {number} options.chunkSize - 每个分段的字符数,默认10
* @param {number} options.delayMs - 每个分段之间的延迟时间(毫秒),默认500
* @returns {Promise<boolean|null>} 成功返回true,文章不存在返回null
*/
const streamArticleWithSSE = async (id, onChunk, options = {}) => {
const { chunkSize = 10, delayMs = 500 } = options
const articleData = getArticleById(id)
const { article } = articleData
// 发送开始事件
await onChunk({
type: 'start',
data: {
id: articleData.id,
totalLength: article.length
}
}, false)
// 等待延迟后开始发送内容
await new Promise(resolve => setTimeout(resolve, delayMs))
// 分段发送文章内容
let currentIndex = 0
const totalChunks = Math.ceil(article.length / chunkSize)
while (currentIndex < article.length) {
const chunk = article.slice(currentIndex, currentIndex + chunkSize)
await onChunk({
type: 'chunk',
data: {
index: Math.floor(currentIndex / chunkSize),
content: chunk,
progress: Math.round(((currentIndex + chunkSize) / article.length) * 100)
}
}, false)
currentIndex += chunkSize
// 如果还有下一段,等待延迟
if (currentIndex < article.length) {
await new Promise(resolve => setTimeout(resolve, delayMs))
}
}
// 发送结束事件
await onChunk({
type: 'end',
data: {
totalChunks,
message: '文章发送完成'
}
}, true)
return true
}
// 返回所有方法
return { getArticleById, streamArticleWithSSE }
}
module.exports = createSseService
这样的话,我们就有了一个sse的接口了
Nginx设置对应响应头
注意,sse需要设置特殊的响应头,这里我们使用nginx代理,可以配置如下
js
# SSE专用配置,接口调用
location /fastify-api/sse/ {
proxy_pass http://localhost:33333/fastify/sse/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $server_name;
# SSE特殊配置
proxy_buffering off; #避免阻塞流式输出
proxy_cache off; #防止缓存干扰实时数据
proxy_set_header Connection ""; #维持长连接
# SSE超时
proxy_connect_timeout 30s;
proxy_send_timeout 30s;
proxy_read_timeout 86400s; # 24小时,适合SSE
}
前端
光标跟随实现
- 如果只是展示普通的文本,那么可以创建一个元素
- 将这个元素摆放在文字的最后
- 通过动画的方式,模拟出来光标闪烁的效果
- 如下
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
.cursor {
display: inline-block;
width: 2px;
height: 1.2em;
background-color: #1677ff;
margin-left: 2px;
/* 光标元素的底部与父元素的文本底部对齐 */
vertical-align: text-bottom;
animation: blink 1s infinite;
}
@keyframes blink {
0%,
50% {
opacity: 1;
}
51%,
100% {
opacity: 0;
}
}
</style>
</head>
<body>
<p>你好,这个世界 <span class="cursor"></span> </p>
</body>
</html>
效果图

- 如果要展示复杂的带层级的结构,比如代码、链接等,就要通过js去动态控制
- 找到最后一个文本接口、追加一个问题,并获取文字的位置,再设置光标到文字为止
可以参考这个视频的实现方案:www.douyin.com/video/75535...
使用Promise.resolve()的链式调用,实现队列方案
案例效果中,因为是不断地完成打字机渲染数据的效果,所以我们要确保上一次渲染完成后,再执行下一次的打字机渲染,这里我们采用Promise.resolve()的链式调用,实现队列方案(保证顺序)
案例分析,假设我们要发送五个请求(通过id传参的形式区分),下方的写法,无法保证输入的结果,对应的是参数 1 2 3 4 5的结果
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://cdn.bootcdn.net/ajax/libs/axios/1.3.0/axios.js"></script>
</head>
<body>
<script>
const base = 'https://ashuai.work/api/xyj?id='
const ids = ['1', '2', '3', '4', '5']
ids.forEach(async (id) => {
const res = await axios.get(`${base}${id}`)
console.log('res', res.data.data)
})
</script>
</body>
</html>
但是,如果我们使用使用Promise的链式调用,就能够实现通过.then()实现入队操作,而Promise会自动执.then的代码做到自动出队的效果,如下
js
<script>
const base = 'https://ashuai.work/api/xyj?id='
const ids = ['1', '2', '3', '4', '5']
// 初始化一个"空的已完成Promise"作为起点
let promiseChain = Promise.resolve()
// 用forEach遍历
ids.forEach(id => {
// 将新任务追加到Promise链的末尾(变量重新指向新创建的Promise)
promiseChain = promiseChain.then(async () => {
const res = await axios.get(`${base}${id}`)
console.log('res', res.data.data)
return res.data.data
})
})
</script>
或者使用for of也行,也能保证顺序
js
const base = 'https://ashuai.work/api/xyj?id='
const ids = ['1', '2', '3', '4', '5']
async function fetchSequentially() {
for (const id of ids) {
const res = await axios.get(`${base}${id}`)
console.log('res', res.data.data)
}
console.log('全部完成')
}
// 调用函数
fetchSequentially()
完整前端代码
前端控制流程
erlang
用户点击"开始接收"
↓
startSSE() - 重置状态 + 建立连接
├─ articleText='', progress=0
├─ isStreaming=true (显示光标█)
└─ new EventSource(url)
↓
━━━━ 服务器: start 事件 ━━━━
↓
console.log('SSE开始')
↓
━━━━ 服务器: chunk 事件 (多次) ━━━━
↓
chunk1 到达 → Promise链
└─ typeWriter("文本1")
└─ 逐字显示 (50ms/字) ⏰
└─ 完成后 setProgress(20)
↓
chunk2 到达 → Promise链 (等待chunk1完成)
└─ typeWriter("文本2")
└─ 逐字显示 (50ms/字) ⏰
└─ 完成后 setProgress(40)
↓
chunk3 到达 → Promise链 (等待chunk2完成)
└─ typeWriter("文本3")
└─ 逐字显示 (50ms/字) ⏰
└─ 完成后 setProgress(100)
↓
━━━━ 服务器: end 事件 ━━━━
↓
Promise链 (等待所有typeWriter完成)
└─ setIsStreaming(false) (隐藏光标)
└─ eventSource.close()
└─ 完成 ✓
注意,这里是使用Promise链式调用保证顺序的
css
【Promise 链保证顺序】
服务器快速发送:
chunk1 → chunk2 → chunk3 → end
前端执行顺序:
typeWriter(chunk1) ✓
↓ 等待完成
typeWriter(chunk2) ✓
↓ 等待完成
typeWriter(chunk3) ✓
↓ 等待完成
隐藏光标 ✓
核心: promiseChainRef 确保串行执行,不会乱序
React代码
jsx
import React, { useState, useEffect, useRef } from 'react'
import { Button, Progress } from 'antd'
import './Sse.css'
export default function Sse() {
const [articleText, setArticleText] = useState('') // 文章内容
const currentTextRef = useRef('') // 存储打字机效果中累积的文本内容
const [progress, setProgress] = useState(0) // sse返回的进度
const [isStreaming, setIsStreaming] = useState(false) // 是否正在接收
const eventSourceRef = useRef(null) // sse链接实例
const typeWriterTimerRef = useRef(null) // 定时器的id,用于清理定时器
const promiseChainRef = useRef(Promise.resolve()) // Promise链
// 清理函数
useEffect(() => {
return () => {
// 组件卸载清理关闭sse连接和定时器
if (eventSourceRef.current) {
eventSourceRef.current.close()
}
if (typeWriterTimerRef.current) {
clearInterval(typeWriterTimerRef.current)
}
}
}, [])
// 打字机效果返回Promise,确保顺序执行
const typeWriter = (text) => {
return new Promise((resolve) => {
let index = 0
const interval = setInterval(() => {
// 组件被卸载了,停止打字机效果
if (!typeWriterTimerRef.current) {
clearInterval(interval)
return
}
// 定时器循环赋值刷新UI,直到赋值完成,再执行resolve
if (index < text.length) {
currentTextRef.current += text[index]
setArticleText(currentTextRef.current)
index++
} else {
clearInterval(interval)
typeWriterTimerRef.current = null
resolve()
}
}, 50)
typeWriterTimerRef.current = interval
})
}
// 开始SSE连接
const startSSE = () => {
if (eventSourceRef.current) {
eventSourceRef.current.close()
}
// 重置状态
currentTextRef.current = ''
setArticleText('')
setProgress(0)
setIsStreaming(true)
promiseChainRef.current = Promise.resolve() // 重置Promise链
// 创建SSE连接
const eventSource = new EventSource('https://ashuai.site/fastify-api/sse/article/66')
eventSourceRef.current = eventSource
// 监听后端自定义的start事件
eventSource.addEventListener('start', (event) => {
const data = JSON.parse(event.data)
console.log('SSE开始:', data)
})
// 监听后端自定义的chunk事件
eventSource.addEventListener('chunk', (event) => {
const data = JSON.parse(event.data)
console.log('接收到的数据:', data)
// 链式调用:等前一个完成后再执行,这样能够确保前一句话完成,再执行下一句话
promiseChainRef.current = promiseChainRef.current
.then(() => typeWriter(data.content))
.then(() => setProgress(data.progress))
})
// 监听后端自定义的end事件
eventSource.addEventListener('end', (event) => {
const data = JSON.parse(event.data)
console.log('SSE结束:', data)
// 等待所有Promise链完成后再设置isStreaming为false
// 防止see结束了,文字还没有打完,光标消失了
promiseChainRef.current = promiseChainRef.current.then(() => {
setIsStreaming(false)
})
// 关闭连接
eventSource.close()
eventSourceRef.current = null
})
// 监听后端自定义的error事件
eventSource.addEventListener('error', (event) => {
console.error('SSE错误:', event)
setIsStreaming(false)
if (eventSourceRef.current) {
eventSourceRef.current.close()
eventSourceRef.current = null
}
// 也可以加上定时器重连机制
})
}
// 取消SSE连接
const cancelSSE = () => {
if (eventSourceRef.current) {
eventSourceRef.current.close()
eventSourceRef.current = null
}
if (typeWriterTimerRef.current) {
clearInterval(typeWriterTimerRef.current)
typeWriterTimerRef.current = null
}
setIsStreaming(false)
promiseChainRef.current = Promise.resolve()
}
return (
<div style={{ width: '720px' }}>
<div className="article-container">
<div className="article-text">
{articleText}
{/* 当正在接收时,使用span元素模拟鼠标光标 */}
{isStreaming && <span className="cursor"></span>}
</div>
</div>
<Progress percent={progress} />
<Button
type="primary"
onClick={startSSE}
disabled={isStreaming}
>
{isStreaming ? '正在接收...' : '开始接收'}
</Button>
<Button onClick={cancelSSE} disabled={!isStreaming}>{isStreaming ? '取消链接' : '未曾链接'}</Button>
</div>
)
}
Css代码
css
.article-container {
background: #f7fafc;
border-radius: 8px;
padding: 20px;
border-left: 4px solid #1677ff;
min-height: 240px;
}
.article-text {
font-size: 16px;
line-height: 1.8;
color: #2d3748;
white-space: pre-wrap;
word-wrap: break-word;
}
.cursor {
display: inline-block;
width: 2px;
height: 1.2em;
background-color: #1677ff;
margin-left: 2px;
/* 光标元素的底部与父元素的文本底部对齐 */
vertical-align: text-bottom;
animation: blink 1s infinite;
}
@keyframes blink {
0%,
50% {
opacity: 1;
}
51%,
100% {
opacity: 0;
}
}
原生new EventSource的不足之处
- 原生
new EventSource主要有以下不足之处 - 首先无法自定义请求头,比如我想要在接口的请求头中添加
'Authorization': 'myToken'是不好控制的,毕竟可能业务中接口要有鉴权才行 - 然后,仅支持get方法,post不能用,参数只能通过 URL 传递,比如上述笔者提供的接口,就是通过get请求的params拿到的参数,即
new EventSource('https://ashuai.site/fastify-api/sse/article/66')这里的参数比如是66或者67等 - 当然 我们也可以把token拼接到params中去,比如
new EventSource('https://ashuai.site/fastify-api/sse/article/66/user_token') - 或者也可以使用cookie去带着token
- 但是这毕竟不优雅,前人已经提供好了一些包解决方案供我们使用
- 比如event-source-polyfill和fetch-event-source
event-source-polyfill
示例代码
js
import { EventSourcePolyfill } from 'event-source-polyfill';
// 初始化配置项多多
const es = new EventSourcePolyfill('/api/sse', {
method: 'POST', // 支持POST
headers: {
'Authorization': 'user_token',
'Content-Type': 'application/json'
},
body: JSON.stringify({ id: 66 }) // POST请求体
});
// 监听服务器发送的消息
es.onmessage = (event) => { console.log('收到数据:', event.data) };
// 监听连接打开
es.onopen = () => { console.log('连接已建立') };
// 监听错误
es.onerror = (error) => { console.error('连接错误:', error) };
或者使用fetch-event-source
fetch-event-source
示例代码
js
import { fetchEventSource } from '@microsoft/fetch-event-source';
fetchEventSource('/api/sse', {
method: 'POST',
headers: {
'Authorization': 'user_token',
'Content-Type': 'application/json'
},
body: JSON.stringify({ id: 66 }),
onopen: (response) => {
if (!response.ok) {
console.error('连接失败:', response.status);
} else {
console.log('连接已建立');
}
},
onmessage: (event) => {
console.log('收到数据:', event.data);
},
onclose: () => {
console.log('连接关闭');
},
onerror: (error) => {
console.error('连接错误:', error);
// 可以返回true表示重试连接
return true;
}
});
| 特性 | event-source-polyfill |
fetch-event-source |
|---|---|---|
| 底层依赖 | 模拟原生 EventSource |
基于现代 Fetch API |
| 兼容性 | 支持旧浏览器(如 IE9+) | 依赖 Fetch,需兼容现代浏览器 |
| 灵活性 | 有限(接近原生 API) | 更高(支持 Fetch 所有特性) |
| 重试与控制 | 简单重试逻辑 | 自定义重试策略、取消机制 |
笔者推荐fetch-event-source这个包,笔者在实际项目中,使用的也是这个,没有用原生EventSource写法
F12的Network中请求类型的区别
注意,如果是使用原生的EventSource,那么F12的Network中请求type类型是eventssource

如果是使用fetch方式的话,那么请求type类型则是fetch,我们打开ChatGpt发现其类型正是fetch

但是,无论哪种,后端的响应头,都要返回Content-Type text/event-stream; charset=utf-8来告诉浏览器,这是一个事件流,这样的话才能有如下图的EventStream
笔者的返回EventStream

ChatGpt的返回EventStream

大家可以打开豆包、通义千文,大家会发现这两家也是用的fetch技术,并没有直接使用EventSource,不过deepseek特殊一些用的是xhr方式回复消息,但同样也是Content-Type text/event-stream; charset=utf-8
除此之外,还可以使用Fetch ReadableStream读取Chunked流实现see功能如下
使用Fetch ReadableStream读取Chunked分块流实现see功能
核心:
js
rawReply.writeHead(200, {
'Transfer-Encoding': 'chunked', // 👈 关键!告诉浏览器用分块方式接收
'Content-Type': 'text/plain; charset=utf-8',
...
})
浏览器收到这个头后会
- 知道数据会分多次发送过来
- 自动解析每个chunk前面的十六进制长度
- 把解析后的纯数据交给JavaScript
- 持续读取直到收到
0\r\n\r\n(结束标记)
这种方式稍微复杂一些,应对特殊的需求能用上 一般来说,fetch-event-source 够用了
Fetch + ReadableStream
- 是更底层、更灵活的流式数据接收方式
- 可以接收任意格式的流式数据(JSON、纯文本、二进制等)
- 需要自己处理数据解析、缓冲区等
- 可以实现 SSE 的效果,甚至更强大
效果图

线上示例地址:ashuai.site/reactExampl...
Controller层
js
// 根据ID获取对应文章2(chunked传输)
const getArticleById2 = async (request, reply) => {
try {
const { id } = request.params
// 参数校验
const idValidation = validateId(id)
if (!idValidation.isValid) {
return ResponseUtils.sendValidationError(reply, 'ID参数无效', idValidation.errors)
}
const sseService = createSseService()
// 获取原始响应对象
const rawReply = reply.raw
// 设置响应头
rawReply.writeHead(200, {
'Content-Type': 'text/plain; charset=utf-8',
'Transfer-Encoding': 'chunked',
'Cache-Control': 'no-cache',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Connection': 'keep-alive'
})
let isConnectionClosed = false
// 监听连接关闭事件
rawReply.on('close', () => {
isConnectionClosed = true
})
rawReply.on('finish', () => {
isConnectionClosed = true
})
// 定义写入chunk的辅助函数
const writeChunk = (data) => {
if (isConnectionClosed) return
const dataStr = JSON.stringify(data)
const bufferChunk = Buffer.from(dataStr, 'utf8')
rawReply.write(`${bufferChunk.length.toString(16)}\r\n`)
rawReply.write(dataStr)
rawReply.write('\r\n')
}
// 定义处理每个数据块的回调函数
const handleChunk = async (chunk, isLast) => {
if (isConnectionClosed) return
writeChunk(chunk)
if (isLast) {
rawReply.write('0\r\n\r\n')
rawReply.end()
}
}
// 调用service层的流式传输方法
const result = await sseService.streamArticleWithChunked(
idValidation.data,
handleChunk,
{ chunkSize: 10, delayMs: 500 }
)
if (result === null) {
return ResponseUtils.sendNotFound(reply, '文章不存在')
}
} catch (error) {
request.log.error(error)
// chunked传输错误处理
const errorData = JSON.stringify({
code: 500,
message: '查询失败',
error: error.message,
type: 'error'
})
const errorChunk = Buffer.from(errorData, 'utf8')
reply.raw.write(`${errorChunk.length.toString(16)}\r\n`)
reply.raw.write(errorData)
reply.raw.write('\r\n0\r\n\r\n')
reply.raw.end()
}
}
Service层
数据
txt
const article2 = `臣本布衣,躬耕于南阳,苟全性命于乱世,不求闻达于诸侯。
先帝不以臣卑鄙,猥自枉屈,三顾臣于草庐之中,咨臣以当世之事,由是感激,遂许先帝以驱驰。
后值倾覆,受任于败军之际,奉命于危难之间,尔来二十有一年矣。
先帝知臣谨慎,故临崩寄臣以大事也。受命以来,夙夜忧叹,恐托付不效,以伤先帝之明;
故五月渡泸,深入不毛。今南方已定,兵甲已足,当奖率三军,北定中原,庶竭驽钝,攘除奸凶,兴复汉室,还于旧都。
此臣所以报先帝而忠陛下之职分也。至于斟酌损益,进尽忠言,则攸之、祎、允之任也。
愿陛下托臣以讨贼兴复之效,不效,则治臣之罪,以告先帝之灵。若无兴德之言,则责攸之、祎、允等之慢,以彰其咎;
陛下亦宜自谋,以咨诹善道,察纳雅言,深追先帝遗诏。臣不胜受恩感激。今当远离,临表涕零,不知所言。`
业务逻辑处理
js
const createSseService = () => {
// 根据ID获取对应文章2(用于chunked传输)
const getArticleById2 = (id) => {
return {
id,
article: article2,
}
}
/**
* Chunked传输文章
* @param {number} id - 文章ID
* @param {Function} onChunk - 处理每个数据块的回调函数 (chunk, isLast) => void
* @param {Object} options - 配置选项
* @param {number} options.chunkSize - 每个分段的字符数,默认10
* @param {number} options.delayMs - 每个分段之间的延迟时间(毫秒),默认500
* @returns {Promise<boolean|null>} 成功返回true,文章不存在返回null
*/
const streamArticleWithChunked = async (id, onChunk, options = {}) => {
const { chunkSize = 10, delayMs = 500 } = options
const articleData = getArticleById2(id)
const { article } = articleData
// 发送开始标记
await onChunk({
id: articleData.id,
totalLength: article.length,
type: 'chunked'
}, false)
// 等待初始延迟
await new Promise(resolve => setTimeout(resolve, 100))
// 分段发送文章内容
let currentIndex = 0
const totalChunks = Math.ceil(article.length / chunkSize)
while (currentIndex < article.length) {
const chunk = article.slice(currentIndex, currentIndex + chunkSize)
await onChunk({
index: Math.floor(currentIndex / chunkSize),
content: chunk,
progress: Math.round(((currentIndex + chunkSize) / article.length) * 100),
type: 'chunk'
}, false)
currentIndex += chunkSize
// 如果还有下一段,等待延迟
if (currentIndex < article.length) {
await new Promise(resolve => setTimeout(resolve, delayMs))
}
}
// 发送结束标记
await onChunk({
totalChunks,
message: '文章发送完成',
type: 'end'
}, true)
return true
}
// 返回所有方法
return {
getArticleById2,
streamArticleWithChunked
}
}
module.exports = createSseService
前端代码
jsx
import React, { useState, useRef, useEffect } from 'react'
import { Button, Progress } from 'antd'
import './Sse2.css'
export default function Sse2() {
const [articleText, setArticleText] = useState('') // 显示在界面上的文章内容
const [progress, setProgress] = useState(0) // 当前加载进度(0-100)
const [isStreaming, setIsStreaming] = useState(false) // 标记是否正在接收数据流
const currentTextRef = useRef('') // 打字机效果中实时累积的文本内容(缓存)
const typeWriterTimerRef = useRef(null) // 打字机定时器的 ID,用于在需要时清理定时器
const promiseChainRef = useRef(Promise.resolve()) // Promise 链,确保多个打字机效果按顺序执行,不会乱序
const abortControllerRef = useRef(null) // 用于中断 fetch 请求的控制器
useEffect(() => {
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
if (typeWriterTimerRef.current) {
clearInterval(typeWriterTimerRef.current)
}
}
}, [])
const typeWriter = (text) => {
return new Promise((resolve) => {
let index = 0 // 当前要显示的字符索引
const interval = setInterval(() => {
// 安全检查:如果组件已卸载(定时器被清除),停止执行
if (!typeWriterTimerRef.current) {
clearInterval(interval)
return
}
// 还有字符没显示完,继续逐个添加
if (index < text.length) {
currentTextRef.current += text[index] // 累积文本到 ref 中
setArticleText(currentTextRef.current) // 更新界面显示
index++ // 移动到下一个字符
} else {
// 所有字符都显示完了,清理定时器并通知 Promise 完成
clearInterval(interval)
typeWriterTimerRef.current = null
resolve() // 告诉外部:这段文本显示完了,可以显示下一段了
}
}, 50) // 每 50ms 显示一个字符
typeWriterTimerRef.current = interval // 保存定时器 ID,方便后续清理
})
}
/**
* 处理从服务器接收到的分块数据
* @param {Object} data - 服务器返回的数据对象
*
* 数据类型说明:
* - type: 'chunk' -> 文章内容片段,需要用打字机效果显示
* - type: 'end' -> 传输结束标记
* - type: 'error' -> 错误信息
*/
const handleChunkedData = async (data) => {
// 情况1:接收到一段文章内容
if (data.type === 'chunk') {
promiseChainRef.current = promiseChainRef.current
.then(() => typeWriter(data.content)) // 先等上一段显示完
.then(() => setProgress(data.progress)) // 再更新进度条
return
}
// 情况2:接收到结束标记
if (data.type === 'end') {
// 等待所有文本都显示完成后,再标记传输结束
// 这样不会出现文本还没显示完,按钮就变成可点击的情况
promiseChainRef.current = promiseChainRef.current.then(() => {
setIsStreaming(false)
})
return
}
// 情况3:服务器返回了错误
if (data.type === 'error') {
throw new Error(data.message || '服务器返回错误')
}
}
/**
* 使用 fetch + ReadableStream 接收服务器的流式数据
*
* 流式传输的优势:
* 1. 数据边接收边显示,不用等所有数据到达后才显示
* 2. 类似于 ChatGPT 的效果,用户体验更好
* 3. 可以处理大量数据,不会一次性占用太多内存
*/
const startChunkedStream = async () => {
// 如果之前有未完成的请求,先取消掉
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
// 创建新的取消控制器,用于取消这次请求
abortControllerRef.current = new AbortController()
// 重置所有状态,准备接收新数据
currentTextRef.current = '' // 清空缓存的文本
setArticleText('') // 清空界面显示
setProgress(0) // 进度归零
setIsStreaming(true) // 标记为正在接收
promiseChainRef.current = Promise.resolve() // 重置 Promise 链
try {
// 向服务器发起请求
const response = await fetch('https://ashuai.site/fastify-api/sse/article2/1', {
signal: abortControllerRef.current.signal // 传入取消控制器,可随时中断请求
})
// 检查请求是否成功
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
// 获取响应体的读取器(用于读取流式数据)
const reader = response.body.getReader()
// 创建文本解码器(将二进制数据转换为文本)
const decoder = new TextDecoder()
// 缓冲区:用于存储不完整的数据
let buffer = ''
// 循环读取数据流
while (true) {
// 读取一块数据
const { done, value } = await reader.read()
// 如果数据读取完毕,退出循环
if (done) {
break
}
// 将二进制数据解码成文本
const chunk = decoder.decode(value, { stream: true })
buffer += chunk // 累积到缓冲区
// 按行分割数据(服务器每次发送一个完整的 JSON 对象,以换行符分隔)
const lines = buffer.split('\n')
// 处理每一行数据(除了最后一行,因为最后一行可能不完整)
for (let i = 0; i < lines.length - 1; i++) {
const line = lines[i].trim()
// 忽略空行和 '0'('0' 是 chunked 编码的结束标记)
if (line && line !== '0') {
try {
// 将 JSON 字符串解析为对象
const data = JSON.parse(line)
// 处理这条数据(显示文本、更新进度等)
await handleChunkedData(data)
} catch (e) {
// 如果解析失败,忽略这条数据,继续处理下一条
}
}
}
// 将不完整的行保留在缓冲区中,等待下次接收数据时拼接
const lastNewlineIndex = buffer.lastIndexOf('\n')
if (lastNewlineIndex !== -1) {
buffer = buffer.substring(lastNewlineIndex + 1)
}
}
} catch (error) {
// 处理错误
if (error.name === 'AbortError') {
console.log('传输已取消')
} else {
console.error('Chunked传输错误:', error)
}
setIsStreaming(false)
} finally {
// 无论成功或失败,都清空取消控制器
abortControllerRef.current = null
}
}
/**
* 取消正在进行的流式传输
* 需要清理三个东西:
* 1. 网络请求
* 2. 打字机定时器
* 3. Promise 链
*/
const cancelStream = () => {
// 1. 取消网络请求
if (abortControllerRef.current) {
abortControllerRef.current.abort()
abortControllerRef.current = null
}
// 2. 停止打字机效果
if (typeWriterTimerRef.current) {
clearInterval(typeWriterTimerRef.current)
typeWriterTimerRef.current = null
}
// 3. 更新状态
setIsStreaming(false)
// 4. 重置 Promise 链
promiseChainRef.current = Promise.resolve()
}
return (
<div style={{ width: '720px' }}>
{/* 文章显示区域 */}
<div className="article-container">
<div className="article-text">
{articleText}
{/* 当正在接收时,显示一个闪烁的光标,模拟打字效果 */}
{isStreaming && <span className="cursor"></span>}
</div>
</div>
<Progress percent={progress} />
<Button
type="primary"
onClick={startChunkedStream}
disabled={isStreaming} // 正在接收时禁用,避免重复点击
>
{isStreaming ? '正在接收...' : '开始接收'}
</Button>
<Button onClick={cancelStream} disabled={!isStreaming}>
{isStreaming ? '取消传输' : '未在传输'}
</Button>
</div>
)
}
A good memory is not as reliable as a written record. Write it down...