前端流式通信 Hook:useBlogStream 详解

前端流式通信 Hook:useBlogStream 详解

本文是《墨言博客助手(InkWords)架构解析》系列的第 18 篇。项目完整源码:https://github.com/2692341798/InkWords

引言:为什么需要流式通信?

想象一下这样的场景:你正在使用一个 AI 写作工具,输入一个复杂的项目源码后,点击"生成博客"按钮。然后...你盯着一个空白的屏幕等待了整整 30 秒,突然之间,一篇完整的 5000 字博客"砰"地一下出现在你面前。

这种体验好吗?显然不好。用户不知道系统是否还在工作,也不知道进度如何,更无法中途停止。这就是为什么我们需要流式通信

在 InkWords 中,我们使用 Server-Sent Events (SSE) 技术实现流式通信。简单来说,就像打开一个水龙头:后端不断地"滴答滴答"地向前端发送数据片段,前端则实时地展示这些片段。这样用户就能看到生成过程,知道系统正在工作,并且可以随时停止。

今天,我们就来深入剖析管理这一切的前端核心 Hook ------ useBlogStream

useBlogStream 的整体架构

首先,让我们看看这个 Hook 提供了哪些核心功能:

typescript 复制代码
// 简化的功能概览
export const useBlogStream = () => {
  return {
    analyzeGit,      // 分析 Git 仓库
    parseFile,       // 解析上传文件
    generateSingle,  // 生成单篇博客
    generateSeries,  // 生成系列博客
    stopAnalyzing,   // 停止分析
    stopGenerating   // 停止生成
  }
}

这六个方法构成了 InkWords 前端与后端流式通信的全部接口。它们都基于同一个核心技术:fetchEventSource

核心技术:fetchEventSource

在深入每个方法之前,我们需要理解底层的通信机制。fetchEventSource 是微软提供的一个库,它封装了原生的 EventSource API,提供了更好的错误处理和更灵活的配置。

让我们通过一个生活化的比喻来理解 SSE:

传统 API 调用:就像写信。你寄出一封信(请求),然后等待回信(响应)。整个过程是"一次性的"。

SSE 流式通信:就像打电话。你拨通电话(建立连接),然后对方可以连续不断地说话(发送数据片段),你可以实时听到并回应。

核心方法详解

1. analyzeGit:分析 Git 仓库

这是用户输入 Git 仓库 URL 后调用的第一个方法。它的主要任务是:

  1. 验证 URL:确保用户输入的是有效的 Git 地址
  2. 建立 SSE 连接:连接到后端的分析服务
  3. 实时更新进度:显示分析的不同阶段
  4. 获取大纲:最终得到博客系列的大纲

让我们看看关键代码:

typescript 复制代码
const analyzeGit = useCallback(async (gitUrl: string) => {
  // 1. 基础 URL 验证
  if (!gitUrl.startsWith('http://') && 
      !gitUrl.startsWith('https://') && 
      !gitUrl.startsWith('git@') && 
      !gitUrl.startsWith('file://')) {
    alert('请输入有效的 Git 仓库链接')
    throw new Error('invalid url')
  }

  // 2. 设置分析状态
  store.setAnalyzing(true)
  store.setAnalysisStep(-1)
  store.setAnalysisMessage('正在建立连接...')
  
  // 3. 创建 AbortController(用于取消请求)
  if (store.abortController) {
    store.abortController.abort()
  }
  const ctrl = new AbortController()
  store.setAbortController(ctrl)
  
  try {
    // 4. 获取认证令牌
    const token = localStorage.getItem('token')
    
    // 5. 建立 SSE 连接
    await fetchEventSource('/api/v1/stream/analyze', {
      method: 'POST',
      headers: { 
        'Content-Type': 'application/json',
        'Authorization': token ? `Bearer ${token}` : ''
      },
      signal: ctrl.signal,  // 绑定取消信号
      openWhenHidden: true, // 标签页隐藏时也保持连接
      body: JSON.stringify({ git_url: gitUrl }),
      
      // 6. 连接建立时的回调
      async onopen(response) {
        if (response.ok && response.headers.get('content-type')?.startsWith('text/event-stream')) {
          return; // 连接成功
        }
        // 处理连接失败
        throw new StopStreamError('请求失败')
      },
      
      // 7. 接收消息时的回调(核心!)
      onmessage(msg) {
        if (msg.event === 'chunk') {
          try {
            const data = JSON.parse(msg.data)
            // 更新分析步骤和消息
            if (data.step !== undefined) {
              store.setAnalysisStep(data.step)
              store.setAnalysisMessage(data.message || '')
            }
            // 保存大纲数据
            if (data.data?.outline) {
              store.setSource('git', data.data.source_content, gitUrl)
              store.setOutline(data.data.outline)
            }
          } catch {
            // 忽略解析错误
          }
        } else if (msg.event === 'done') {
          // 分析完成
          store.setAnalyzing(false)
          store.setAnalysisStep(-1)
          throw new StopStreamError('done')
        }
      },
      
      // 8. 错误处理
      onerror(err: unknown) {
        if (err instanceof StopStreamError) {
          throw err
        }
        // 处理各种错误情况
        store.setAnalyzing(false)
        store.setAnalysisStep(-1)
        throw err
      }
    })
  } catch (err: unknown) {
    // 统一的错误处理
    if (err instanceof StopStreamError) {
      if (err.message !== 'done' && err.message !== 'aborted') {
        alert(`分析失败: ${err.message}`)
      }
      return
    }
    // 其他错误处理...
  }
}, [store])

