作者:互联网 时间: 2026-09-01 18:24:54
不用第三方SDK!Vue3原生Fetch实现DeepSeek流式输出,90%前端都会踩的分片解析坑一次性解决并不只看表面做法,关键还要理解相关条件、限制和后续影响。
做AI聊天页面你一定遇过两种糟心体验:
JSON.parse解析失败,文字丢失乱码网上很多示例只给极简demo,没有处理分片容错,上线必崩。这篇内容以Vite+Vue3+原生Fetch完整实现DeepSeek对话,同时支持流式打字机/一次性返回双模式,自带buffer分片容错逻辑,看完你能学到:
后端等AI完整生成全部文本,组装成完整JSON一次性返回。前端调用response.json()直接解析,优点代码简单,缺点等待时间长,交互割裂。
大模型每生成一段Token,就封装成data: JSON格式通过二进制流实时推送到前端:
response.body 二进制可读流(Uint8Array字节数组)n分割,结尾单独发送data: [DONE]标识流结束response.body.getReader():创建流读取器,逐块拉取二进制数据TextDecoder():二进制Uint8Array转UTF-8字符串,解决中文乱码data:报文,下一轮拼接完整再解析本方案纯浏览器原生API,不需要openai/langchain等第三方SDK,Vite Vue3项目开箱即用。
项目根目录新建.env文件,填入DeepSeek密钥:
VITE_DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxxxxxx
Vite通过import.meta.env.VITE_XXX读取环境变量,打包后不会明文暴露密钥。
<script setup>import { ref } from 'vue'// 响应式状态const question = ref('讲一个中国龙的故事'); // 用户输入提问const content = ref(''); // AI输出内容const stream = ref(true); // 是否开启流式输出开关// 核心请求函数const update = async () => {// 空提问拦截,避免无效请求if (!question.value) return;content.value = '思考中...';// DeepSeek对话接口地址const endpoint = 'https://api.deepseek.com/chat/completions';const headers = {'Content-Type': 'application/json',Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`};// 发起POST请求const response = await fetch(endpoint, {method: 'POST',headers,body: JSON.stringify({model: 'deepseek-v4-flash',messages: [{ role: 'user', content: question.value }],stream: stream.value // 动态控制流式开关})})// ========== 分支1:流式输出(打字机效果,本文核心) ==========if (stream.value) {content.value = ""; // 清空思考中占位文字// 获取二进制流读取器const reader = response.body?.getReader();// 二进制转UTF8文本解码器const decoder = new TextDecoder();let done = false; // 流读取完成标记let buffer = ''; // 残缺分片缓存(解决JSON截断报错核心)// 循环持续拉取二进制分片while (!done) {// 异步读取一块二进制数据const { value, done: doneReading } = await reader?.read();done = doneReading;// 拼接上一轮残留残缺片段 + 当前新解码文本const chunkValue = buffer + decoder.decode(value);buffer = ""; // 缓存已合并,清空等待下一轮残缺数据// 按换行分割文本,过滤仅保留data:开头的SSE有效行const lines = chunkValue.split('n').filter((line) => line.startsWith('data: '))// 逐行解析每条SSE报文for (const line of lines) {// 切掉前缀 data: 6个字符,获取纯JSON/结束标识const incoming = line.slice(6);// 检测到结束标识,终止全部循环if (incoming === '[DONE]') {done = true;break;}try {// 解析JSON字符串const data = JSON.parse(incoming);// 流式专属增量文本deltaconst delta = data.choices[0].delta.content;// 存在增量文字则追加到页面,实现打字机效果if (data && delta) {content.value += delta;}} catch (err) {// JSON解析失败=分片不完整,存入buffer下一轮拼接buffer = `data: ${incoming}`;}}}}// ========== 分支2:非流式一次性返回 ==========else {const data = await response.json();// 非流式使用message完整文本,而非delta增量content.value = data.choices[0].message.content;}}</script><template><div class="container"><!-- 提问输入区域 --><div><label>输入:</label><input class="input" v-model="question" /><button @click="update">提交</button></div><!-- 流式开关 + AI回答展示区 --><div class="output"><div><label>Streaming流式输出</label><input type="checkbox" v-model="stream" /></div><div>{{ content }}</div></div></div></template><style scoped>.container {display: flex;flex-direction: column;align-items: flex-start;justify-content: flex-start;height: 100vh;font-size: 0.85rem;padding: 20px;}.input {width: 300px;padding: 4px 8px;}.output {margin-top: 12px;min-height: 300px;width: 100%;text-align: left;line-height: 1.6;}button {padding: 4px 12px;margin-left: 8px;cursor: pointer;}</style>
if (stream.value) {content.value = "";const reader = response.body?.getReader();const decoder = newTextDecoder();let done = false;let buffer = '';
reader:流专属读取器,串行读取二进制数据,保证顺序不乱decoder:全局解码器,循环内复用,避免中文跨分片乱码done:外层while循环开关,控制数据流是否全部接收完毕buffer:全文最关键容错变量,专门存储被TCP分包截断的半条data:报文while (!done) {const { value, done: doneReading } = await reader?.read();done = doneReading;const chunkValue = buffer + decoder.decode(value);buffer = "";const lines = chunkValue.split('n').filter((line) => line.startsWith('data: '))}
reader.read():异步阻塞读取,有新分片立刻返回,无数据持续等待chunkValue = buffer + 新文本:核心容错操作,把上一轮残缺片段和本次新数据拼接,保证报文完整split('n'):SSE协议每条数据换行分隔,切割后过滤无效空行、心跳包,只保留data:有效数据for (const line of lines) {const incoming = line.slice(6);if (incoming === '[DONE]') {done = true;break;}try {const data = JSON.parse(incoming);const delta = data.choices[0].delta.content;if (data && delta) content.value += delta;} catch (err) {buffer = `data: ${incoming}`;}}
line.slice(6):剔除data: 固定前缀,提取纯JSON字符串[DONE]:服务端流结束标志,终止所有循环delta.content:流式接口专属增量字段,每次仅返回本次生成的少量文字,Vue响应式追加实现逐字打字效果catch容错逻辑:JSON解析报错代表当前行是残缺报文,存入buffer,下一轮循环拼接新分片后再解析,杜绝文字丢失else {const data = await response.json();content.value = data.choices[0].message.content;}
关闭流式时,后端等待AI全部生成完毕,一次性返回完整JSON,使用message.content完整文本,无需处理二进制流、分片、buffer,代码极简,但用户等待体验差。
现象:控制台频繁抛出JSON语法错误,AI回答文字残缺、丢失原因:网络传输会把一条data: JSON切成两块,单块无法完整解析解决方案:代码中buffer缓冲区,拼接残缺片段后再解析
现象:部分中文显示问号、乱码字符优化方案:decoder.decode(value, { stream: true }),解码器自动缓存跨分片字节,完整解析中文
现象:AI回答重复、内容翻倍修复:拼接chunkValue后立刻执行buffer = ""清空缓存
现象:关闭流式返回undefined,开启流式无文字输出区分:
data.choices[0].delta.contentdata.choices[0].message.content优化补充:增加loading锁,请求期间禁用提交按钮,防止并发请求
风险:前端打包后源码泄露密钥,产生高额扣费规范:统一放入.env环境变量,通过import.meta.env读取
| 对比维度 | stream=true 流式SSE | stream=false 一次性返回 |
|---|---|---|
| 传输方式 | 二进制分片持续推送 | 完整JSON单次返回 |
| 解析逻辑 | ReadableStream+buffer容错 | 直接response.json() |
| 输出字段 | delta.content(增量小段) | message.content(全文) |
| 用户体验 | 边生成边展示,低等待感知 | 等待全部生成后一次性渲染 |
| 代码复杂度 | 高,需处理分片、异常截断 | 极低,两行代码完成 |
| 适用场景 | 正式AI对话产品 | 内部简单工具、本地Demo |
loading响应式变量,请求中禁用按钮,防止重复点击white-space: pre-wrap,保留AI返回换行格式buffer缓冲区是流式解析的灵魂,专门解决TCP分包截断JSON的线上致命bug;delta与message字段切勿混用;