fetch+readablestream(Streamable)流式输出
流程图

代码
data字段
data() {
return {
transcript: "",
answerBuffer: "",
thinkingBuffer: "",
thinkingCompleteMarkdown: "",
answerCompleteMarkdown: "",
isThinking: false,
renderTimer: null,
// 中断控制器,用于取消上一次流式请求
streamAbortController: null,
chats: [],
historyId: "",
uploadImgUrl: "",
textarea: "",
textareaHeight: 3,
// 防止短时间多次强制渲染
isForceRendering: false,
};
}
sendMessage发送消息
sendMessage() {
// ===== 新增:终止上一条还在跑的流式请求 =====
if (this.streamAbortController) {
this.streamAbortController.abort();
this.streamAbortController = null;
}
// 清理上一轮定时器
clearTimeout(this.renderTimer);
this.renderTimer = null;
// 重置流式缓存变量
this.transcript = "";
this.answerBuffer = "";
this.thinkingBuffer = "";
this.thinkingCompleteMarkdown = "";
this.answerCompleteMarkdown = "";
this.isForceRendering = false;
let id = this.chats.length;
if (this.isTextEmpty) {
this.$message.error("还没说呢");
return;
} else {
if (this.uploadImgUrl !== "") {
this.sendImg();
} else {
let message = {
id: id,
type: "user",
avatar: this.$store.state.avatarImageUrl,
thinking: "",
answer: "",
content: this.textarea,
imgUrl: "",
modules: [],
};
const aiMessage = {
id: id + 1,
type: "assistant",
avatar:
"https://oss-mtc.oss-cn-hangzhou.aliyuncs.com/2c927a761478a4a4248706b534bac146.gif",
thinking: "",
answer: "",
content: "",
imgUrl: "",
modules: [],
};
this.chats.push(message);
this.isThinking = true;
this.$nextTick(() => {
const chatContainer = this.$refs.cultureBox;
if (chatContainer) {
chatContainer.scrollTop = chatContainer.scrollHeight;
}
});
const url = `${this.$baseUrl}agent/chat`;
const chatAto = {
message: message.content,
historyId: this.historyId,
};
this.fetchData2(url, chatAto, aiMessage);
setTimeout(() => {
this.getHistoryId();
}, 500);
this.textarea = "";
this.transcript = "";
this.textareaHeight = 3;
this.uploadImgUrl = "";
}
}
},
fetchData2包装请求
async fetchData2(url, data, aiMessage) {
return this.fetchDataCommon(url, data, aiMessage, {
"Content-Type": "application/json",
});
},
fetchDataCommon发送请求
async fetchDataCommon(url, data, aiMessage, headers) {
let isStreamComplete = false;
// 创建本次请求的中断控制器
const abortController = new AbortController();
this.streamAbortController = abortController;
try {
const response = await fetch(url, {
method: "POST",
headers: {
...headers,
token: `${this.$store.state.token}`,
},
body:
typeof data === "object" && !(data instanceof FormData)
? JSON.stringify(data)
: data,
signal: abortController.signal,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
if (!response.body) {
throw new Error("当前环境不支持 ReadableStream");
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
let currentEvent = { name: null, data: "" };
while (true) {
const { done, value } = await reader.read();
if (done) {
// 处理缓存里最后一条事件
if (currentEvent.name) {
this.handleEvent(currentEvent, aiMessage);
}
isStreamComplete = true;
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
if (line.trim() === "") {
if (currentEvent.name) {
this.handleEvent(currentEvent, aiMessage);
currentEvent = { name: null, data: "" };
}
} else if (line.startsWith("event:")) {
currentEvent.name = line.split(":")[1].trim();
} else if (line.startsWith("data:")) {
currentEvent.data += line.slice(5);
}
}
}
if (isStreamComplete) {
// ✅ 流全部结束:强制最后渲染一次,清理资源
clearTimeout(this.renderTimer);
this.renderMarkdown("思考");
this.renderMarkdown("答案");
// 缓冲区统一清空(重点:不再在render里清空)
this.thinkingBuffer = "";
this.answerBuffer = "";
this.streamAbortController = null;
this.isThinking = false;
this.getHistory();
}
} catch (error) {
// 区分:手动abort 和 真实报错
if (error.name === "AbortError") {
console.log("流式请求被手动终止");
} else {
console.error("请求出错:", error);
this.$message.error("回答异常中断,请稍后重试!");
}
this.isThinking = false;
this.streamAbortController = null;
}
},
handleEvent处理分片
handleEvent(event, aiMessage) {
if (this.isThinking) {
this.isThinking = false;
this.chats.push(aiMessage);
}
const handleEventData = (completeMarkdownKey, bufferKey, eventName) => {
// 1. 处理换行归一
const normalizedData = event.data
.replace(/\\n/g, "\n")
.replace(/\r?\n\r?\n/g, "\n\n");
// 2. 追加完整文本 和 buffer
this[completeMarkdownKey] += normalizedData;
this[bufferKey] += normalizedData;
// 3. 防抖渲染
clearTimeout(this.renderTimer);
this.renderTimer = setTimeout(() => {
this.renderMarkdown(eventName);
}, 100);
// 4. 满足强制渲染标记,立刻渲染(增加锁防止重复调用)
if (!this.isForceRendering && this.shouldForceRender(normalizedData)) {
this.isForceRendering = true;
this.renderMarkdown(eventName);
setTimeout(() => {
this.isForceRendering = false;
}, 50);
}
};
if (event.name === "思考") {
handleEventData("thinkingCompleteMarkdown", "thinkingBuffer", "思考");
} else if (event.name === "答案") {
handleEventData("answerCompleteMarkdown", "answerBuffer", "答案");
}
},
renderMarkdown渲染md
renderMarkdown(type) {
const bufferKey = this.getBufferKey(type);
// 没有数据直接返回
if (!this[bufferKey] && !this.getCompleteMarkdownKey(type)) return;
try {
const completeMarkdownKey = this.getCompleteMarkdownKey(type);
const renderedContent = this.md.render(this[completeMarkdownKey]);
this.$nextTick(() => {
const chatKey = this.getChatKey(type);
const lastAiMsg = this.chats[this.chats.length - 1];
if (lastAiMsg) {
lastAiMsg[chatKey] = renderedContent;
}
this.scrollToBottom();
});
} catch (error) {
console.error("Markdown 渲染错误:", error);
this.$message.error("Markdown 渲染错误");
}
},
工具函数
scrollToBottom() {
this.$nextTick(() => {
const chatContainer = this.$refs.cultureBox;
if (chatContainer) {
chatContainer.scrollTop = chatContainer.scrollHeight;
}
});
},
shouldForceRender(text) {
const forceRenderMarkers = [
"###",
"##",
"####",
"\n\n",
"。",
"!",
"?",
];
return forceRenderMarkers.some((marker) => text.includes(marker));
},
getBufferKey(type) {
return type === "思考" ? "thinkingBuffer" : "answerBuffer";
},
getCompleteMarkdownKey(type) {
return type === "思考"
? "thinkingCompleteMarkdown"
: "answerCompleteMarkdown";
},
getChatKey(type) {
return type === "思考" ? "thinking" : "answer";
},
面试问题
1.为什么用fetch不用axios
浏览器环境,传统 axios 默认适配器(XHR)不支持真正的 ReadableStream 流式逐块接收;fetch 原生基于 Streams 标准,response.body 直接返回 ReadableStream,所以 AI Streamable / SSE 流式对话大家首选 fetch
⚠️ 补充:Node.js 里 axios 很早就支持
responseType: 'stream';axios 1.x 新版本浏览器端可以切换adapter: 'fetch'适配器,间接支持流,但并不是主流方案
fetch(浏览器原生) 底层标准:WHATWG Fetch API,原生绑定 ReadableStream
const res = await fetch(url);
const reader = res.body.getReader(); // ✅ 原生可读流,收到一块就能读一块
- 数据分片到达,立刻读取,不需要等后端返回全部内容
- 搭配
AbortController原生支持随时中断流式请求(停止 AI 生成) - 完全适配 OpenAI / 各类大模型 SSE(event:xxx data:xxx)流式协议
axios 默认浏览器适配器(XMLHttpRequest) XHR 规范里 responseType 合法值不包含 stream,只能是:arraybuffer / blob / document / json / text
- XHR 会缓存完整响应,等后端全部传输完毕,你才能拿到完整字符串
onDownloadProgress只能拿到进度数字,不能直接拿到每一块原始二进制分片,只能拿到累积拼接后的文本,不是真正流式- 如果你强行用 onDownloadProgress 模拟 SSE,要自己维护 buffer,容易出现中文截断、分包错乱,不稳定
误区澄清:axios 能不能做流式
- Node.js 服务端:axios 原生支持
responseType: 'stream',可用 - 浏览器 axios ≥1.x:切换 fetch 适配器
axios({
method:"POST",
url:"/stream",
adapter:"fetch", // 关键:底层切到fetch
responseType:"stream"
})
缺点:
- 很多老项目还是 axios 0.x,没有 fetch 适配器
- 全局 axios 拦截器、baseURL、超时、token 处理,切换适配器后行为容易不一致,踩坑多
- 社区示例、大模型官方 SDK(OpenAI)全部是 fetch 实现,遇到问题资料少
❌ 绝对不能:直接用普通 axios.post 接收 SSE
// ❌ 错误写法,会等待全部输出完成后一次性返回
axios.post('/agent/chat', data).then(res=>{})
为什么行业里 Streamable、AI 流式对话统一用 fetch?
- 标准原生,无额外依赖,不用引入 axios
- 精准控制流生命周期:AbortController 随时终止生成(停止回答)
- 直接操作二进制 Uint8Array + TextDecoder,完美处理中文不截断
- 所有大模型流式接口规范(SSE chunked)原生适配
- 不存在适配器兼容问题,跨框架(Vue/React/ 原生 JS)写法统一
2.流式输出为什么不用 EventSource,选择 fetch + ReadableStream?
后端返回 SSE 协议 chunked 流式响应,前端用
fetch获取response.bodyReadableStream,手动解析 event/data。 没有用浏览器自带EventSource,原因:
- EventSource 只支持 GET 请求,我们要传复杂 JSON 请求体,必须 POST;
- EventSource 设置请求头、携带 token 比较麻烦;
- 需要
AbortController灵活中断请求,切换会话、发送下一条消息时终止上一条流,防止乱数据;- 自己解析流逻辑可控,可以区分多事件类型(
event:思考/event:答案)。
补充:EventSource 适合简单 GET 订阅,POST 场景基本都会手动读 ReadableStream。
3.SSE 的协议格式是什么?空行的作用?
event:思考
data:xxx思考片段
event:答案
data:xxx回答片段
event:声明事件名称;data:是数据载荷;\n\n(连续换行空行)代表一条事件结束,浏览器 / 我们代码收到空行,就把当前缓存的 event+data 当作一条完整事件去处理。
这就是代码里
if(line.trim()==="")触发 handleEvent 的原因。
4.ReadableStream、reader.read () 返回值是什么?
参考答案 const {done, value} = await reader.read()
done:布尔,true 代表流传输全部结束;value:Uint8Array二进制字节数组;
TCP 会分片,一次 read 不一定拿到完整一条业务数据,必须本地 buffer 缓存不完整的行。
5.TextDecoder 的 {stream:true} 参数是干嘛的?不加会出现什么问题?
参考答案 decoder.decode(value, {stream:true})
stream:true:流式解码,多字节字符(中文、emoji)被 TCP 分片截断时,把残缺字节缓存,下一块数据到来再完成解码。- 不加
stream:true:遇到被切割的中文,直接乱码、出现问号。
6.AbortController 在这个项目做什么?为什么一定要加?
参考答案 用于中断 fetch 请求。 场景:用户快速连续发消息、切换对话,上一个 AI 流还在返回,如果不 abort,旧流还会继续执行 handleEvent,往当前聊天列表乱写入旧思考 / 答案,造成消息错乱。
发送新消息时调用
abort(),流循环会抛出AbortError,需要区分这个错误,不要给用户弹异常报错。
7.解释代码里那个 buffer 变量作用?为什么要 buffer = lines.pop()?
参考答案 网络 TCP 层会把服务端返回的字符串任意切割分片,分片边界不一定刚好在换行符上。 例如: 服务端输出:event:答案\ndata:abc123\n\n 网络分片切成两段:event:答案\ndata:abc 和 123\n\n。
buffer.split('\n')按换行切割所有行;lines.pop()取出数组最后一项,这一项大概率是不完整的半行,放回 buffer,留给下一次reader.read()拼接;- lines 数组剩下的都是完整行,循环解析。
如果不做这个缓存,会丢数据、解析错乱。
8.为什么要防抖渲染,同时还要有 shouldForceRender 强制渲染逻辑?
参考答案
- 防抖:AI 流返回颗粒非常细,可能几十毫秒就回来一小块片段,如果每块都执行 markdown 渲染 + DOM 操作,会频繁重渲染,页面卡顿闪烁;设置 100ms 防抖合并多次渲染。
- 但是防抖会带来延迟感,看到句号、双换行、标题这些语义结束标记,强制立刻渲染,提升用户体验。
追问:那强制渲染会不会多次重复渲染? 答:会,所以加了
isForceRendering锁,短时间避免重复调用渲染函数。
更多推荐

所有评论(0)