关键点解析

  1. AbortController :这是现代 JavaScript 中用于取消 fetch 请求的 API。用户点击"停止"按钮时,会调用 abort() 方法中断连接。
  2. openWhenHidden: true:即使浏览器标签页被隐藏,也保持连接。这对于长时间运行的任务很重要。
  3. StopStreamError:自定义错误类,用于区分正常的"完成"和真正的错误。

2. parseFile:解析上传文件

analyzeGit 不同,文件解析通常很快,所以我们采用了一种"模拟进度"的策略:

typescript 复制代码
const parseFile = useCallback(async (file: File) => {
  store.setAnalyzing(true)
  store.setAnalysisStep(0)
  
  // 模拟进度 - 让用户看到系统在工作
  const timer1 = setTimeout(() => store.setAnalysisStep(1), 300)
  const timer2 = setTimeout(() => store.setAnalysisStep(2), 800)
  const timer3 = setTimeout(() => store.setAnalysisStep(3), 1300)
  const timer4 = setTimeout(() => store.setAnalysisStep(4), 1800)

  try {
    // 使用 FormData 上传文件
    const formData = new FormData()
    formData.append('file', file)

    const response = await fetch('/api/v1/project/parse', {
      method: 'POST',
      headers: { 
        'Authorization': localStorage.getItem('token') ? `Bearer ${localStorage.getItem('token')}` : ''
      },
      body: formData
    })
    
    const res = await response.json()
    if (res.code === 200 && res.data) {
      // 保存解析结果
      store.setSource('file', res.data.source_content)
      store.setOutline([]) // 单篇博客,没有大纲
    }
  } finally {
    // 清理定时器
    clearTimeout(timer1)
    clearTimeout(timer2)
    clearTimeout(timer3)
    clearTimeout(timer4)
    store.setAnalyzing(false)
  }
}, [store])

为什么使用模拟进度?

对于文件解析这种快速操作,真实的进度反馈可能太快(用户来不及看),模拟进度可以提供更好的用户体验。

3. generateSingle:生成单篇博客

这是生成单篇博客的核心方法。与 analyzeGit 类似,它也使用 SSE,但有重要的优化:

typescript 复制代码
const generateSingle = useCallback(async (sourceContent: string) => {
  store.setGenerating(true)
  store.clearGeneratedContent()
  
  // 创建新的 AbortController
  const ctrl = new AbortController()
  store.setAbortController(ctrl)

  // 关键优化:批量更新
  let pendingUpdates: string = ''
  let updateTimeout: ReturnType<typeof setTimeout> | null = null

  const flushUpdates = () => {
    if (pendingUpdates) {
      store.appendGeneratedContent(pendingUpdates) // 批量更新状态
      pendingUpdates = ''
    }
    if (updateTimeout) {
      clearTimeout(updateTimeout)
      updateTimeout = null
    }
  }
  
  try {
    await fetchEventSource('/api/v1/stream/generate', {
      // ... 配置与 analyzeGit 类似
      
      onmessage(msg) {
        if (msg.event === 'chunk') {
          // 累积更新,而不是立即更新
          pendingUpdates += msg.data
          if (!updateTimeout) {
            // 200ms 后批量更新(性能优化!)
            updateTimeout = setTimeout(flushUpdates, 200)
          }
        } else if (msg.event === 'done') {
          flushUpdates() // 确保最后的内容被更新
          store.setGenerating(false)
          store.reset()
          fetchBlogs() // 刷新博客列表
          throw new StopStreamError('done')
        }
      }
    })
  } catch (err) {
    // 错误处理...
  }
}, [store, fetchBlogs])

