fastify-sse-v2搭配EventSource实现SSE中的AI流式回复打字机效果&Fetch+ReadableStream+Chunked分块也可实现

本文不赘述具体概念,通过具体案例效果,学习sse (Server-Sent Events)的具体实现,以react框架为例

SSE具体应用场景

SSE(Server-Sent Events,服务器推送事件)是一种基于 HTTP 的单向实时通信协议,核心特点是服务器主动向客户端推送数据,客户端仅被动接收,无需频繁轮询,且天然支持断线重连、事件标识等特性。其应用场景主要集中在 "服务器需主动向客户端推送实时数据,且客户端无需向服务器发送高频请求" 的场景

具体场景

  • 服务器监控到系统异常如负载过高、数据库连接失败等,通过SSE将告警信息推送给运维人员的管理后台界面(物联网设备故障也可以通过SSE进行告警信息的推送)
  • 审批类:业务系统中审批流程通过或者驳回时,向申请人推送审批结果通知
  • 消息推送类:用户收到新私信、点赞、评论、关注请求时,服务器通过 SSE 将通知推送给对应用户的客户端,实现实时提醒
  • 订单状态变更(如商家接单、快递揽收、派送中)时,通过 SSE 推送状态通知;商品降价、补货时,向订阅该商品的用户推送提醒
  • 等等...

总而言之,就是及时推送消息,无需用户手动刷新获取最新数据

效果图

  • 本文给到的效果示例

需求场景逻辑流程

  • 有一段文章,当用户点击开始接收按钮的时候
  • 前端需要使用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...

相关推荐
月下点灯2 小时前
🏮一眼就会🗂️大文件分片上传,白送前后端全套功法
javascript·typescript·node.js
一雨方知深秋8 小时前
2.fs模块对计算机硬盘进行读写操作(Promise进行封装)
javascript·node.js·promise·v8·cpython
小彭律师13 小时前
Node.js环境变量配置的实战技术
node.js
Q_Q51100828515 小时前
python+django/flask的校园活动中心场地预约系统
spring boot·python·django·flask·node.js·php
Q_Q196328847517 小时前
python+django/flask基于机器学习的就业岗位推荐系统
spring boot·python·django·flask·node.js·php
by__csdn19 小时前
Node.js版本与npm版本的对应关系
前端·npm·node.js
aini_lovee19 小时前
Node.js 中的中间件机制与 Express 应用
中间件·node.js·express
重铸码农荣光1 天前
从「[1,2,3].map (parseInt)」踩坑,吃透 JS 数组 map 与包装类核心逻辑
面试·node.js
Jonathan Star1 天前
Next.js、NestJS、Nuxt.js 是 **Node.js 生态中针对不同场景的框架**
开发语言·javascript·node.js
Q_Q5110082851 天前
python+django/flask的眼科患者随访管理系统 AI智能模型
spring boot·python·django·flask·node.js·php