Vue 实战:手把手教你实现 ChatGPT 同款打字机效果
本文带你从零实现一个流式输出 demo,把 Vue 基础、Fetch API、Web Streams 等知识点串成一条线。无论你是前端学生还是想了解 AI 前端交互的开发者,都能有所收获。
一、为什么要关注流式输出?
你有没有用过 ChatGPT、DeepSeek 这些 AI 产品?你会发现,它们的回答不是一次性全给你的,而是像打字机一样,一个字一个字地"打"出来。
这就是流式输出(Streaming)。
作为前端工程师,这是我们面对 AI 产品时要解决的第一个用户体验问题。用户问一个问题,AI 可能要"想"好几秒甚至十几秒。如果让用户干等着,体验很差。但如果一边生成一边显示,用户就会觉得"它在认真回答我"。
今天这篇文章,我会从 Vue 基础讲起,一步步带你实现一个完整的流式输出 demo。
二、Vue3 组件化基础
在开始写流式输出之前,我们先来聊聊 Vue。
什么是组件?
你可以把组件想象成乐高积木。一个网页不是由一堆 HTML 标签堆出来的,而是由一个个"组件"拼出来的。Facebook 的官网据说由一万多个组件组成。
在 Vue 里,一个组件就是一个 .vue 文件,它包含三部分:
vue
<template>
<!-- 模板:写 HTML,但可以绑定数据 -->
<div>{{ message }}</div>
</template>
<script setup>
// 逻辑:写 JavaScript,处理数据和事件
import { ref } from 'vue'
const message = ref('Hello Vue!')
</script>
<style>
/* 样式:写 CSS */
</style>
- template:不是普通的 HTML,它是"动态模板",可以绑定数据
- script :写逻辑的地方,
<script setup>是 Vue3 的语法糖,写起来更简洁 - style :写样式,可以加
scoped让样式只作用于当前组件
🎯 面试考点:Composition API vs Options API
Vue3 推荐用 <script setup>(Composition API),而 Vue2 用的是 Options API:
javascript
// Vue2 的 Options API
export default {
data() {
return { count: 0 }
},
methods: {
increment() { this.count++ }
}
}
// Vue3 的 Composition API
import { ref } from 'vue'
const count = ref(0)
const increment = () => count.value++
区别在哪? Options API 把数据、方法、生命周期分开写,逻辑分散了;Composition API 可以把相关的逻辑放在一起,更好维护。就像整理文件,按"项目"分比按"类型"分更容易找。
三、响应式数据驱动
Vue 最核心的思想就是数据驱动。
什么是数据驱动?
传统写网页,你要手动操作 DOM:
javascript
// 传统做法:手动改 DOM
document.getElementById('count').innerHTML = count
Vue 的做法是:你只管改数据,页面自动更新。
vue
<template>
<div>{{ count }}</div>
<button @click="count++">+1</button>
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0) // 改这个数据,页面自动变
</script>
ref 和 reactive 的区别
Vue3 提供了两种创建响应式数据的方式:
javascript
import { ref, reactive } from 'vue'
// ref:适合基本类型(数字、字符串、布尔)
const count = ref(0)
console.log(count.value) // 取值要加 .value
// reactive:适合对象和数组
const user = reactive({ name: '张三', age: 18 })
console.log(user.name) // 不用 .value
简单记: ref 简单数据用,reactive 复杂数据用。
🎯 面试考点:v-model 的本质
双向绑定 v-model 是面试高频题。它其实是语法糖:
vue
<!-- 这一行 -->
<input v-model="question">
<!-- 等价于这两行 -->
<input :value="question" @input="question = $event.target.value">
说白了就是:把数据绑到输入框的值上(:value),同时监听输入事件(@input),用户一输入就更新数据。这就是"双向"的含义------数据变了页面变,页面输入了数据也变。
🎯 面试考点:Vue 的响应式原理
- Vue2 用
Object.defineProperty劫持数据的 getter/setter - Vue3 用
Proxy代理整个对象
Proxy 的优势是能监听新增属性和数组变化,而 Vue2 要用 Vue.set() 才能监听新增属性。
四、从 DOM 编程到数据驱动
这个思维转变很重要,我用一个例子说明。
假设我们要把服务器返回的数据显示到页面上:
javascript
// ❌ DOM 编程思维
const data = await fetchData()
document.getElementById('content').innerHTML = data.text
// ✅ 数据驱动思维(Vue)
const content = ref('')
content.value = data.text // 改数据就行,页面自动更新
在 Vue 的模板里:
vue
<template>
<div>{{ content }}</div>
</template>
你不需要关心 DOM 怎么操作,只需要关心数据。这就是现代前端框架的核心思想。
五、Fetch API 与 HTTP 基础
在流式输出之前,我们先要会发 HTTP 请求。
fetch 是什么?
fetch 是浏览器自带的 API,用来发网络请求。你可能听过 jQuery 的 $.ajax,或者 axios 库------但 fetch 是原生的,不需要装任何库。
javascript
// 一个基本的 fetch 请求
const response = await fetch('https://api.example.com/data', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer sk-xxxx' // API Key 认证
},
body: JSON.stringify({ question: '你好' })
})
const data = await response.json() // 解析 JSON 响应
async/await
fetch 返回的是 Promise,我们用 async/await 来处理:
javascript
// async 标记函数是异步的
const update = async () => {
// await 等待 Promise 完成,拿到结果
const response = await fetch(endpoint, options)
const data = await response.json()
}
你可以理解为:await 就是"等一下,等这个操作做完再继续"。
🎯 面试考点:Bearer Token 认证
为什么请求头里写的是 Bearer sk-xxxx?这是 HTTP 认证的一种标准格式:
makefile
Authorization: Bearer <token>
Bearer是认证类型,意思是"持有这个 token 的人就是合法用户"- 后面跟的就是 API Key 或 JWT Token
- 服务器收到后会验证这个 token 是否有效
六、核心:流式输出的实现
终于到重头戏了!
6.1 什么是流式输出?
用一个比喻来解释:
传统模式:你去餐厅点菜,厨师做好了整桌菜才端上来。你可能要等 30 分钟。
流式模式:厨师做好一道端一道,你边吃边等。体验好很多。
在技术上,就是服务器不是等所有内容生成完才返回,而是生成一个 token 就发一个 token。前端收到一个就显示一个。
6.2 协议约定
要实现流式输出,客户端和服务器需要约定好:
- 客户端发送请求时带上
stream: true - 服务器收到后,不是返回一个完整的 JSON,而是持续发送数据块(chunk)
javascript
// 请求体里加上 stream: true
body: JSON.stringify({
model: 'gpt-3.5-turbo', // 或其他支持流式输出的模型
messages: [{ role: 'user', content: question.value }],
stream: true // 告诉服务器:我要流式输出
})
6.3 代码实现详解
下面是完整的流式输出代码,我逐行解释:
javascript
if (stream.value) {
content.value = '' // 清空之前的内容
// 1. 获取响应体的读取器
// response.body 是一个 ReadableStream(可读流)
// getReader() 创建一个读取器,用来逐块读取数据
const reader = response.body?.getReader()
// 2. 创建文本解码器
// 服务器发来的是二进制数据(Uint8Array)
// TextDecoder 把二进制转成我们能看懂的文字
const decoder = new TextDecoder()
let done = false // 读取是否完成的标志
let buffer = '' // 缓冲区,存放已读取的所有文字
// 3. 循环读取,直到流结束
while (!done) {
// reader.read() 返回一个 Promise
// 解构出 value(当前块的数据)和 done(是否读完)
const { value, done: doneFlag } = await reader.read()
// 把二进制数据解码成文字,追加到缓冲区
// 注意:必须加 { stream: true },否则多字节字符(如中文)可能被截断导致乱码
buffer += decoder.decode(value, { stream: true })
// 更新完成标志
done = doneFlag
// 4. 更新页面显示
// 因为 content 是 ref,改它的值,页面自动更新
// 这就是"数据驱动"在这里的实际应用!
content.value = buffer
}
}
注意 :上面的代码会把服务器发来的原始数据显示出来(包括
data:前缀)。如果你只想显示纯文本内容,需要解析 SSE 格式,见下一节。
markdown
**关键概念解释:**
- **ReadableStream**:Web Streams API 的一部分,代表一个"可读的数据流"。就像水管里的水,你可以一块一块地接
- **TextDecoder**:把二进制数据(Uint8Array)转换成文字。因为网络传输用的是二进制
- **reader.read()**:每次调用读取一个数据块,返回 `{ value, done }`。`done` 为 `true` 时说明数据读完了
- **while 循环**:不停地读,直到服务器说"我说完了"
### 6.4 流式数据的格式
服务器发来的数据是 SSE(Server-Sent Events)格式:
data: {"choices":{"delta":{"content":"你"}}}
data: {"choices":{"delta":{"content":"好"}}}
data: DONE
arduino
每一行以 `data: ` 开头,后面是一个 JSON。最后用 `data: [DONE]` 表示结束。
如果你需要解析每个 token 的内容,可以这样处理:
```javascript
// 在 while 循环里,解析 SSE 数据
const lines = buffer.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
const jsonStr = line.slice(6) // 去掉 "data: " 前缀
if (jsonStr === '[DONE]') {
done = true
break
}
const json = JSON.parse(jsonStr)
const token = json.choices[0].delta.content
// 处理每个 token...
}
}
🎯 面试考点:SSE vs WebSocket vs 轮询
| 方式 | 方向 | 协议 | 适用场景 |
|---|---|---|---|
| 轮询 | 客户端定时请求 | HTTP | 简单但浪费资源 |
| SSE | 服务器 → 客户端(单向) | HTTP | 流式输出、实时通知 |
| WebSocket | 双向通信 | WS | 聊天室、游戏 |
流式输出用 SSE 就够了,因为只需要服务器向客户端发数据,不需要双向通信。
七、Vite 环境变量与安全
在我们的 demo 里,API Key 存放在 .env.local 文件中:
ini
VITE_MIMO_API_KEY=sk-xxxxx
VITE_MIMO_API_BASE_URL=https://api.xiaomimimo.com/v1
VITE_MIMO_MODEL=mimo-v2.5-pro
在代码里通过 import.meta.env 读取:
javascript
const apiKey = import.meta.env.VITE_MIMO_API_KEY
为什么不能把 API Key 写死在代码里?
因为前端代码是公开的。如果你把 key 写在 .js 文件里,任何人打开浏览器的"开发者工具"都能看到。用环境变量的好处是:
.env.local不会被提交到 Git(在.gitignore里)- 不同环境可以用不同的 key
- 部署时可以在服务器上设置环境变量
八、面试高频问题汇总
1. Vue 的响应式原理是什么?
Vue3 用 Proxy 代理数据对象,拦截数据的读取(get)和修改(set)。当你读取数据时,Vue 记录"谁在用这个数据"(依赖收集);当你修改数据时,Vue 通知所有用到这个数据的地方更新(派发更新)。
2. v-model 的本质是什么?
v-model 是语法糖,等价于 :value(绑定值)+ @input(监听输入事件)。对于表单元素,它实现了数据和用户输入的双向同步。
3. SSE vs WebSocket 怎么选?
- 只需要服务器推送(如流式输出、通知)→ 用 SSE,基于 HTTP,实现简单
- 需要双向实时通信(如聊天、协作编辑)→ 用 WebSocket,性能更好
- 不想改后端代码、数据变化不频繁 → 用轮询,最简单但最浪费
4. ReadableStream 的工作流程?
- 服务器返回响应,
response.body是一个ReadableStream - 调用
getReader()获取读取器 - 循环调用
reader.read(),每次返回{ value: Uint8Array, done: boolean } - 用
TextDecoder把二进制转成文字 done为true时停止读取
5. async/await 和 Promise 的关系?
async/await 是 Promise 的语法糖。async 函数返回一个 Promise,await 可以"暂停"函数执行,等 Promise 完成后再继续。本质上是 Generator + Promise 的封装,让异步代码看起来像同步代码。
九、总结与学习建议
回顾这篇文章,我们走过了这样一条路:
sql
Vue 组件化 → 响应式数据 → 数据驱动思维 → Fetch 发请求 → 流式输出
核心就一句话:数据驱动 > DOM 编程。
在 AI 时代,流式输出是前端工程师的必备技能。但更重要的是理解背后的思维方式------你不需要手动操作 DOM,只需要告诉框架"数据是什么",框架会帮你更新页面。
最后给正在学习的同学几点建议:
- 别只看教程,动手写代码。看十遍不如自己写一遍
- 遇到不懂的概念,先用比喻理解,再看技术细节
- 面试准备要有重点,把核心知识点吃透比泛泛了解一百个知识点更有用
希望这篇文章对你有帮助。如果你也在学习前端,欢迎一起交流!
📌 本文代码示例基于 Vue3 + Vite,完整项目代码可参考官方文档或自行实现