一、那个让我想删代码的午后
"去年实习时,我正在开发一个AI消息回答模块,用户等了5~6秒才看到回答,产品经理拍说:'能不能像deepseek那样字一个一个蹦出来?,我心想这不是打字机效果吗,说实话,我一直以为打字机效果是前端模拟,使用setInterval轮询就行了。然后自己开发时,先试着写了个打字机:
ini
let index =0
// 模拟事件流
const events = [
{ data: '{"data": {"answer": "你"}}' },
{ data: '{"data": {"answer": "好"}}' },
{ data: '{"data": true}' }
];
let index = 0;
const timer = setInterval(() => {
if (index >= events.length) {
clearInterval(timer);
return;
}
const event = events[index++];
const val = JSON.parse(event.data);
if (typeof val.data === 'boolean' && val.data) {
console.log('结束');
clearInterval(timer);
return;
}
if (val.data?.answer) {
document.getElementById('output').innerText += val.data.answer;
}
}, 200);
但是这种方法在生产环境上是错的。因为定时器是间隔触发,网络延迟为0,真实流式输出网络不确定,块到达时间可能不均与,大小也不固定,还有太多问题了,不能展示流式输出优势,用户体验也不好。
二、真相:后端在"流",前端在"接"
****deepseek的"流淌"不是前端模拟的,而是后端一边生成一边发送(像水龙头流水,不是水桶装满再倒)。
学习{ stream: true }
ini
const decoder = new TextDecoder('utf-8');
const text = decoder.decode(value,{ stream: true }) ;
这行代码的作用是从网络流中读取的二进制数据块(value)解码为字符串。{ stream: true } 参数是最为重要的,它告诉解码器当前的数据块只是整个流式数据的一部分,不完整,要保留后拼接。 我出于好奇,试了下不加{ stream: true },结果出现了乱码,原因是UTF-8里一个汉字占3字节,假如第一个chunk 只返回了两个字节,第二个chunk返回了一个字节。解码器认为每个块都是独立完整的,不完整就报错。所以需要设置 { stream: true }来缓存后拼接
三、SSE格式的真相:为什么需要EventSourceParserStream?
介绍一个库,EventSourceParserStream 后端返回的数据格式:
kotlin
data: {"data": {"answer": "你"}}
data: {"data": {"answer": "好"}}
data: {"data": true} // 结束标志
问题来了:为什么不能直接用JSON.parse?
- SSE格式不是纯JSON !它是
data:前缀+JSON的格式 - 网络传输是流式,可能把一条消息切成多段
- 需要解析
data:前缀,提取JSON内容 既然不是JSON 格式的字符串,自然不能直接使用JSON.parse转为对象
为什么我选择EventSourceParserStream?
当然是这个库好用嘛。SSE的边界也多,省的自己写解析器了。
- 自动去除
data:前缀 - 处理
\n\n分隔符 - 处理不完整的JSON片段
代码演示
npm install --save eventsource-parser
javascript
import {EventSourceParserStream} from 'eventsource-parser/stream'
const reader = response.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(new EventSourceParserStream())
四、为什么需要手动解析JSON?(SSE的真相)
SSE协议规定:data: 是前缀,后面才是数据。EventSourceParserStream会自动把data: 去掉,只留下JSON字符串
kotlin
//通过EventSourceParserStream处理后,event.data是个纯json字符串
cosnt val = JSON.parse(event.data)
五、终极优化:用 for await 让代码更优雅
之前:
javascript
whlie(true) {
const {value,done} =await reader.read()
if(done) break;
}
现在:
kotlin
for await(const event of reader){
try{
const val =JSON.parse(event.data);
const d=val.data;
//结束标志 :看到true就结束(与后端定好)
if(typeof d ==="boolean" && d){
setDone(true);
break;
if(d.answer){
setAnsewr(d);
}
}
}
catch{
//错误处理
}
}
✅ 为什么 for await 更好?
- 自动处理流结束 (不用手动检查
done) - 代码更简洁(少写20%的样板代码)
- 符合ES2020规范(现代浏览器完全支持)
七、最终代码:已经上线6个月
这里贴一段,真实项目代码:
typescript
import { EventSourceParserStream } from 'eventsource-parser/stream';
const send = useCallback(
async (
body: any,
controller?: AbortController,
): Promise<{ response: Response; data: ResponseType } | undefined> => {
// 关键1:初始化请求取消控制器
initializeSseRef();
// 关键2:重置完成状态
setDone(false);
try {
const response = await fetch(url, {
method: 'POST',
headers: {
[Authorization]: getAuthorization(),
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
signal: controller?.signal || sseRef.current?.signal,
});
// 500错误直接处理
if (response.status === 500) {
message.error(i18n.t('message.500'));
setDone(true);
return;
}
// 核心:管道式处理 + for await
const stream = response.body
.pipeThrough(new TextDecoderStream()) //二进制-> 字符串
.pipeThrough(new EventSourceParserStream());// 字符串 -> SSE 对象
// 关键:用for await循环处理流(现代写法!)
for await (const event of stream) {
try {
const val = JSON.parse(event.data);
const d = val.data;
// 结束标志:看到true就结束
if (typeof d === 'boolean' && d) {
setDone(true);
break;
}
// 处理普通内容
if (d?.answer && d.answer.trim() !== '') {
setAnswer({
...d,
conversationId: body?.conversation_id,
});
}
} catch (e) {
console.error('解析失败', e);
}
}
setDone(true);
return { data: await response.clone().json(), response };
} catch (e) {
setDone(true);
console.error('请求失败', e);
}
},
[initializeSseRef, url], // 依赖项这么少的原因
);
//创建控制器
const sseRef =useRef(null)
// 初始化:每次新请求取消旧的请求
const initializeSseRef = (){
if (sseRef.current) { sseRef.current.abort(); // ✅ 关键!取消旧请求 }
sseRef.current =new AbortController();
}
我的成长
写这个Hook的时候,我一开始以为加个setTImeout,后来老老实实啃了SSE协议,解决了中文乱码,又发现了EventSourceParserStream这个库,不用自己写解析器,代码从while改成了for await of,清爽了不少。 最后把send、answer、done暴漏出去了。Hook后面调试总算能用。最后吐槽下,前端的坑是真多,但好在有各种轮子可以抱。(~ ̄▽ ̄)~