前言
最近在学习 AI Chatbot 的前端开发,花了几天时间从零开始,用 Vue 3 调用 DeepSeek API,实现了一个支持"流式输出"和"非流式输出"切换的聊天机器人。这篇文章把整个过程记录下来,既帮你搞懂 Vue 3 的核心概念(组件化、响应式、数据绑定),也带你理解 AI 应用最关键的用户体验------流式输出。
你将会收获:
- Vue 3 核心概念:组件化、响应式数据、模板语法、双向绑定
- 从二进制流到 JSON 字符串再到 JS 对象的完整数据转换链路
- DeepSeek API 的调用方式(流式 vs 非流式)
- 用 ReadableStream 实现打字机效果的原理
- 一个可运行的 AI 聊天机器人项目
一、项目跑起来需要啥?
先搞清楚一个 Vue 3 项目是怎么启动的,别小看这几步------每个文件都有它的职责。
1.1 三个核心文件的分工
bash
index.html → 浏览器入口,提供挂载点 <div id="app">
src/main.js → 创建 Vue 应用,挂载到 #app 上
src/App.vue → 你的第一个组件,所有业务逻辑在这里
index.html ------ 浏览器入口
html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>stream-demo</title>
</head>
<body>
<div id="app"></div> <!-- 1. 占个坑,Vue 接管这里 -->
<script type="module" src="/src/main.js"></script> <!-- 2. 加载入口 JS -->
</body>
</html>
<div id="app"> 是整个 Vue 应用的唯一挂载点。它本身是空的,Vue 会把它填满。类比:墙上的钉子,Vue 应用是一幅画,mount 就是把画挂到钉子上。
main.js ------ 创建并挂载 Vue 应用
js
import { createApp } from 'vue' // 从 Vue 库引入"创建应用"方法
import './style.css' // 全局样式
import App from './App.vue' // 引入根组件
createApp(App).mount('#app') // 创建应用 → 挂到 #app 上
createApp(App) 创建了一个 Vue 应用实例。.mount('#app') 把它塞进 <div id="app">。这一步之后,#app 内部的世界就由 Vue 组件统治了。
App.vue ------ 你的组件
.vue 文件由三部分组成:
<template>------ HTML 模板,负责页面结构<script>------ JS 逻辑,负责数据和交互<style>------ CSS 样式,只管这个组件的外观
这种"三合一"的设计就是组件化思想的核心:把属于同一个功能的结构、逻辑、样式封在一起,不再像传统开发那样三个文件到处找。
1.2 Vite 脚手架做了什么
浏览器不认识 .vue 文件(它只认识 HTML、CSS、JS)。所以需要 Vite(一个由 Node.js 写的构建工具)在中间做编译:
.vue 文件 → Vite 编译 → 浏览器能跑的 JS/CSS → 页面渲染
启动命令:
bash
npm run dev # 在 5173 端口启动 HTTP 服务
# 浏览器访问 http://localhost:5173
访问根路径 / 时,服务器默认返回 index.html,加载 main.js,Vue 应用启动------整个链条就通了。
二、Vue 3 的核心概念
2.1 组件化(Componentization)
"构成页面的最小单位不再是 HTML 标签,而是组件。"
HTML 标签像砖块(<div>、<button>、<input>),太细碎了。组件是把一堆 HTML + CSS + JS 打包成一个功能单元,像乐高积木一样拼装页面:
xml
页面
├── <Header />
├── <Sidebar />
├── <ChatArea>
│ ├── <MessageBubble /> ← AI 对话气泡
│ ├── <MessageBubble /> ← 用户对话气泡
│ └── <InputBox /> ← 发送消息的输入框
└── <Footer />
每个组件职责单一,出问题直接定位到对应 .vue 文件,改了不影响其他组件。
2.2 响应式数据(Reactive)------ 数据变了,页面自动更新
这是 Vue 和传统 DOM 编程最大的区别:
js
// 传统方式:手动操作 DOM
document.querySelector('.content').innerHTML = '思考中....'
// Vue 方式:声明响应式数据,只管改数据
const content = ref('')
content.value = '思考中....' // 改数据 → 页面自动刷新
ref() 是 Vue 3 里把普通数据变成响应式数据 的方法。它给数据装了一个"追踪器"------Vue 知道谁在用这个数据(模板里的 {{ content }}),数据一变就通知那边更新。
js
import { ref } from 'vue'
const count = ref(1) // 数字,初始值 1
const question = ref('') // 字符串,初始值空
const stream = ref(false) // 布尔值,初始 false
// JS 里读写需要 .value
count.value = 2 // 改了,模板里 {{ count }} 自动变 2
console.log(count.value) // 读也要 .value
三种初始值模式:
js
const count = ref(0) // 数字从 0 开始
const content = ref('') // 字符串从空开始,等接口返回再填
const list = ref([]) // 数组从空开始,等着 push 数据
const user = ref(null) // 对象从 null 开始,等接口返回再赋值
2.3 <script setup> ------ 语法糖
Vue 3 新增的 <script setup> 让你不用手动导出数据给模板:
vue
<script setup>
const count = ref(1) // 顶层变量自动暴露给模板
</script>
<template>
{{ count }} <!-- 直接用,不用 return -->
</template>
对比老的写法需要 return:
js
export default {
setup() {
const count = ref(1)
return { count } // 必须手动 return
}
}
<script setup> 把这个 return 步骤省掉了------这就是"语法糖":功能一样,写起来更甜。
2.4 模板语法大全
{{ }} ------ 插值表达式(单向数据绑定)
html
<p>{{ content }}</p> <!-- JS 数据 → 页面,用户不能改 -->
用在标签里面的文字内容。数据只从 JS 流向页面。
: ------ 属性绑定(v-bind 简写)
html
<input :value="question" /> <!-- value 属性跟着 question 变 -->
<input :placeholder="'请输入' + question" />
<button :disabled="question === ''">提交</button>
<img :src="imageUrl" />
用在标签身上 的属性。: 是 v-bind: 的简写,二者等价。
v-model ------ 双向绑定
html
<input v-model="question" /> <!-- 文本:绑字符串 -->
<input type="checkbox" v-model="stream" /> <!-- 勾选框:绑布尔值 -->
v-model 本质是 :value + @input 的语法糖:
html
<!-- v-model="question" 等价于 -->
<input :value="question" @input="question = $event.target.value" />
不同表单控件绑定的类型:
| 控件 | v-model 绑的值 |
|---|---|
<input type="text"> |
字符串 |
<input type="checkbox"> |
布尔值(true/false) |
<select> |
字符串(选中项的 value) |
<textarea> |
字符串 |
v-if ------ 条件渲染
html
<div v-if="stream">流式模式已开启</div>
<!-- stream 为 true 时显示,false 时这个 div 直接从 DOM 移除 -->
@click ------ 事件绑定(v-on 简写)
html
<button @click="update">提交</button>
<!-- 点击时执行 update 函数 -->
三、"数据"到底是什么?------ 状态的概念
编程课本上常说"变量",但在 Vue 里,更准确的说法是状态(State)。
状态 = 描述当前页面长什么样的数据。
js
const question = ref('讲一个关于中国龙的故事') // 输入框里有什么 → 一个"状态"
const stream = ref(false) // 勾没勾流式 → 一个"状态"
const content = ref('') // AI 返回了什么 → 一个"状态"
传统写法里,要"看"页面上的东西,你只能去读 DOM:
js
const text = document.querySelector('.input').value // 取值靠查 DOM
Vue 反过来了------数据 JS 是真相来源,页面的 DOM 只是它的镜像。你只管 JS 数据,页面自动跟着数据走。这就是"数据驱动(Data Driven)"。
四、表单的例外 ------ 什么时候需要双向绑定?
{{ }} 是单向的:JS 数据 → 页面。用户没法在页面上改这个值。
但表单不一样------用户要在输入框里打字,输入的内容得传回 JS。所以需要双向:
JS 改 question.value → 输入框自动更新 (JS → 页面)
用户打字改输入框内容 → question.value 自动更新 (页面 → JS)
question.value 变了 → {{ question }} 也更新 (JS → 页面)
v-model 就是在表单元素上用的双向绑定工具。
五、数据类型和转换 ------ 从 0101 到页面显示
这是一个很基础但容易绕晕的点。从 DeepSeek 服务器到你的 {{ content }},数据经历了多次形态变化:
5.1 完整链路
css
① 二进制流(网线上跑的原始 0 和 1)
│
│ fetch 自动接收,包进 Response 对象
↓
② Response 对象
│ 方式1:response.json() → 一次性读完 → 解析成 JS 对象
│ 方式2:response.body.getReader() → 一小块一小块读(流式用这个)
↓
③ JSON 字符串(纯文本,人能读的中间格式)
│ 例:'{"choices":[{"message":{"content":"你好"}}]}'
│
│ JSON.parse(json字符串) → 字符串变 JS 对象
│ JSON.stringify(js对象) → JS 对象变字符串(发请求时用)
↓
④ JS 对象(代码里能用 . 取值操作)
│ 例:{ choices: [{ message: { content: '你好' } }] }
│
│ data.choices[0].message.content → 取出最终的内容字符串
↓
⑤ JS 基本类型
字符串 '你好' → 赋给 content.value → {{ content }} 显示
布尔值 true/false → 控制开关、显隐
数字 123 → 计数器、计数
5.2 两个关键转换
JSON.stringify() ------ 发请求时:JS 对象 → JSON 字符串
js
// JS 对象(在内存里,代码可以 . 访问)
const body = {
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: '你好' }],
stream: false
}
// JSON.stringify 之后 → 变成纯文本字符串(可以在网络上传输)
// '{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"你好"}],"stream":false}'
HTTP 请求只能传文本。你没法把一个 JS 对象直接塞进请求体,得先 "装箱" 成 JSON 字符串。
response.json() ------ 收响应时:二进制流 → JS 对象
js
const response = await fetch(endpoint, {...}) // response 里包着二进制流
const data = await response.json() // 读出来 → 解析 → JS 对象
content.value = data.choices[0].message.content // 现在可以 . 取值了
5.3 总结表
| 形态 | 是什么 | 在哪用 | 人能读吗 | 代码能 . 操作吗 |
|---|---|---|---|---|
| 二进制流 | 0 1 的字节 | 网线上跑 | ❌ 乱码 | ❌ 不能 |
| JSON 字符串 | 文本 | 网络传输的载体 | ✅ 能 | ❌ 不能(它只是字) |
| JS 对象 | 数据结构 | 代码里操作 | ✅ 能 | ✅ 能 |
| JS 基本类型 | 字符串/数字/布尔 | 代码里赋值、显示 | ✅ 能 | ✅ 能 |
六、正题:流式输出 vs 非流式输出
6.1 问题
AI 模型推理生成内容耗时很长(Transformer 的推理计算量很大,问题越难越长耗时越久)。如果等它全部生成完再一次性返回,用户盯着白屏干等十几秒------体验极差。
6.2 解决:流式(Streaming)
不用等全部生成完。模型每生成一个 token(可以理解为一个字或一个词),就立刻推送到客户端。客户端不断拼接这些 token,效果就像一个打字机实时往外吐字。
vbnet
stream: true → "你好" "我" "是" "一" "个" ... 一个个推过来 → 打字机效果
stream: false → 等 10 秒 → 一次性全部返回 → 用户干等
类比:水流,接根管子从 LLM 服务器接到 chatbot 客户端。生成的 token 就像水流一样不断流过来。
6.3 约定
这套机制需要前后端配合:
- 客户端发请求时 :
body里带"stream": true - 服务器收到后 :看到
stream: true,就生成一段吐一段,不一次性返回
七、实战:完整的 Chatbot 代码逐段讲解
7.1 模板部分 ------ UI
vue
<template>
<div class="container">
<div>
<label>输入:</label>
<input type="text" class="input" v-model="question">
<button @click="update">提交</button>
</div>
<div class="output">
<div>
<label>Streaming</label>
<input type="checkbox" v-model="stream" />
<div v-if="stream">
<label>Streaming</label>
</div>
<div>
{{content}}
</div>
</div>
</div>
</div>
</template>
一步步分解:
<input v-model="question">------ 输入框和question双向绑定。用户打字,question.value自动更新。<button @click="update">------ 点击触发update函数,发送请求。<input type="checkbox" v-model="stream">------ 勾选框绑stream(布尔值),决定是否开流式。<div v-if="stream">------stream为true才显示"Streaming"标签。{{content}}------ 显示 AI 返回的内容。不管是流式还是非流式,最终都渲染到这里。
7.2 脚本部分 ------ 逻辑
7.2.1 定义状态
js
import { ref } from 'vue'
const question = ref('讲一个关于中国龙的故事') // 用户输入的问题
const stream = ref(false) // 是否开启流式输出
const content = ref('') // AI 返回的内容
// 三个 ref,三个独立的状态。各管各的,互不干扰。
7.2.2 update 函数 ------ 完整流程
js
const update = async () => {
// 1. 空值守卫:输入为空就不发请求,避免浪费
if (!question.value) return
// 2. 设置"加载中"状态,给用户即时反馈
content.value = '思考中....'
// 3. 准备请求配置
const endpoint = 'https://api.deepseek.com/chat/completions'
const headers = {
'Content-Type': 'application/json', // 告诉服务器"我发的是 JSON"
'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}` // API Key 验证身份
}
// 4. 发送请求
const response = await fetch(endpoint, {
method: 'POST', // POST 方式提交,body 被加密(HTTPS),比 GET 安全
headers,
body: JSON.stringify({ // 把 JS 对象打包成 JSON 字符串
model: 'deepseek-v4-flash',
messages: [{
role: 'user',
content: question.value // 用户的问题
}],
stream: stream.value // 关键参数:是否流式
})
})
// 5. 根据 stream 分支处理响应
if (stream.value) {
// ===== 流式输出 =====
content.value = ''
// 5a. 获取读取器(把二进制流一小块一小块读出来)
const reader = response.body?.getReader() // ?. 安全访问,body 为 null 时不报错
const decoder = new TextDecoder() // 解码器:把二进制字节转成文本
let done = false // 是否读完的标志
let buffer = '' // 缓冲区,用来拼碎片
while (!done) { // 一直读,直到 done === true
const { value, done: doneReading } = await reader.read()
// value 是 Uint8Array(十进制的字节数组,底层是二进制)
// doneReading 是 true 时说明读完了
done = doneReading
if (value) {
buffer += decoder.decode(value, { stream: !done }) // 解码二进制 → 文本 → 拼接到 buffer
}
content.value = buffer // 每读一块就更新页面 → 打字机效果!
}
} else {
// ===== 非流式输出 =====
// 服务器一次性返回全部结果,直接读完
const data = await response.json()
content.value = data.choices[0].message.content
// 取响应里的这个路径:choices 数组 → 第 0 个 → message → content
}
}
7.3 请求参数说明
发给 DeepSeek 的 body 里有哪些参数:
| 参数 | 值 | 说明 |
|---|---|---|
model |
'deepseek-v4-flash' |
模型名称,flash 版更快更便宜 |
messages |
[{ role: 'user', content: ... }] |
对话历史数组,role: 'user' 表示用户说的话 |
stream |
stream.value |
true = 流式输出,false = 一次性返回 |
temperature |
0.5 |
随机性,0~2,越高越"放飞",越低越保守 |
max_tokens |
1024 |
最多生成多少个 token |
top_p |
0.5 |
核采样参数,控制词汇选择范围 |
frequency_penalty |
0.0 |
重复惩罚,大于 0 减少重复 |
presence_penalty |
0.0 |
话题惩罚,大于 0 鼓励聊新话题 |
7.4 Authorization 认证是什么?
js
'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`
Authorization------ HTTP 请求头字段,用于身份验证Bearer------ 令牌的前缀类型(一种约定格式),表示"持票人认证"import.meta.env.VITE_DEEPSEEK_API_KEY------ 从环境变量文件.env.local里读取你的 DeepSeek API Key
Vite 会自动读取项目根目录的 .env.local 文件。你需要在里面写:
ini
VITE_DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxx
上面代码中我们用到了 import.meta.env.VITE_DEEPSEEK_API_KEY,这个 import.meta.env 是 Vite 提供的,不是 Vue 的。通过它就能在浏览器端读到 .env.local 文件里的环境变量(注意:只有以 VITE_ 开头的变量才会被暴露给前端代码)。
八、流式输出的原理深入
8.1 ReadableStream 是什么?
fetch 返回的 response.body 是一个 ReadableStream(可读流)。它不是一次性给你所有数据,而是像一个水龙头------你可以拧开一点接一点。
scss
DeepSeek 服务器
│
│ 把一个一个 token 推到"水管"里
↓
response.body(ReadableStream 水管)
│
│ reader.read() 一次拧开一点,接住一个 chunk
↓
你的代码 → decoder.decode() 解码 → 拼到 buffer → 更新 content.value → 页面显示
8.2 读取器(Reader)的工作方式
js
const reader = response.body?.getReader() // 从水管上装一个"阀门"
const { value, done } = await reader.read()
// value = 这一批读到的字节(Uint8Array 类型,十进制数组,底层是二进制)
// done = true/false,流读完了就是 true
每次 reader.read() 返回一小块数据(chunk)。DeepSeek 流式模式下,一个 chunk 可能包含一个或多个 token。
8.3 TextDecoder ------ 二进制 → 文本
网线上跑的是二进制字节。人读不了,代码也不好直接处理。所以用 TextDecoder 把它解码成文本:
js
const decoder = new TextDecoder()
const text = decoder.decode(value, { stream: true })
// 参数 stream: true 表示"还有更多数据在后面"
// 这样 decoder 知道不完整的汉字不要强行翻译,等下一块拼好了再说
8.4 为什么用 ?.(可选链)
js
const reader = response.body?.getReader()
某些特殊情况(比如 HTTP 204 状态码或跨域限制)下 response.body 可能是 null。如果直接 response.body.getReader() 就会报错崩溃。?. 是安全访问------body 为 null 时自动停下返回 undefined,不报错。
九、API Key 的正确存放方式
js
'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`
不要 把 API Key 直接写在代码里(有泄露风险)。应该放在 .env.local 文件中:
ini
VITE_DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Vite 构建时会读取这个文件,import.meta.env.VITE_DEEPSEEK_API_KEY 会被替换成对应的值。(注意:只有以 VITE_ 开头的变量才会暴露给前端代码。)
十、从 data.choices[0].message.content 理解 JSON 取值的路径
DeepSeek 返回的 JSON 结构大概长这样:
json
{
"id": "chatcmpl-xxx",
"object": "chat.completion",
"created": 1234567890,
"model": "deepseek-v4-flash",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "你好!我可以帮你做什么?"
},
"finish_reason": "stop"
}
]
}
取值路径的拆解:
js
data // 整个响应对象
data.choices // 找到 choices 数组
data.choices[0] // 取数组的第 0 个元素(第一个,通常只有一个)
data.choices[0].message // 取里面的 message 对象
data.choices[0].message.content // 最终取到回复文本
这就是 JS 对象的"点语法(dot notation)"------一层层深入取出你想要的值。
十一、完整项目结构一览
java
stream demo/
├── index.html ← 浏览器入口 + 挂载点 <div id="app">
├── package.json ← 项目依赖:vue、vite、@vitejs/plugin-vue
├── vite.config.js ← Vite 配置
├── .env.local ← 环境变量,存 API Key(VITE_DEEPSEEK_API_KEY)
├── .gitignore ← git 忽略文件列表
├── README.md ← 项目说明
├── public/
│ └── favicon.svg ← 网站图标
└── src/
├── main.js ← Vue 应用入口:createApp + mount
├── style.css ← 全局样式
├── App.vue ← 主组件:模板 + 逻辑 + 样式
├── assets/ ← 静态资源(图片等)
└── components/ ← 子组件目录
十二、总结:每一步在完整流程里的位置
把整个交互流程串起来:
sql
1. 用户在输入框打字
v-model → question.value 自动更新
2. 用户勾选/取消 Streaming
v-model → stream.value 自动更新(true/false)
3. 用户点击"提交"
@click → 触发 update 函数
4. update 函数
a. 空值检查 → 设置 content = '思考中....'(页面即时反馈)
b. 构建请求头(Content-Type + Authorization)
c. 把数据 (question, stream) 用 JSON.stringify 打包
d. fetch(endpoint, { method:'POST', body:... }) 发请求
5. 服务器收到请求
→ DeepSeek API 处理
→ stream: false → 一次性返回全部 JSON
→ stream: true → 推流式数据块
6. 客户端处理响应
→ 非流式:response.json() → data.choices[0].message.content → 赋值
→ 流式:response.body.getReader() → 循环 read() → decoder 解码 → 拼接 → 赋值
7. 页面渲染
→ content.value 更新 → {{ content }} 自动刷新 → 用户看到结果
整个过程中,没有一行 document.querySelector 或 innerHTML。Vue 的响应式系统全程接管了"数据 → 页面"的同步------这就是现代前端框架的价值。
核心概念速查表
| 概念 | 一句话 |
|---|---|
| 组件化 | HTML+CSS+JS 打包成一个 .vue 功能单元,像乐高积木 |
| 响应式 | ref() 包裹的数据改了,页面自动更新 |
{{ }} |
单向:JS 数据 → 页面文字,用于标签内容 |
:属性 |
单向:JS 数据 → 标签属性,如 :value :src :disabled |
v-model |
双向:JS ↔ 表单,用户打字反过来改 JS 数据 |
v-if |
条件渲染:true 显示,false 移除 |
@事件 |
事件绑定:如 @click="函数" |
ref() |
创建响应式数据,.value 读写,模板自动解包 |
JSON.stringify |
JS 对象 → JSON 字符串(发请求前打包) |
response.json() |
二进制流 → JS 对象(收响应后拆包) |
| ReadableStream | 水管,流式读取一小块一小块拿 |
?. |
安全访问,为 null 时不报错 |
| "状态" | 描述当前页面长什么样的数据,改状态 = 改页面 |
| 流式输出 | 一个字一个字吐(打字机),不等全部生成完 |