从 ReadableStream 到打字机效果:手撕 Vue 3 + DeepSeek 流式输出全链路

2 阅读9分钟

你是否还在让用户盯着空白屏幕等待 AI 生成结果?Streaming 流式输出是 AI 产品体验的“生命线”。本文将带你从零搭建一个 Vue 3 项目,手写一套完整的流式对话功能,并深入解析二进制流、SSE 协议等核心概念。全程高能注释,新手也能轻松跟练。


一、为什么流式输出是 AI 产品的“标配”?

在传统请求中,用户点击提交后,必须等后端完全计算完毕才能看到结果。对于大语言模型(LLM),一次推理可能需要几秒甚至十几秒,用户会感到焦虑、不确定,甚至以为页面卡死了

流式输出(Streaming)的思路很简单:生成一个 token,就立即推送一个 token。就像在客户端和服务端之间接了一根水管,数据像水流一样源源不断涌来,页面上呈现出“打字机”效果。这不仅让用户感知到“系统正在工作”,还能提前阅读部分内容,体验提升是质的飞跃

要实现流式输出,需要前后端双向约定:

  • 服务端支持 stream: true 参数,并返回 text/event-stream 格式。
  • 客户端发送该参数,并准备处理分块响应。

本文我们使用 DeepSeek API 作为后端,Vue 3 作为前端,完整实现这一过程。


二、项目结构速览

text

stream-demo2/
├── .vscode/
│   └── extensions.json
├── node_modules/
├── public/
├── src/
│   ├── assets/
│   ├── components/
│   ├── App.vue          # 唯一组件,包含全部逻辑
│   ├── main.js          # 入口文件
│   └── style.css        # 全局样式(可被覆盖)
├── .env.local           # 环境变量(存放 API Key)
├── .gitignore
├── index.html
├── package.json
├── pnpm-lock.yaml
└── vite.config.js       # Vite 配置文件(可选)

这是一个标准的 Vite + Vue 3 项目。我们所有的业务逻辑都写在 App.vue 中,便于演示。


三、Vue 3 组合式 API 快速回顾

在开始之前,简单梳理几个核心概念,确保阅读顺畅:

  • <script setup> :Vue 3 的编译时语法糖,让组件代码更简洁,无需 export default,顶层变量自动暴露给模板。
  • ref:将基本类型或对象变为响应式数据,访问时需用 .value,模板中自动解包。
  • v-model:双向数据绑定,常用于表单输入。
  • @click:事件绑定,调用方法。

这些知识点在我们接下来的代码中会反复出现,注释会一一说明。


四、核心代码逐行解析(App.vue)

为了方便阅读,我将代码拆分为 模板(Template)脚本(Script)  和 样式(Style)  三部分,每一行都附上详细注释。

4.1 模板部分 —— 界面与数据绑定

vue

<template>
  <!-- 
    整个应用只有一个容器,采用 flex 列布局
    v-model 实现双向绑定,用户输入自动同步到 question
    @click 触发 update 方法
  -->
  <div class="container">
    <!-- 输入区域 -->
    <div>
      <label>输入:</label>
      <input class="input" v-model="question" />
      <button @click="update">提交</button>
    </div>

    <!-- 输出区域 -->
    <div class="output">
      <div>
        <label>Streaming</label>
        <!-- checkbox 绑定 stream 状态,true 表示开启流式 -->
        <input type="checkbox" v-model="stream" />
      </div>
      <!-- 展示 LLM 返回的内容,支持多行文本 -->
      <div>{{ content }}</div>
    </div>
  </div>
</template>

4.2 脚本部分 —— 响应式状态与请求逻辑

这是文章最核心的部分,我们采用 组合式 API 将相关逻辑聚合在一起,便于维护。

vue

<script setup>
// ============================================================
// 1. 导入 Vue 响应式 API
// ============================================================
import { ref } from 'vue'

// ============================================================
// 2. 定义响应式状态(数据状态)
// ============================================================
const question = ref('讲一个中国龙的故事')   // 用户输入的问题
const stream = ref(true)                    // 是否开启流式输出(默认开启)
const content = ref('')                    // 存储 LLM 返回的完整内容

