🧭 从零理解 Vue 3 流式输出:概念精讲与学习笔记

96 阅读10分钟

上一篇文章我们逐行拆解了代码。这一篇换一个视角 —— 不先讲"怎么写",而是先讲"为什么"。

我们从一段被注释掉的旧代码出发,还原一位开发者从"会用 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 的格式规则非常简单,只有三条:

  1. 每个事件以 data: 开头
  2. 事件之间用空行(\n\n)分隔
  3. 特殊标记 data: [DONE] 表示流结束

关键字段解读:

字段含义
choices[0].delta.content本次推送的增量 token(重点!)
choices[0].delta增量对象,对应非流式里的 choices[0].message
finish_reasonnull = 生成中,"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 tokenSSE 行被截断。同上,用 buffer 缓存残片
页面无反应一直"思考中"API Key 未配置或错误;检查 .env 文件名和 VITE_ 前缀
CORS 报错blocked by CORS policyDeepSeek API 需后端代理,或确认是否允许浏览器直连
reader 为 undefined老浏览器不支持response.body?.getReader() 的 ?. 已做兼容,但仍需现代浏览器

🏁 十、总结:一个 Demo 里的三层进阶

回顾整个项目,它其实是一条完整的前端工程师成长路径:

第一层:Vue 基础(响应式、绑定、事件)
   └─ 会用 ref、v-model、@click

第二层:异步与网络(fetch、async/await)
   └─ 会调用 API、处理 Promise

第三层:流式编程(ReadableStream、SSE、编解码)
   └─ 理解数据流、二进制、协议解析

从"改一个数字"到"接住 AI 的思维流",你跨越的不仅是 API 的复杂度,更是对"数据"本质的理解 —— 数据不再是一个静态的值,而是一条流动的河。 🌊

📚 延伸阅读: