别等了!用 Vue 3 让 AI 边想边说,字字蹦到你脸上

🚀 Vue 3 流式输出实战:从零开始玩转LLM对话!

你以为AI对话边生成边输出是魔法?不!这是前端工程师的浪漫操作!✨


🌟 一、整体功能:让AI在你眼前"活"起来

想象一下:你输入"讲一个喜洋洋和灰太狼的故事,200字",然后每个字都像打字机一样蹦出来 ,而不是等整段答案蹦出来!这就是我们今天要实现的流式输出。用Vue 3 + DeepSeek API,打造一个能实时吐字的AI小助手,让科技感拉满!🎯

废话不多说,看效果:


💻 二、项目初始化:Vite,前端脚手架的扛把子!

"Vite 是最近最优秀的前端脚手架" ------ 这句话我反复念了10遍才敢信!😱

  1. npm init vite@latest

用最新版的 Vite 脚手架初始化一个新项目!执行这行命令后,终端会问你几个问题:

less 复制代码
Project name: >> my-ai-chat      ← 你起的项目名
Select a framework: >> Vue       ← 选 Vue
Select a variant: >> JavaScript  ← 用 JS(不是 TS)
// 选 **Vue 3** + **JavaScript**(别选TypeScript,新手友好!)
  1. cd your-project + npm install + npm run dev

切换到刚刚创建的项目文件夹+下载项目需要的所有依赖(第三方库)+启动开发服务器在浏览器里看到网页

  1. 项目结构瞬间搭建好:src/ 是你的主战场!📁

所有组件、逻辑、样式都在这里写

css 复制代码
my-ai-chat/
├── src/
│   ├── App.vue        ← 根组件(整个应用的入口)
│   └── main.js        ← 启动文件(把 App.vue 挂到 HTML 上)
├── index.html         ← 单页应用的 HTML 入口
├── vite.config.js     ← Vite 配置文件(比如代理设置)
└── package.json       ← 项目信息 + 依赖列表 + 脚本命令

💡 为什么Vite这么香?

传统脚手架要编译几秒,Vite直接秒开!就像从自行车升级到火箭🚀


📝 三、App.vue:三明治结构,前端的"灵魂"!

xml 复制代码
<script setup> <!-- 逻辑层(核心!) -->
// ja代码
</script>

<template> <!-- 视图层(UI) -->
    // html代码
  <div>输入框+按钮</div>
</template>

<style scoped> <!-- 样式层(美颜) -->
// css代码
</style>

三明治精髓

你只管写业务逻辑(script),Vue自动帮你管DOM(template)!

以前要写document.getElementById的苦日子,一去不复返!😭


💡 四、响应式数据:从DOM操作到"写代码像写日记"的革命

1、过去:手动操作 DOM 的"机械时代":

在没有 Vue/React 的年代,想实现一个"点击按钮数字+1"的功能,你得这样写:

✅ 原生 JavaScript(HTML + JS 分离)

xml 复制代码
html

<!-- index.html -->
<div>
  <p id="count">0</p>
  <button id="btn">+1</button>
</div>

<script>
let count = 0; // 数据

// 1. 找到按钮和显示区域
const btn = document.getElementById('btn');
const countEl = document.getElementById('count');

// 2. 监听事件
btn.addEventListener('click', () => {
  // 3. 修改数据
  count++;
  // 4. 手动同步到 DOM
  countEl.innerText = count;
});
</script>

