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...

相关推荐
逍遥5966070102 小时前
网络设备交换机路由器 AI 配置助手
node.js·硬件工程
damoluomu4 小时前
Directus 换掉 GPL 之后,Node.js CMS 到底怎么选?(三)
node.js
DevUp5 小时前
Directus 换掉 GPL 之后,Node.js CMS 到底怎么选?(三)
开源·node.js·cms
明月_清风18 小时前
Deno 终局来了:从挑战 Node 到被 Cloudflare 收编
前端·后端·node.js
guslegend1 天前
脚手架原理与本地调试:从 bin 软链接到 npm link
前端·npm·node.js·脚手架·前端工程化
zReadonly1 天前
不用反复 nvm use 了:nvm-windows 2.x 按项目自动切换 Node.js
前端·node.js
百度一下吧1 天前
Fastify 实战教程:从零搭建电商平台后台服务系统
node.js
YIAN1 天前
LangGraph 完全入门指南:从线性工作流到带中断恢复的有状态 Agent 编排
langchain·node.js·agent
DevUp1 天前
Directus 换掉 GPL 之后,Node.js CMS 到底怎么选?(二)
开源·node.js·cms