性能优化关键pendingUpdatesflushUpdates

想象一下,如果每次收到一个字符就更新 React 状态,那么:

  • 对于 1000 字的文章,会触发 1000 次状态更新
  • 每次状态更新都会触发组件重新渲染
  • 页面会变得非常卡顿

我们的解决方案:

  1. 累积更新:将短时间内收到的多个片段合并
  2. 延迟更新:每 200ms 批量更新一次状态
  3. 减少渲染:从 1000 次渲染减少到约 5 次渲染

4. generateSeries:生成系列博客

这是最复杂的方法,需要同时管理多个章节的生成:

typescript 复制代码
const generateSeries = useCallback(async () => {
  if (!store.outline || !store.sourceContent) return

  store.setGenerating(true)
  store.clearGeneratedContent()
  
  const ctrl = new AbortController()
  store.setAbortController(ctrl)

  // 注意:这里使用对象来管理多个章节
  let pendingUpdates: Record<number, string> = {}
  let updateTimeout: ReturnType<typeof setTimeout> | null = null

  const flushUpdates = () => {
    if (Object.keys(pendingUpdates).length > 0) {
      store.appendChapterContents(pendingUpdates) // 批量更新所有章节
      pendingUpdates = {}
    }
    if (updateTimeout) {
      clearTimeout(updateTimeout)
      updateTimeout = null
    }
  }
  
  try {
    // 只生成未完成的章节
    const remainingOutline = store.outline.filter(
      ch => store.chapterStatus[ch.sort] !== 'completed'
    )
    
    await fetchEventSource('/api/v1/stream/generate', {
      body: JSON.stringify({
        source_content: store.sourceContent,
        outline: remainingOutline, // 只发送未完成的章节
        source_type: store.sourceType || 'git',
        git_url: store.gitUrl || '',
        series_title: store.seriesTitle || '',
        parent_id: store.parentBlogId || '' // 系列博客的父ID
      }),
      
      onmessage(msg) {
        if (msg.event === 'chunk') {
          try {
            const data = JSON.parse(msg.data)
            
            if (data.status === 'generating') {
              // 开始生成新章节
              flushUpdates()
              store.clearChapterContent(data.chapter_sort)
              store.updateChapterStatus(data.chapter_sort, 'generating')
            } else if (data.status === 'streaming') {
              // 累积章节内容
              pendingUpdates[data.chapter_sort] = 
                (pendingUpdates[data.chapter_sort] || '') + data.content
              if (!updateTimeout) {
                updateTimeout = setTimeout(flushUpdates, 200)
              }
            } else if (data.status === 'completed') {
              // 章节完成
              flushUpdates()
              store.updateChapterStatus(data.chapter_sort, 'completed')
              fetchBlogs() // 刷新显示
            }
          } catch {
            // 忽略解析错误
          }
        }
      }
    })
  } catch (err) {
    // 错误处理...
  }
}, [store, fetchBlogs])

系列博客生成的复杂性

  1. 章节状态管理:每个章节都有独立的状态(等待中、生成中、已完成、错误)
  2. 父子关系:系列博客需要一个父博客来组织所有章节
  3. 断点续传:只生成未完成的章节,支持中途停止后继续
  4. 并行优化:后端可以并行生成多个章节,前端需要正确显示

错误处理的艺术

在整个 useBlogStream 中,错误处理是一个重点。我们处理了多种错误情况:

typescript 复制代码
// 自定义错误类,用于区分不同类型的"结束"
class StopStreamError extends Error {}

