第五篇:前端任务状态管理与实时反馈 (SSE 客户端篇)

📁 专栏系列:AI 提示词与知识库管理系统

项目技术栈 :Node.js + Express + 阿里云百炼 (Bailian) + SSE + Zustand
核心目标:实现文档自动切片、向量索引构建和智能问答功能。


第五篇:前端任务状态管理与实时反馈 (SSE 客户端篇)

适用场景 :React 前端状态管理、SSE 长连接处理
大神提示:本篇主要记录前端如何优雅地处理异步任务轮询,避免重复请求,大神可略过,求轻喷 🙏

本篇是 RAG 系统的前端收尾环节。虽然后端已经实现了状态流转,但如果前端做得不好,很容易出现**"重复建立连接""内存泄漏"**的问题。

核心逻辑是:Zustand 全局状态去重 + 单例连接控制


1. 核心痛点:如何避免重复请求?

在 SPA(单页应用)中,用户可能会频繁切换页面或组件重渲染。如果没有状态管理:

  • 用户点击上传 -> 建立 SSE 连接。
  • 用户切到别的页面再切回来 -> 组件重新挂载 -> 又建立一个新连接。
  • 结果:浏览器同时维持了 5 个连接在听同一个任务,控制台疯狂打印日志。

解决方案

  1. Zustand Store :用一个全局的 Set 存储所有"正在进行的任务 ID"。
  2. 单例模式:在请求层判断,如果该 ID 已经在监听中,直接返回,不再建立新连接。

2. 核心代码解析 (TypeScript + React)

本篇分为两部分:状态定义和连接逻辑。

第一部分:状态定义 (Zustand)

使用 Set 而不是 Array,因为 Set 天然保证唯一性,且 has() 查找速度是 O(1),比数组的 includes() (O(n)) 更快。

typescript 复制代码
import { create } from 'zustand';

// 类型定义
interface TaskState {
  activeTaskIds: Set<string>;
  actions: {
    addTask: (id: string) => void;
    removeTask: (id: string) => void;
  };
}

const useTaskStore = create<TaskState>((set) => ({
  activeTaskIds: new Set(),
  actions: {
    addTask: (id: string) =>
      set((state) => {
        const newSet = new Set(state.activeTaskIds);
        newSet.add(id); // 如果已存在,Set 会自动忽略
        return { activeTaskIds: newSet };
      }),
    removeTask: (id: string) =>
      set((state) => {
        const newSet = new Set(state.activeTaskIds);
        newSet.delete(id);
        return { activeTaskIds: newSet };
      }),
  },
}));

// 导出 Hooks
export { useTaskStore };
export const useTaskActions = () => useTaskStore((state) => state.actions);
export const useActiveTaskIds = () => useTaskStore((state) => state.activeTaskIds);

第二部分:连接逻辑 (Utils)

这里有一个非常关键的优化点:listeningTasks

Zustand 里的 activeTaskIds 是给 UI 用的(驱动视图更新),而 listeningTasks 是给逻辑层用的(防止代码重复执行)。两者结合才能保证万无一失。

typescript 复制代码
import { message } from "antd";
import { useTaskStore } from "@/store";
import { getTaskListAction } from "@/api/rag";

// 🔑 关键:逻辑层的去重 Set
// Zustand 是为了驱动 UI 更新,这个 Set 是为了防止代码逻辑重复执行
const listeningTasks = new Set();

/**
 * 启动单个任务的监听器
 * @param taskId 任务 ID
 */