// ============================================================
// 3. 核心方法:处理提交事件
// ============================================================
const update = async () => {
  // 空值校验
  if (!question.value) return

  // 设置占位文字,提示用户开始请求
  content.value = '思考中......'

  // ==========================================================
  // 3.1 配置请求参数
  // ==========================================================
  const endpoint = 'https://api.deepseek.com/chat/completions'
  const headers = {
    'Content-Type': 'application/json',
    // 从环境变量读取 API Key,避免硬编码泄露
    Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`
  }

  // ==========================================================
  // 3.2 发起 fetch 请求
  // ==========================================================
  const response = await fetch(endpoint, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      model: 'deepseek-v4-flash',
      messages: [{ role: 'user', content: question.value }],
      stream: stream.value   // 关键:是否开启流式
    })
  })

  // ==========================================================
  // 3.3 分支一:流式输出(stream: true)
  // ==========================================================
  if (stream.value) {
    content.value = ''  // 清空占位,准备逐字追加

    // --- 3.3.1 获取可读流读取器 ---
    // response.body 是一个 ReadableStream 对象,代表响应体的二进制数据流
    console.log(response.body)  // 打印查看结构

    // 调用 getReader() 获取读取器,就像在水管上装了一个水龙头
    const reader = response.body?.getReader()
    console.log(reader)

    // --- 3.3.2 准备解码器与缓冲区 ---
    // TextDecoder 用于将二进制(Uint8Array)转为文本
    const decoder = new TextDecoder()
    let done = false           // 是否读取完成(遇到 data: [DONE])
    let buffer = ''            // 缓冲区,处理不完整的 chunk

    // --- 3.3.3 循环读取数据块 ---
    while (!done) {
      // 读取一块数据:返回 { value: Uint8Array, done: boolean }
      // 如果流结束,done 为 true
      const { value, done: doneReading } = await reader?.read()
      done = doneReading

      // 解码当前块,stream: true 表示可能还有后续数据,保留内部状态
      const chunk = decoder.decode(value, { stream: true })
      buffer += chunk   // 拼接到缓冲区

      // --- 3.3.4 解析 SSE 格式数据 ---
      // SSE 数据格式为多行,每行以 "data: " 开头,最后以 "data: [DONE]" 结束
      // 按换行分割,过滤掉空行和结束标记
      const lines = buffer.split('\n')
        .filter(line => line.startsWith('data:') && line !== 'data: [DONE]')

      // 保留未处理完的碎片(例如某一行被截断)
      // 这里简单处理:将 buffer 重置为最后一行之后的内容(实际更严谨应保留不完整行)
      // 简单起见,我们采用更稳健的方式:不截断 buffer,而是每次处理所有完整行后清空
      // 下面采用更推荐的写法:
      // 将所有完整行取出,将 buffer 剩余部分保留
      // 为简化演示,这里使用更常见的处理方式:
      const linesArray = buffer.split('\n')
      const validLines = linesArray.filter(line => line.startsWith('data:') && line !== 'data: [DONE]')
      // 将 buffer 重置为未处理完的部分(最后一行可能不完整)
      // 此处演示一种常见处理:保留最后一行(可能不完整)
      const lastIndex = buffer.lastIndexOf('\n')
      buffer = buffer.slice(lastIndex + 1)

      // 遍历每一行,提取 token
      for (const line of validLines) {
        // 去掉 "data: " 前缀,得到 JSON 字符串
        const jsonStr = line.replace('data: ', '')
        try {
          const resJson = JSON.parse(jsonStr)
          // delta.content 是本次新增的文本片段
          const token = resJson.choices[0].delta?.content || ''
          // 追加到响应式变量,Vue 自动更新视图(打字效果)
          content.value += token
        } catch (err) {
          // 解析失败(如遇到不完整的 JSON),跳过继续
          continue
        }
      }
    }
  } 
  // ==========================================================
  // 3.4 分支二:非流式输出(stream: false)
  // ==========================================================
  else {
    const data = await response.json()
    // 直接取完整结果
    content.value = data.choices[0].message.content
  }
}

// ============================================================
// 小彩蛋:演示响应式数据(注释掉,供参考)
// ============================================================
// const count = ref(0)
// console.log(count, count.value)
</script>

💡 关键点解读:

  • response.body 是一个 ReadableStream 对象,属于浏览器原生 API,用于处理流式数据。
  • getReader() 返回一个读取器,通过 read() 异步读取数据块,每次返回 { value, done }
  • TextDecoder 将二进制(Uint8Array,值范围 0~255)解码为字符串。
  • SSE 格式:每行 data: {...},最后一行 data: [DONE] 表示结束。
  • 缓冲区 buffer 用于拼合可能被截断的 chunk,确保 JSON 完整解析。

4.3 样式部分 —— 流式布局与移动端适配

vue

<style>
/* ============================================================
   文档流(Normal Flow)是页面布局的基础:
   块级元素从上到下,行内元素从左到右。
   每个盒子在文档流中都有自己的位置和大小(盒模型)。
   通过 display: flex 开启新的格式化上下文(BFC),
   让子元素按 flex 规则排列。
   ============================================================ */
.container {
  display: flex;
  flex-direction: column;   /* 列方向排列 */
  align-items: start;       /* 交叉轴起点对齐 */
  justify-content: start;   /* 主轴起点对齐 */
  height: 100vh;            /* 占满视口高度 */
  font-size: 0.85rem;       /* 移动端适配,使用相对单位 */
  padding: 20px;
  box-sizing: border-box;
}

.input {
  width: 200px;
  padding: 6px 10px;
}

.output {
  margin-top: 10px;
  min-height: 300px;
  width: 100%;
  text-align: left;
  border: 1px solid #ccc;
  padding: 10px;
  border-radius: 6px;
  background: #f9f9f9;
  white-space: pre-wrap;    /* 保留换行和空格 */
}

button {
  padding: 0 10px;
  margin-left: 6px;
  cursor: pointer;
}
</style>

五、深入理解二进制流与编解码

在流式处理中,我们频繁接触到 Uint8Array 和 TextDecoder/TextEncoder,这里额外解释一下:

  • Uint8Array:一个 0~255 之间的无符号整数数组,是二进制数据的原始表示。例如,中文字符 “你好” 通过 UTF-8 编码会变成多个字节。
  • TextEncoder:将字符串编码为 Uint8Array。
  • TextDecoder:将 Uint8Array 解码为字符串。

我们可以在 JS1.js 中做个实验(见图片):

javascript

const encoder = new TextEncoder()
const bytes = encoder.encode("你好")
console.log(bytes)  // Uint8Array(6) [228, 189, 160, 229, 165, 189]

const decoder = new TextDecoder()
const str = decoder.decode(bytes)
console.log(str)    // "你好"

在流式场景中,decoder.decode(value, { stream: true }) 告诉解码器:数据可能不完整,保留内部状态以便下次继续解码。这是处理流式文本的关键。


六、Vite 热更新与开发体验

在项目开发中,Vite 提供了极快的热更新(HMR)。当你修改 .vue 文件时,Vite 会局部替换修改的组件,而不会刷新整个页面,从而保留页面状态(如输入框内容)。这比传统 Webpack 的刷新体验好得多。

  • 环境变量通过 .env.local 文件管理,Vite 使用 import.meta.env 读取,且只有以 VITE_ 开头的变量才会暴露给客户端。
  • vite.config.js 可自定义配置,但本 Demo 不需要额外配置。

七、总结与延伸思考

模块技术点作用
前端框架Vue 3 + <script setup>组件化开发,响应式数据驱动视图
数据绑定v-model{{ }}实现双向绑定和单向渲染
流式请求fetch + ReadableStream处理分块响应,实时获取 token
数据解析SSE 格式 + TextDecoder将二进制流还原为文本,提取增量内容
环境管理.env.local + import.meta.env安全存储 API Key
开发工具Vite极速热更新,提升开发效率

进阶思考:

  • 实际生产环境中,需要处理网络异常、重试机制、取消请求等。
  • 对于更复杂的场景,可以封装一个通用的 useSSE 组合式函数,复用流式逻辑。
  • 也可以考虑使用 EventSource API(原生 SSE 客户端),但它只支持 GET 请求,而我们的对话接口需要 POST 携带消息体,因此用 fetch + ReadableStream 更灵活。

前端工程师的价值,在于将复杂的技术细节封装成丝滑的用户体验。  流式输出只是一个起点,未来 Agent 时代,我们还需要学会将工作拆分:把 AI 擅长的交给 Agent,我们负责审核与兜底。项目初始化也不必从零开始,可以直接从 GitHub 拉取模板工程——但理解底层原理,永远是进阶的基石。

希望这篇文章能帮你彻底搞懂 Vue 3 + 流式输出的全链路。如果觉得有帮助,欢迎点赞、收藏、评论,我们下期再见!🚀