📁 专栏系列:AI 提示词与知识库管理系统
项目技术栈 :Node.js + Express + 阿里云百炼 (Bailian) + SSE + Zustand
核心目标:实现文档自动切片、向量索引构建和智能问答功能。
第五篇:前端任务状态管理与实时反馈 (SSE 客户端篇)
适用场景 :React 前端状态管理、SSE 长连接处理
大神提示:本篇主要记录前端如何优雅地处理异步任务轮询,避免重复请求,大神可略过,求轻喷 🙏
本篇是 RAG 系统的前端收尾环节。虽然后端已经实现了状态流转,但如果前端做得不好,很容易出现**"重复建立连接"或"内存泄漏"**的问题。
核心逻辑是:Zustand 全局状态去重 + 单例连接控制。
1. 核心痛点:如何避免重复请求?
在 SPA(单页应用)中,用户可能会频繁切换页面或组件重渲染。如果没有状态管理:
- 用户点击上传 -> 建立 SSE 连接。
- 用户切到别的页面再切回来 -> 组件重新挂载 -> 又建立一个新连接。
- 结果:浏览器同时维持了 5 个连接在听同一个任务,控制台疯狂打印日志。
解决方案:
- Zustand Store :用一个全局的
Set存储所有"正在进行的任务 ID"。 - 单例模式:在请求层判断,如果该 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 而不是 Map 或 Array?
| 数据结构 | 适用场景 | 本项目场景分析 |
|---|---|---|
| Array (数组) | 存储有序列表,需要遍历或通过下标访问。 | 不合适 。如果用数组,每次都要遍历检查 ID 是否存在(arr.includes(id)),效率低且代码冗余。 |
| Map (映射) | 存储键值对,需要通过 Key 查找 Value。 | 过度设计。如果我们不需要存储额外的 Value(比如任务进度条数值),只关心 ID 是否存在,Map 就显得多余。 |
| Set (集合) | 存储唯一值,判断"是否存在"。 | 完美匹配 。我们的需求仅仅是:"这个任务 ID 123 正在运行吗?" (Set.has(id))。它自动去重,查找速度快 (O(1))。 |
💡 总结
至此,我们的 RAG 系统前端状态管理闭环了。从用户上传文件开始,到后端处理,再到前端实时感知,整个流程已经跑通。
最后的建议:
- 入口调用 :记得在
App.tsx或路由守卫中调用initGlobalTaskListener。 - 组件调用 :文件上传成功后,调用
useTaskActions().addTask(id)和startTaskListener(id)。
代码获取提示 :
由于篇幅限制,UI 组件部分(如进度条组件)没有贴出。如果你需要完整的前端源码(含 React 组件封装),可以私信我获取,欢迎交流讨论!