问题在哪?

  • 要先找 DOM 元素(getElementById
  • 数据变了,必须手动更新 UI
  • 如果有多个地方显示 count,你得改 N 次!
  • 代码像"操作手册":先做A,再做B,最后做C......繁琐且毫无乐趣!

2、现在:Vue 3 的"响应式浪漫时代":

在 Vue 3 中,同样的功能,只需关注数据本身,UI 自动跟着变!

✅ Vue 3 示例(使用 <script setup>

xml 复制代码
vue

<!-- App.vue -->
<script setup>
import { ref } from 'vue'

// 1. 定义响应式数据
const count = ref(0)

// 2. 定义业务逻辑(只关心数据!)
function increment() {
  count.value++ // 注意:要加 .value!
}
</script>

<template>
  <!-- 3. 模板直接消费数据 -->
  <div>
    <p>{{ count }}</p>          <!-- 显示数据 -->
    <button @click="increment">+1</button> <!-- 绑定事件 -->
  </div>
</template>

神奇之处:

  • 只写了 count.value++ ,没碰任何 DOM!

  • 页面上 {{ count }} 自动更新

  • 即使有 10 个地方用了 count,也全部同步,零额外代码
    💖 这就是 "声明式编程" vs "命令式编程" 的魅力:

  • 旧时代: "你要怎么做" (步骤清单)

  • 新时代: "你想要什么" (描述状态)


🔥 五、对代码的深度解读:流式响应处理(重点!)

1️⃣ 请求三件套:向 LLM 发起"召唤"

javascript 复制代码
js

const endpoint = '/api/chat/completions'
const headers = {
  'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
  'Content-Type': 'application/json'
}
const body = { 
  model: 'deepseek-chat', 
  stream: true, 
  messages: [{ role: 'user', content: question.value }] 
}
🔑 关键点解释:
  1. /api/chat/completions

    • 这不是直接调 DeepSeek 官方 API!
    • 而是通过 Vite 开发服务器代理 (在 vite.config.js 中配置)。
    • 为什么?→ 绕过浏览器 CORS 限制(后面详述)。
  2. stream: true

    • 告诉 LLM:"请一个字一个字地吐出来,别等全部生成完!"

    • 服务器会以 SSE(Server-Sent Events) 格式返回数据:

      css 复制代码
      text
      
      data: {"choices": [{"delta": {"content": "喜"}}]}
      data: {"choices": [{"delta": {"content": "羊"}}]}
      data: [DONE]
  3. VITE_DEEPSEEK_API_KEY

    • Vite 只会暴露以 VITE_ 开头的环境变量到前端。
    • ⚠️ 注意:生产环境中绝不能直接在前端用 API Key!
      (这里仅用于开发,真实项目应由后端代理)
  4. Content-Type

    • 这是 HTTP 请求头(Headers)中的一个字段,用于声明请求体(body)的数据格式

    • DeepSeek、OpenAI、Anthropic 等主流 LLM API 都遵循 OpenAI 兼容协议

    • 它们只接受 JSON 格式的请求体

    • 如果你不声明 Content-Type: application/json,服务器可能:

      • 拒绝请求(400 Bad Request)
      • 无法正确解析你的 body(当成纯文本处理)

2️⃣ 流式响应处理:核心循环逻辑

📌 核心代码(关键部分):
javascript 复制代码
let buffer = '' // !核心变量!
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: '))
  
  for (const line of lines) {
    const incoming = line.slice(6) // 去掉 "data: "
    if (incoming === '[DONE]') break
    
    try {
      const data = JSON.parse(incoming)
      content.value += data.choices[0].delta.content
    } catch {
      buffer += `data: ${incoming}` // !关键!存回缓冲
    }
  }
}
📦 初始化关键变量
csharp 复制代码
js

let buffer = ''        // 👑 灵魂变量:暂存"没说完"的数据
let done = false       // 标记流是否结束
const reader = response.body.getReader()
const decoder = new TextDecoder() // 把二进制转成字符串

💡 response.body 是一个 ReadableStream,只能顺序读取。


🔁 主循环:一块一块读数据
bash 复制代码
js

while (!done) {
  const { value, done: doneReading } = await reader.read()
  done = doneReading
  • reader.read() 返回一个 Promise,解析后得到:

    • value: 当前 chunk(Uint8Array 二进制数据)
    • done: 是否已读完(最后一个 chunk 后为 true)

🧩 第一步:拼接缓冲区 ------ buffer 登场!
ini 复制代码
js

const chunkValue = buffer + decoder.decode(value)
buffer = ''
  • 上一轮没解析完的数据(buffer) + 当前新 chunk 拼成完整字符串。
  • 然后清空 buffer(准备接收下一轮"残片")。

✅ 举例:

  • 上次剩:'data: {"choices": [{"delta": {"content": "喜羊羊'
  • 本次收到:'和灰太狼"}}]}\n'
  • 拼接后:'data: {"choices": [{"delta": {"content": "喜羊羊和灰太狼"}}]}\n' → 完整!

✂️ 第二步:按行分割,只处理有效行
ini 复制代码
js

const lines = chunkValue.split('\n')
  .filter(line => line.startsWith('data: '))
  • SSE 协议以 \n 分隔每一行。
  • 只保留以 data: 开头的行(忽略空行或其他控制信息)。

🧪 第三步:逐行解析 JSON
kotlin 复制代码
js

for (const line of lines) {
  const incoming = line.slice(6) // 去掉 "data: "
  
  if (incoming === '[DONE]') {
    done = true
    break
  }
  
  try {
    const data = JSON.parse(incoming) // 反序列化,将JSON字符串转化为真正的JavaScript对象
    // 若是一个完整的JSON字符串,这里就可以反序列化成功,否则进入catch
    const delta = data.choices[0].delta.content
    if (delta) content.value += delta
  } catch (err) {
    // ❗ 解析失败 → 说明这行不完整!
    buffer += `data: ${incoming}`
  }
}
🎯 关键细节:
  1. line.slice(6)

    • 去掉 data: 前缀,得到纯 JSON 字符串。
  2. [DONE] 终止信号

    • LLM 流结束时会发送 data: [DONE],此时退出循环。
  3. try...catch + buffer 回写

    • 如果 JSON.parse 失败(比如 incoming = '{"choices": [{"delta": {"conten'),说明这一行被截断了。
    • 于是把它原样加回 buffer (注意加上 data: 前缀),等下次 chunk 到达再拼!

💡 这就是 buffer 的魔法:永不丢弃任何数据片段,直到它变成合法 JSON!


📊 streamdonebuffer 的角色总览

变量 作用 通俗比喻
stream 是否开启流式 开/关"打字机模式"
done 流是否结束 "AI说完了没?"
buffer 暂存未完成的JSON "等你把话说完再听"

🎯 关键逻辑

  • 每次读取新数据 → 拼到 buffer
  • \n 分割 → 逐行解析
  • 解析失败 → 丢回 buffer(等待下次拼接)

🔄 六、流式 vs 非流式:体验大不同!

类型 体验 代码 适用场景
流式 每个字蹦出来(像真人打字) stream: true 需要实时反馈的场景(AI对话)
非流式 等整段答案出来 stream: false 简单查询(如天气API)

💡 为什么流式更香?

用户体验:等待3秒 vs 等3秒+实时看到"正在生成..."!
"思考中...""喜羊羊和灰太狼去野餐...""结果被红太狼发现了!"

体验感直接拉满!🎯


⚠️ 七、CORS 错误:浏览器的"安全门卫"在作妖!

错误提示
"浏览器阻止了从 http://localhost:5173 请求 api.deepseek.com"

🔥 原因:浏览器安全策略(CORS),阻止直接跨域请求!

❌ 为什么不能直接写?

scss 复制代码
fetch('https://api.deepseek.com/chat/completions') // ❌ 被浏览器拦了!

✅ 正确方案:Vite 代理!

javascript 复制代码
// vite.config.js
export default defineConfig({
  server: {
    proxy: {
      '/api': {
        target: 'https://api.deepseek.com',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^/api/, '')
      }
    }
  }
})

🌈 代理原理

  1. 前端请求 /api/chat/completions
  2. Vite 代理服务器转成 https://api.deepseek.com/chat/completions
  3. 浏览器只看到 localhost,不会触发CORS!
    安全 + 体验双杀! 🔥

💎 八、总结:从"写代码"到"写故事"的蜕变

项目 以前 现在
项目初始化 配置Webpack、Babel... npm init vite → 3秒搞定!
响应式 手动操作DOM ref + v-model → 业务优先!
流式输出 无法实现 buffer + ReadableStream → 字字清晰!
CORS 搞不定 Vite代理 → 一行配置搞定!

终极感悟

Vue 3 不是框架,是前端工程师的浪漫 ------

你只管写"我想让AI说'喜羊羊和灰太狼'...",

Vue默默帮你把"打字机效果"实现得丝滑到哭!
代码写得像写诗,体验做得像魔术! 🌟

相关推荐
StarkCoder1 小时前
求求你,别在 Swift 协程开头写 guard let self = self 了!
前端
清妍_1 小时前
一文详解 Taro / 小程序 IntersectionObserver 参数
前端
清水寺小和尚1 小时前
RAG (检索增强生成) 深度实战知识库
aigc
电商API大数据接口开发Cris1 小时前
构建异步任务队列:高效批量化获取淘宝关键词搜索结果的实践
前端·数据挖掘·api
符方昊1 小时前
如何实现一个MCP服务器
前端
喝咖啡的女孩1 小时前
React useState 解读
前端
渴望成为python大神的前端小菜鸟1 小时前
浏览器及其他 面试题
前端·javascript·ajax·面试题·浏览器
1024肥宅2 小时前
手写 new 操作符和 instanceof:深入理解 JavaScript 对象创建与原型链检测
前端·javascript·ecmascript 6
吃肉的小飞猪2 小时前
uniapp 下拉刷新终极方案
前端