export const startTaskListener = async (taskId: string) => {
  // ✅ 第一层防御:逻辑层去重
  // 即使组件多次调用,这里也能拦截住
  if (listeningTasks.has(taskId)) {
    console.log('该任务已在监听列表中,跳过重复请求', taskId);
    return;
  }

  listeningTasks.add(taskId);
  console.log('开始监听任务状态', taskId);

  try {
    const response = await fetch(`/proxy/api/ragEngine/getFileStatus/${taskId}`, {
      method: 'GET',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${localStorage.getItem('token')}`,
      },
    });

    if (!response.ok) {
      throw new Error('连接建立失败!');
    }

    const reader = response.body?.getReader();
    const decoder = new TextDecoder();

    // ✅ 第二层防御:无限循环读取流
    // done 为 true 时才会退出
    while (true && reader) {
      const { done, value } = await reader.read();
      if (done) break;

      const chunk = decoder.decode(value, { stream: true });
      const lines = chunk.split('\n');

      for (const line of lines) {
        if (line.startsWith('data: ')) {
          const dataStr = line.slice(6).trim();
          try {
            const parsed = JSON.parse(dataStr);
            console.log('后端推送数据:', parsed);

            // ✅ 第三层防御:状态判断与清理
            // 只有成功或失败才清理,否则一直挂着
            if (parsed.status === "SUCCESS") {
              message.success('文件解析成功,可以进行问答了!');
              // 1. 通知 Store 移除任务(驱动 UI 更新)
              useTaskStore.getState().actions.removeTask(taskId);
              // 2. 退出循环,断开连接
              return;
            }
          } catch (e) {
            console.warn('解析 JSON 失败:', line);
          }
        }
      }
    }
  } catch (error: any) {
    if (error.name === 'AbortError') {
      console.log('用户主动中断了请求');
    } else {
      console.error('SSE 连接错误:', error);
      message.error('文件解析异常中断');
    }
  } finally {
    // ✅ 第四层防御:确保清理逻辑层状态
    listeningTasks.delete(taskId);
  }
};

/**
 * 初始化全局监听器
 * 应用启动时调用,恢复上次未完成的任务
 */
export const initGlobalTaskListener = () => {
  getTaskListAction().then((res: any) => {
    if (res.code !== 200) return;

    // 筛选出还在进行中的任务
    const tasks = res.data.filter((f: any) => !['SUCCESS', 'FAILED'].includes(f.status));
    
    tasks.forEach((task: any) => {
      // 1. 先加入 Store,防止页面上的按钮重复点击
      useTaskStore.getState().actions.addTask(task.taskId);
      // 2. 开启监听
      startTaskListener(task.taskId);
    });
  });
};

3. 为什么用 Set 而不是 MapArray

数据结构 适用场景 本项目场景分析
Array (数组) 存储有序列表,需要遍历或通过下标访问。 不合适 。如果用数组,每次都要遍历检查 ID 是否存在(arr.includes(id)),效率低且代码冗余。
Map (映射) 存储键值对,需要通过 Key 查找 Value。 过度设计。如果我们不需要存储额外的 Value(比如任务进度条数值),只关心 ID 是否存在,Map 就显得多余。
Set (集合) 存储唯一值,判断"是否存在"。 完美匹配 。我们的需求仅仅是:"这个任务 ID 123 正在运行吗?" (Set.has(id))。它自动去重,查找速度快 (O(1))。

💡 总结

至此,我们的 RAG 系统前端状态管理闭环了。从用户上传文件开始,到后端处理,再到前端实时感知,整个流程已经跑通。

最后的建议

  1. 入口调用 :记得在 App.tsx 或路由守卫中调用 initGlobalTaskListener
  2. 组件调用 :文件上传成功后,调用 useTaskActions().addTask(id)startTaskListener(id)

代码获取提示

由于篇幅限制,UI 组件部分(如进度条组件)没有贴出。如果你需要完整的前端源码(含 React 组件封装),可以私信我获取,欢迎交流讨论!

相关推荐
LIO1 小时前
Axios Token 无感刷新机制:原理、实现与最佳实践
前端·axios
「已注销」2 小时前
面试分享:二本靠7轮面试成功拿下大厂P6
前端·javascript·面试
Lee川2 小时前
深入浅出:用 React 打造高性能懒加载无限滚动组件
前端·react.js
walking9572 小时前
重新学习前端之JavaScript
前端·vue.js·面试
walking9572 小时前
重新学习前端之HTML
前端·vue.js·面试
walking9572 小时前
重新学习前端之Vue
前端·vue.js·面试
牛奶2 小时前
开发者的"奇技淫巧":那些让你效率翻倍的实战技巧
前端·后端·程序员
咸鱼翻身更入味2 小时前
Vue创建一个简单的Agent聊天——工具调用
前端
Timo来了2 小时前
indexDB的用法示例
前端