// 在 onerror 回调中
onerror(err: unknown) {
  if (err instanceof StopStreamError) {
    throw err  // 这是我们预期的"结束"
  }
  if (err instanceof DOMException && err.name === 'AbortError') {
    throw new StopStreamError('aborted')  // 用户取消
  }
  const e = err as any
  if (e?.name === 'AbortError' || 
      e?.message?.includes('AbortError') || 
      e?.message?.includes('aborted') || 
      e?.message?.includes('Failed to fetch')) {
    throw new StopStreamError('aborted')  // 各种取消情况
  }
  // 真正的错误
  console.error('SSE Connection Error:', err)
  store.setGenerating(false)
  throw err
}

错误分类

  1. 正常结束StopStreamError('done') - 任务完成
  2. 用户取消StopStreamError('aborted') - 用户点击了停止按钮
  3. 网络错误:连接断开、超时等
  4. 服务器错误:后端返回错误信息

状态管理:与 Zustand Store 的协作

useBlogStream 不直接管理状态,而是通过 Zustand Store 来管理。这种分离带来了几个好处:

typescript 复制代码
// 在 store/streamStore.ts 中
export const useStreamStore = create<StreamState>((set) => ({
  // 状态
  analyzing: false,
  generating: false,
  generatedContent: '',
  chapterContents: {},
  chapterStatus: {},
  
  // 操作方法
  setAnalyzing: (analyzing) => set({ analyzing }),
  appendGeneratedContent: (content) => 
    set((state) => ({ 
      generatedContent: state.generatedContent + content 
    })),
  appendChapterContent: (chapterSort, content) =>
    set((state) => ({
      chapterContents: {
        ...state.chapterContents,
        [chapterSort]: (state.chapterContents[chapterSort] || '') + content
      }
    })),
  // ... 其他方法
}))

架构优势

  1. 关注点分离:Hook 负责通信,Store 负责状态
  2. 易于测试:可以单独测试状态逻辑
  3. 性能优化:Zustand 有内置的性能优化
  4. 类型安全:完整的 TypeScript 支持

实战:如何在你的项目中使用

如果你想在自己的 React 项目中实现类似的流式通信,可以遵循以下步骤:

步骤 1:安装依赖

bash 复制代码
npm install @microsoft/fetch-event-source
# 或
yarn add @microsoft/fetch-event-source

步骤 2:创建自定义 Hook

typescript 复制代码
// useStream.ts
import { useCallback } from 'react'
import { fetchEventSource } from '@microsoft/fetch-event-source'

export const useStream = () => {
  const streamData = useCallback(async (input: string) => {
    let fullText = ''
    
    await fetchEventSource('/api/your-stream-endpoint', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ input }),
      
      onmessage(msg) {
        if (msg.event === 'chunk') {
          fullText += msg.data
          // 更新 UI...
        } else if (msg.event === 'done') {
          // 处理完成...
        }
      },
      
      onerror(err) {
        // 错误处理...
      }
    })
    
    return fullText
  }, [])
  
  return { streamData }
}

步骤 3:在组件中使用

typescript 复制代码
// YourComponent.tsx
import { useStream } from './useStream'

function YourComponent() {
  const { streamData } = useStream()
  const [content, setContent] = useState('')
  const [loading, setLoading] = useState(false)
  
  const handleGenerate = async () => {
    setLoading(true)
    setContent('')
    
    await streamData('你的输入')
    
    setLoading(false)
  }
  
  return (
    <div>
      <button onClick={handleGenerate} disabled={loading}>
        {loading ? '生成中...' : '开始生成'}
      </button>
      <div>{content}</div>
    </div>
  )
}

性能优化总结

useBlogStream 中,我们实现了多个性能优化:

  1. 批量更新:累积 200ms 内的更新,减少 React 渲染次数
  2. 条件渲染:只在必要时更新 UI
  3. 内存管理:及时清理定时器和中断的连接
  4. 错误恢复:优雅地处理网络中断
  5. 进度反馈:让用户知道系统正在工作

总结

useBlogStream 是 InkWords 前端架构中的通信枢纽,它:

  1. 封装了复杂的 SSE 通信逻辑,让组件可以简单调用
  2. 实现了完善的错误处理,提供良好的用户体验
  3. 优化了性能,通过批量更新减少不必要的渲染
  4. 支持多种生成模式,适应不同的使用场景

