Vue 实战:手把手教你实现 ChatGPT 同款打字机效果

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 的响应式原理

  • Vue2Object.defineProperty 劫持数据的 getter/setter
  • Vue3Proxy 代理整个对象

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 协议约定

要实现流式输出,客户端和服务器需要约定好:

  1. 客户端发送请求时带上 stream: true
  2. 服务器收到后,不是返回一个完整的 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 文件里,任何人打开浏览器的"开发者工具"都能看到。用环境变量的好处是:

  1. .env.local 不会被提交到 Git(在 .gitignore 里)
  2. 不同环境可以用不同的 key
  3. 部署时可以在服务器上设置环境变量

八、面试高频问题汇总

1. Vue 的响应式原理是什么?

Vue3 用 Proxy 代理数据对象,拦截数据的读取(get)和修改(set)。当你读取数据时,Vue 记录"谁在用这个数据"(依赖收集);当你修改数据时,Vue 通知所有用到这个数据的地方更新(派发更新)。

2. v-model 的本质是什么?

v-model 是语法糖,等价于 :value(绑定值)+ @input(监听输入事件)。对于表单元素,它实现了数据和用户输入的双向同步。

3. SSE vs WebSocket 怎么选?

  • 只需要服务器推送(如流式输出、通知)→ 用 SSE,基于 HTTP,实现简单
  • 需要双向实时通信(如聊天、协作编辑)→ 用 WebSocket,性能更好
  • 不想改后端代码、数据变化不频繁 → 用轮询,最简单但最浪费

4. ReadableStream 的工作流程?

  1. 服务器返回响应,response.body 是一个 ReadableStream
  2. 调用 getReader() 获取读取器
  3. 循环调用 reader.read(),每次返回 { value: Uint8Array, done: boolean }
  4. TextDecoder 把二进制转成文字
  5. donetrue 时停止读取

5. async/await 和 Promise 的关系?

async/await 是 Promise 的语法糖。async 函数返回一个 Promise,await 可以"暂停"函数执行,等 Promise 完成后再继续。本质上是 Generator + Promise 的封装,让异步代码看起来像同步代码。


九、总结与学习建议

回顾这篇文章,我们走过了这样一条路:

sql 复制代码
Vue 组件化 → 响应式数据 → 数据驱动思维 → Fetch 发请求 → 流式输出

核心就一句话:数据驱动 > DOM 编程

在 AI 时代,流式输出是前端工程师的必备技能。但更重要的是理解背后的思维方式------你不需要手动操作 DOM,只需要告诉框架"数据是什么",框架会帮你更新页面。

最后给正在学习的同学几点建议:

  1. 别只看教程,动手写代码。看十遍不如自己写一遍
  2. 遇到不懂的概念,先用比喻理解,再看技术细节
  3. 面试准备要有重点,把核心知识点吃透比泛泛了解一百个知识点更有用

希望这篇文章对你有帮助。如果你也在学习前端,欢迎一起交流!


📌 本文代码示例基于 Vue3 + Vite,完整项目代码可参考官方文档或自行实现

相关推荐
秦先生在广东1 小时前
Hallmark:一个拒绝“AI味“的设计技能工具深度解析
人工智能
老金带你玩AI1 小时前
Codex109天重置23次,明天还要再送一次
人工智能
kylin-运维1 小时前
k8s可视化看板嵌入AI助手(悬浮对话球)
人工智能·容器·kubernetes·aicoding
BreezeJiang1 小时前
Vue 3 流式输出实战:ReadableStream + DeepSeek API 实现打字机对话
前端
code_pgf1 小时前
LLM/MLLM 与世界模型在具身智能中的角色、局限与分类
人工智能
用户EasyAdminBlazor1 小时前
EasyAdminBlazor 第十篇:数据导入导出——批量处理不再麻烦
前端·后端
I-NullMoneyException1 小时前
0713|油价飙升、蓝牙致癌辟谣、AI推理成本、曼谷火灾、世界杯超清
人工智能·区块链
饮茶三千1 小时前
电子保函模板编辑器实战二:HTML→FTL 编译转换的设计与实现
前端·vue.js·设计模式
ricardo19731 小时前
SSR / SSG 性能对比:Next.js 三种渲染模式实测数据
前端·面试