写在前面
你问 ChatGPT 一个问题,它不是等你 5 秒再啪一下把答案甩脸上,而是一个字一个字往外蹦。这个体验不是后端送的福利,是前端接的流。
AI 产品给用户的第一印象,往往不是你模型多强,而是"它是不是在动"。用户愿意等 10 秒,但不愿意盯着白屏发 10 秒呆。流式输出(streaming)就是解决这个心理魔法的。
今天用 Vue 3 + Vite + DeepSeek API,手把手搭一个会"打字机"输出的聊天页面。
Vue 3 组件:一个 .vue 文件就是一块乐高
现代前端早就不玩"一个 HTML 文件写到底"了。页面是由一个个组件拼起来的,每个 .vue 文件就是一个组件:
| 区块 | 装什么 | 作用 |
|---|---|---|
<template> |
HTML + 绑定语法 | 视图层 |
<script> |
JS 逻辑 | 数据、方法、生命周期 |
<style> |
CSS | 样式 |
Vue 3 推荐用 <script setup>,把变量、函数直接暴露给模板用,少写一层 return。响应式数据用 ref 包一下:
js
ini
import { ref } from 'vue';
const question = ref('讲一个关于两只羊与奶龙的故事');
const stream = ref(false);
const content = ref('');
ref 把普通值变成"响应式状态"------你改 question.value,输入框里的内容跟着动;输入框里的内容变了,question.value 也跟着变。这就是 v-model 的底层。
Vite 脚手架:一分钟搭好项目
bash
bash
npm init vite
# 选 Vue → JavaScript
cd stream-demo
npm install
npm run dev
Vite 启动后会跑在 http://localhost:5173,index.html 里只有一个 <div id="app"></div>,main.js 负责把 Vue 应用挂到这个点上:
js
javascript
import { createApp } from 'vue';
import App from './App.vue';
import './style.css';
createApp(App).mount('#app');
完整页面代码:一个会流式输出的聊天框
vue
ini
<template>
<div class="container">
<div>
<label>输入:</label>
<input type="text" class="input" v-model="question" />
<label>
<input type="checkbox" v-model="stream" />
流式输出
</label>
<button @click="update">提交</button>
</div>
<div class="output">
<div>{{ content }}</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
const question = ref('讲一个关于两只羊与奶龙的故事');
const stream = ref(false);
const content = ref('');
const update = async () => {
if (!question.value) return;
content.value = '思考中...';
const response = await fetch('https://api.deepseek.com/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
},
body: JSON.stringify({
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: question.value }],
stream: stream.value,
}),
});
if (stream.value) {
content.value = '';
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let done = false;
while (!done) {
const { value, done: doneReading } = await reader.read();
done = doneReading;
if (value) {
const chunk = decoder.decode(value, { stream: true });
// 简单版:把 SSE 数据拼上去
content.value += chunk;
}
}
} else {
const data = await response.json();
content.value = data.choices[0].message.content;
}
};
</script>
非流式 vs 流式:两种姿势差在哪?
js
ini
// 非流式:等全量生成完,一次性端上来
const data = await response.json();
content.value = data.choices[0].message.content;
js
ini
// 流式:边生成边读,边读边展示
const reader = response.body?.getReader();
const decoder = new TextDecoder();
while (!done) {
const { value, done: doneReading } = await reader.read();
done = doneReading;
content.value += decoder.decode(value, { stream: true });
}
| 维度 | 非流式 | 流式 |
|---|---|---|
| 用户感知 | 白屏等,焦虑感拉满 | 字一个个蹦,像有人在打字 |
| 首次可见时间 | 生成完全部才能看 | 几百毫秒就有内容出现 |
| 实现复杂度 | response.json() 一行搞定 |
要 ReadableStream + TextDecoder |
| 适合场景 | 简单问答、短回答 | 长文本生成、代码、故事 |
| 响应体格式 | 完整 JSON | SSE(server-sent events)数据流 |
为什么流式体验更好? 因为人类对"等待中"的忍耐力,取决于有没有反馈。一个进度条、一个转圈圈、甚至一个字一个字往外蹦,都能显著降低焦虑感。流式输出就是 AI 聊天产品的进度条。
流式读取的三件套:ReadableStream / getReader / TextDecoder
fetch 的 response.body 是一个 ReadableStream 对象,代表响应体还没读完的数据流。要消费它,需要两步:
| API | 作用 |
|---|---|
response.body.getReader() |
创建一个 reader,从流里拉数据 |
reader.read() |
异步读取下一块,返回 { value, done } |
TextDecoder.decode(value, { stream: true }) |
把 Uint8Array 二进制块转成文本 |
TextDecoder 的第二个参数 { stream: true } 很关键。中文一个字符可能被切成两半,分别落到两个 chunk 里。stream: true 让 decoder 保留未完成的字节,等下一次 chunk 到了再拼。不加这个,中文后半部分容易变成乱码。
SSE 格式返回的数据流里,每一行前面都有 data: 前缀,最后一条是 data: [DONE]。生产环境要逐行解析,过滤掉空行和 [DONE],只取有效内容。上面那段代码为了演示直观,直接把所有 chunk 拼在一起------实际项目中建议包一层解析函数。
环境变量:import.meta.env 是 Vite 的专利
前端代码不能硬编码 API Key,否则打开 F12 人人可见。Vite 会自动读取项目根目录的 .env.local,并把以 VITE_ 开头的变量注入到 import.meta.env:
env
ini
VITE_DEEPSEEK_API_KEY=sk-xxxxxxxx
代码里这样取:
js
arduino
import.meta.env.VITE_DEEPSEEK_API_KEY
注意:只有 VITE_ 前缀的变量才会被暴露到前端。写成 DEEPSEEK_API_KEY 是拿不到的。
5 个踩坑提醒
1. content.value 和模板没同步。 修改 ref 的值必须用 .value,直接 content = '...' 会断掉响应式,页面不更新。
2. v-model 忘了加 .value。 模板里写 v-model="question" 就够了,但 JS 里读取要 question.value。新手常在 if (!question) 这种地方翻车。
3. 流式输出没关 stream: true 的解析细节。 直接把 SSE 数据拼上去,页面上会带 data: 和 DONE 字样。要解析成纯文本再展示。
4. TextDecoder 没传 { stream: true }。 中文 chunk 被切开,后半截变成乱码。这个参数是流式解码的保险。
5. .env.local 没加到 .gitignore。 API Key 上传 GitHub 是经典社死。创建项目第一件事就是把 .env*.local 写进 gitignore。
写在最后
一个会流式输出的聊天页面,技术点其实不多:Vue 3 响应式 + fetch + ReadableStream + SSE 解析。但它背后有一个产品级的认知:AI 应用的用户体验,一半靠响应速度,一半靠"它正在动"的反馈。
从"等 5 秒看结果"到"边生成边看",中间只差一个 reader。但就是这一个 reader,把 demo 和产品的距离拉近了一大截。