通过这个 Hook,我们实现了:

  • Git 仓库的实时分析
  • 文件上传的快速解析
  • 单篇博客的流式生成
  • 系列博客的并行生成
  • 统一的错误处理和用户反馈

架构图:useBlogStream 的工作流程

输入Git URL
上传文件
生成单篇
生成系列
chunk
done
error
用户操作
操作类型
analyzeGit
parseFile
generateSingle
generateSeries
建立SSE连接
上传文件API
fetchEventSource
解析结果
接收消息类型
累积更新
完成处理
错误处理
批量更新状态
触发重新渲染
设置源内容
清理状态
显示错误
实时显示进度
刷新博客列表
用户反馈

常见问题与解决方案

Q1: 为什么使用 SSE 而不是 WebSocket?

A: SSE 是单向通信(服务器推送到客户端),而 WebSocket 是双向的。对于博客生成这种"服务器推送内容,客户端只接收"的场景,SSE 更简单、更轻量。

Q2: 如何处理页面关闭时的连接?

A : 我们设置了 openWhenHidden: true,但页面关闭时连接会自动断开。对于重要的生成任务,建议:

  1. 后端保存生成进度
  2. 重新打开页面时可以继续
  3. 或者生成完成后自动保存到数据库

Q3: 如何测试 SSE 连接?

typescript 复制代码
// 测试工具函数
const testSSEConnection = async () => {
  try {
    await fetchEventSource('/api/v1/stream/test', {
      onmessage(msg) {
        console.log('收到消息:', msg)
      },
      onerror(err) {
        console.error('连接错误:', err)
      }
    })
    console.log('SSE 连接正常')
  } catch (error) {
    console.error('SSE 测试失败:', error)
  }
}

Q4: 如何优化大文本的流式渲染?

A: 除了批量更新,还可以:

  1. 虚拟滚动:只渲染可视区域的内容
  2. 分块渲染:将大文本分成多个段落分别渲染
  3. 延迟渲染:非关键内容稍后渲染

最佳实践

  1. 始终提供取消功能:长时间运行的任务必须允许用户取消
  2. 显示明确的进度:让用户知道系统正在工作
  3. 优雅的错误处理:不要因为一个错误导致整个应用崩溃
  4. 合理的超时设置:根据任务类型设置不同的超时时间
  5. 内存泄漏防护:及时清理事件监听器和定时器

扩展思考

useBlogStream 的设计模式可以扩展到其他流式场景:

  1. 实时聊天:修改消息格式,支持双向通信
  2. 文件上传进度:显示实际上传进度
  3. 数据同步:实时同步服务器状态变化
  4. 日志监控:实时显示系统日志

结语

流式通信是现代 Web 应用的重要特性,它极大地改善了用户体验。通过 useBlogStream 这个 Hook,InkWords 实现了复杂但用户友好的博客生成流程。

记住:好的用户体验不是让用户等待,而是让用户知道为什么要等待 以及等待的进度如何


下期预告:流式生成服务:单篇与系列博客的并发生成

在下一篇文章中,我们将深入后端,看看 InkWords 如何实现:

  • 单篇博客的流式生成管道
  • 系列博客的并行生成策略
  • 如何管理多个并发的生成任务
  • 生成进度的持久化与恢复机制

敬请期待!

相关推荐
人道领域2 小时前
【黑马点评日记02】Redis解决Tomcat集群Session共享问题
java·前端·后端·架构·tomcat·firefox
MRDONG12 小时前
从 Prompt 到智能体:深入理解 APE、Active-Prompt、DSP、PAL、ReAct 与 Reflexion
前端·react.js·prompt
翻斗包菜2 小时前
实战:使用 HAProxy 搭建高可用 Web 负载均衡集群
运维·前端·负载均衡
凯强同学2 小时前
不上班,想裸辞,可以不可以?
服务器·前端·javascript
吹个口哨写代码2 小时前
h5/小程序直接读本地/在线的json文件数据
前端·小程序·json
和科比合砍81分2 小时前
【无标题】
前端
Z_Wonderful2 小时前
Qiankun 微前端(React+Vue)基础速通webpack
前端·vue.js·react.js
史迪仔01122 小时前
[QML] Popup 与 Dialog
开发语言·前端·javascript·c++·qt