上一篇文章我们逐行拆解了代码。这一篇换一个视角 —— 不先讲"怎么写",而是先讲"为什么"。
我们从一段被注释掉的旧代码出发,还原一位开发者从"会用 Vue 计数器"到"写出一个 AI 流式聊天应用"的完整成长路径,把藏在注释里的每一个概念挖出来讲透。
📖 一、一个项目的进化史:从计数器到 AI 流式对话
打开 App.vue,你会看到顶部有一段被注释掉的代码,下面是真正的实现。这两段代码放在一起,恰好构成了一次完整的"认知升级"。
1.1 第一版:Vue 新手的样子 🐣
<script setup>
import { ref } from 'vue'
const count = ref(0)
const username = ref('张三')
import HelloWorld from './components/HelloWorld.vue'
const increment = () => {
count.value += 1
}
</script>
<template>
<h1>流式输出{{ count }}</h1>
<input type="text" v-model="username" />
<button @click="increment">增加</button>
<HelloWorld />
</template>
这段代码演示了 Vue 三个最基础的能力,但已经埋下了所有后续概念的种子:
| 能力 | 代码 | 埋下的种子 |
|---|---|---|
| 响应式数据 | count = ref(0) | 数据变化 → 视图自动更新 |
| 双向绑定 | v-model="username" | 输入框 ↔ 数据同步 |
| 事件处理 | @click="increment" | 用户操作 → 触发逻辑 |
💡 标题"流式输出{{ count }}"其实是个双关:字面上是"每次点击数字流动变化",而最终版本把它演变成了真正的 LLM 流式输出。一个标题串起了两次技术跃迁。
1.2 第二版:从"改自己的数据"到"调 AI 的接口" 🤖
第二版的本质变化只有一件事:数据的来源变了。
- 第一版:数据来自用户点击(
count.value += 1,本地自增) - 第二版:数据来自远程 API(
content.value来自 DeepSeek 的返回)
数据源从"本地"变成"远程",引发了三个新问题,而这三个问题正好是理解整个项目的钥匙:
问题 1:远程返回太慢,用户盯着白屏 → 为什么需要"流式"?
问题 2:数据是一块块来的,怎么边收边显示 → 怎么处理"流"?
问题 3:收到的二进制怎么变成文字 → 怎么"解码"?
下面三个章节,我们逐个击破。
🎯 二、为什么需要流式输出?—— 一个体验问题
2.1 对比实验:一次生成 vs 流式生成
假设用户问"写一个中国龙的故事",模型需要生成 2000 个 token,耗时约 10 秒。
❌ 非流式(stream: false):
用户点击提交
│
├── 0s content = "思考中...."
├── 1s 等待...(页面无变化)
├── 5s 等待...(页面无变化)
├── 10s 全部内容一次性蹦出来 ✅
└── 用户:盯着"思考中"等了 10 秒,体验极差
✅ 流式(stream: true):
用户点击提交
│
├── 0s content = ""
├── 0.3s "在" ← 第一个 token 到达
├── 0.6s "在遥远的" ← 持续累加
├── 1s "在遥远的东方,"
├── 2s "在遥远的东方,有一条..."
└── 10s 全文生成完毕
流式的本质是把等待时间转化为"内容正在生成"的反馈。心理学上这叫感知性能(Perceived Performance)——用户看到内容在动,就觉得"系统在工作",焦虑感大大降低。
🏆 这就是 ChatGPT、Claude 等所有主流 AI 产品都采用流式输出的根本原因。
2.2 为什么 LLM 天生适合流式?
LLM 是逐 token 生成的(自回归模型),它本来就无法一次性算出全文:
输入"写一个中国龙的故事"
↓
模型预测下一个 token:"在" (概率 0.8)
↓
把"在"接上去,再预测:"遥" (概率 0.6)
↓
把"遥"接上去,再预测:"远" (概率 0.7)
↓
... 如此循环,直到预测出 <结束符>
既然模型内部就是一个个 token 往外蹦的,那服务器完全可以"蹦一个发一个",这就是 Server-Sent Events(SSE) 的用武之地。
📡 三、流是怎么从服务器到浏览器的?—— SSE 协议精讲
3.1 两种"推流"技术的对比
| 特性 | SSE(本项目使用) | WebSocket |
|---|---|---|
| 方向 | 单向:服务器 → 客户端 | 双向:客户端 ↔ 服务器 |
| 协议 | HTTP(复用现有连接) | 独立协议 ws:// |
| 适用场景 | 单向推送(AI 生成、通知、行情) | 双向交互(聊天室、游戏) |
| 复杂度 | 低,普通 HTTP 即可 | 高,需维护连接状态 |
LLM 生成是纯单向的(你发一次请求,它一直回),所以 SSE 是最轻量的选择 —— 不需要 WebSocket 的双向能力,直接用普通 HTTP 请求就行。
3.2 SSE 的数据长什么样?
当请求带上 "stream": true 时,DeepSeek 返回的不是 JSON,而是这样的文本流:
data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"在"}}],"finish_reason":null}
data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"遥"}}],"finish_reason":null}
data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"远"}}],"finish_reason":null}
... (中间还有很多行)...
data: [DONE]
SSE 的格式规则非常简单,只有三条:
- 每个事件以
data:开头 - 事件之间用空行(
\n\n)分隔 - 特殊标记
data: [DONE]表示流结束
关键字段解读:
| 字段 | 含义 |
|---|---|
choices[0].delta.content | 本次推送的增量 token(重点!) |
choices[0].delta | 增量对象,对应非流式里的 choices[0].message |
finish_reason | null = 生成中,"stop" = 正常结束 |
⚠️ delta vs message:这是流式和非流式最关键的差异。
- 非流式返回
choices[0].message.content—— 完整内容- 流式返回
choices[0].delta.content—— 增量内容(每次只多一个词)所以流式处理里,客户端必须自己累加每一个 delta,拼成完整结果。
3.3 一次推送,可能是多行
注释里有句话很关键:
// 一次发送一行,也可能发送多行 llm 计算速度和任务
模型生成快的时候,服务器可能一次推送多个 token,也就是一次网络包里有多行 data:。所以客户端必须按行 split('\n') 再逐个处理,不能假设"一个 chunk = 一行"。
🔤 四、二进制到文字:编解码的核心原理
4.1 为什么需要 TextDecoder?
这是很多人第一次接触流式编程时最困惑的地方。答案藏在数据类型里:
fetch() 拿到 response.body(ReadableStream)
↓
reader.read() 返回 { value, done }
↓
value 的类型是 Uint8Array —— 二进制字节数组!
Uint8Array 是一串数字(每个 0~255),而 SSE 数据是文字。必须做一次解码:
const decoder = new TextDecoder() // 创建解码器
const text = decoder.decode(value) // Uint8Array → 字符串
4.2 一对"镜像" API:TextEncoder vs TextDecoder
代码注释里明确点出了这对关系:
// 二进制解码器 — 和 1.js 里的 TextEncoder 相反
// TextEncoder: 字符串 → Uint8Array(编码)
// TextDecoder: Uint8Array → 字符串(解码)
| API | 方向 | 用途 |
|---|---|---|
TextEncoder.encode() | 字符串 → Uint8Array | 发送数据(如文件上传、fetch body) |
TextDecoder.decode() | Uint8Array → 字符串 | 接收数据(如本项目的流式响应) |
4.3 中文为什么要小心?—— UTF-8 多字节
一个中文字符在 UTF-8 下占 3 个字节。假设"龙"这个字被编码成 3 个字节 [é, ¾, ™],网络传输时这 3 个字节可能被拆到两个 chunk 里:
Chunk 1: [... 前 1 个字节] ← decoder 解不出来,先存着
Chunk 2: [后 2 个字节 ...] ← 和前面拼起来才能解出"龙"
这就是为什么代码里要维护一个 buffer 变量(详见下章)。这是流式编程最容易踩的坑:如果不拼接,中文会显示成乱码 �。
🧩 五、完整的处理管道:把"流"变成"文字"再变成"页面"
5.1 管道全景
┌────────────────────────────────────────────────────────────────┐
│ 流式处理管道 │
│ │
│ response.body (ReadableStream) │
│ │ │
│ ▼ reader.read() ← 每次"喝一口" │
│ ┌──────────────┐ │
│ │ Uint8Array │ 二进制字节 │
│ └──────┬───────┘ │
│ ▼ decoder.decode() ← 二进制 → 字符串 │
│ ┌──────────────┐ │
│ │ UTF-8 字符串 │ "data: {...}\n\ndata: {...}\n\n" │
│ └──────┬───────┘ │
│ ▼ buffer + chunk ← 拼接残片(防截断) │
│ ┌──────────────┐ │
│ │ 完整文本块 │ │
│ └──────┬───────┘ │
│ ▼ split('\n') + filter('data:') ← 提取 SSE 行 │
│ ┌──────────────┐ │
│ │ data: 行数组 │ │
│ └──────┬───────┘ │
│ ▼ JSON.parse() ← 解析 JSON │
│ ┌──────────────┐ │
│ │ delta.content│ "在" / "遥" / "远" ... │
│ └──────┬───────┘ │
│ ▼ content.value += ← 累加 │
│ ┌──────────────┐ │
│ │ 页面实时更新 │ Vue 响应式自动触发 DOM 更新 │
│ └──────────────┘ │
└────────────────────────────────────────────────────────────────┘
5.2 核心循环代码
if (stream.value) {
content.value = '' // 清空旧内容
const reader = response.body?.getReader() // 获取读取器("水龙头")
const decoder = new TextDecoder() // 解码器(二进制→文字)
let done = false // 循环开关
let buffer = '' // 残片缓存
while (!done) {
// 1. 读一块数据
const { value, done: doneReading } = await reader?.read()
done = doneReading
// 2. 拼接上一轮残片 + 解码本轮数据
const chunkValue = buffer + decoder.decode(value)
buffer = '' // 用完清空
// 3. 按行分割,只保留 data: 开头
const lines = chunkValue.split('\n')
.filter((line) => line.startsWith('data:'))
}
}
5.3 为什么 buffer 必须存在?
这是整个流式处理里最精妙、也最容易被忽略的一处。看这个场景:
服务器本意发送: data: {"delta":{"content":"龙"}}\n\n
但网络分成了两包:
第 1 包: data: {"delta":{"content":"
第 2 包: 龙"}}\n\n
如果第 1 包到达时直接 split('\n') 然后 JSON.parse,会报错(JSON 不完整)。
buffer 的作用就是:把解析不了的残片存起来,等下一轮数据到了再拼起来解析。
代码注释里已经点破了这个思想:
// 除了把本轮的 value 要处理之外,之前缓存的 value 也要处理
// 缓存的 value 是上一轮的 value 本轮的 value 是当前轮的 value
🧠 六、藏在注释里的 Vue 核心概念
App.vue 的注释不只是代码说明,更是一份浓缩的 Vue 学习笔记。我们把它展开:
6.1 组合式 API vs 选项式 API
// vue3 composition 组合 api
// 把相关逻辑放在一起
// composition api 相关逻辑组织在一起 vue2 选项式 api 相关逻辑组织在一起
| 维度 | Options API(Vue 2) | Composition API(Vue 3) |
|---|---|---|
| 组织方式 | 按选项类型分块(data / methods / computed) | 按逻辑功能分组 |
| 代码示例 | data(){...}, methods:{...} | 直接写变量和函数,<script setup> |
| 复用性 | 用 mixin(易冲突) | 用组合函数(干净) |
| 类型支持 | 较弱 | 天然友好(TS) |
本项目用的是 Composition API + <script setup> 语法糖,所以可以直接在顶层写响应式变量和函数,无需 export default { data, methods } 的结构。
6.2 ref() 到底返回了什么?
// const count = ref(0);// 变量 -> 数据(数据绑定)
// -> 数据状态(响应式数据) -> 页面状态(反应在页面上)
// RefImpl响应式对象,值是count.value
// count.value 改变时候,页面上绑定了count的地方会局部热更新
这条注释揭示了 Vue 响应式的完整链路:
变量 (count) → 数据
ref() 包装 → 数据状态(响应式)
模板绑定 {{ count }} → 页面状态
count.value 改变 → 局部热更新
ref(0) 返回的不是数字 0,而是一个 RefImpl 对象:
{ value: 0, __v_isRef: true, ... } // 内部结构示意
所以要读写值必须用 .value。模板里可以省略 .value,是因为 Vue 的模板编译器自动"解包"了 ref。
6.3 数据驱动视图的本质
// 只需要修改数据状态,响应式数据会自动更新页面状态
content.value = data.choices[0].message.content
这句话是整个 Vue 设计哲学的一句话总结:你只管改数据,DOM 更新交给框架。对比 jQuery 时代的"手动 $('#result').text(...)",这是革命性的心智转变。
🎨 七、CSS 注释里的布局哲学
App.vue 的样式区也有几行被很多人忽略的"布局入门课":
.container {
/* 文档流 是页面布局的基础 */
/* 从上到下,从左到右,流式布局 */
/* 每个盒子在文档流中都是有自己的位置和大小 */
/* 盒模型 */
/* 开启新的格式化上下文 */
display: flex;
flex-direction: column;
align-items: start;
justify-content: start;
height: 100vh;
font-size: 0.85rem;
/* 移动端适配,等比例html标签等比例 */
}
这些注释串起了 CSS 布局的三个基础概念:
| 概念 | 含义 | 在本项目的作用 |
|---|---|---|
| 文档流 | 元素默认从上到下、从左到右排列 | 理解 Flex 布局的起点 |
| 盒模型 | 每个元素是一个盒子(content + padding + border + margin) | 理解元素占位 |
| 格式化上下文(BFC) | display: flex 开启一个新的布局上下文 | 让子元素按 Flex 规则排列 |
💡 有趣的是,CSS 里的"流式布局"和本文的"流式输出"用了同一个"流"字 —— 都强调数据/内容沿着一个方向持续流动,而不是一次性铺满。
🔧 八、从零跑起来:环境配置
8.1 准备 API Key
代码里用到了环境变量:
Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`
Vite 的环境变量规则:必须以 VITE_ 前缀命名,才会被暴露给客户端代码。在项目根目录创建 .env 文件:
# .env
VITE_DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxxxxxx
⚠️ 安全提示:
.env文件应加入.gitignore,不要把 API Key 提交到仓库。前端代码里的 Key 其实任何人都能看到,生产环境应通过后端代理转发请求。
8.2 启动项目
npm install # 安装依赖
npm run dev # 启动开发服务器
浏览器打开提示的地址(通常是 http://localhost:5173),输入问题,勾选 Streaming,点击提交,就能看到文字逐字蹦出的效果。
🐛 九、常见踩坑指南
| 问题 | 现象 | 原因 & 解决 |
|---|---|---|
| 中文乱码 | 输出 � 或残缺字符 | 中文字符被拆到两个 chunk,没拼接。检查 buffer 逻辑 |
| JSON 解析报错 | Unexpected token | SSE 行被截断。同上,用 buffer 缓存残片 |
| 页面无反应 | 一直"思考中" | API Key 未配置或错误;检查 .env 文件名和 VITE_ 前缀 |
| CORS 报错 | blocked by CORS policy | DeepSeek API 需后端代理,或确认是否允许浏览器直连 |
reader 为 undefined | 老浏览器不支持 | response.body?.getReader() 的 ?. 已做兼容,但仍需现代浏览器 |
🏁 十、总结:一个 Demo 里的三层进阶
回顾整个项目,它其实是一条完整的前端工程师成长路径:
第一层:Vue 基础(响应式、绑定、事件)
└─ 会用 ref、v-model、@click
第二层:异步与网络(fetch、async/await)
└─ 会调用 API、处理 Promise
第三层:流式编程(ReadableStream、SSE、编解码)
└─ 理解数据流、二进制、协议解析
从"改一个数字"到"接住 AI 的思维流",你跨越的不仅是 API 的复杂度,更是对"数据"本质的理解 —— 数据不再是一个静态的值,而是一条流动的河。 🌊
📚 延伸阅读: