摘要
本文详细介绍如何通过 Vue 3 + Element Plus 实现 AI 聊天接口的流式调用,即"逐字输出"的打字机效果。从最基础的正常调用(一次性返回)出发,逐步讲解流式输出的核心原理(ReadableStream + fetch 分块读取),并给出完整代码。随后针对实际使用中的体验痛点,提供了自动滚动到底部的优化方案(判断内容溢出与用户滚动行为),最后总结了一系列其他优化建议(URL 编码、并发锁、取消请求、错误处理等)。适合正在开发聊天机器人前端的同学参考。
一、背景:AI 对话的两种调用方式
当我们调用后端的 AI 聊天接口(如 GPT 类模型)时,常见的返回方式有两种:
| 方式 | 行为 | 体验 |
|---|---|---|
| 正常调用(一次性输出) | 后端完整生成答案后一次性返回 | 用户需等待数秒甚至十多秒,期间界面空白或转圈,容易焦虑 |
| 流式调用(逐字输出) | 后端每生成一个词或一小段就立即发送,前端收到后实时显示 | 文字逐字出现,像打字机,反馈即时,用户体验好 |
显然,流式调用更适合对话场景。下面我们先看一个最简单的流式调用实现,然后分析其原理,最后做各种优化。
二、最简单的流式调用实现
以下是一个基于 Vue 3 + Element Plus 的流式调用示例(仅核心功能):
vue
<template>
<el-input v-model="question" placeholder="请输入问题" />
<el-button type="primary" @click="ask">发送</el-button>
<el-input v-model="answer" type="textarea" :autosize="{ minRows: 3, maxRows: 10 }" placeholder="回答" />
</template>
<script setup>
import { ref } from 'vue'
const question = ref('')
const answer = ref('')
const memoryId = ref('1')
const ask = async () => {
if (!question.value.trim()) return
answer.value = ''
const url = `http://localhost:9000/api/chat/chat01?memoryId=${memoryId.value}&message=${question.value}`
try {
const response = await fetch(url, {
method: 'GET',
headers: { 'Accept': 'text/html;charset=utf-8' }
})
if (!response.ok) throw new Error('请求失败')
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
answer.value += chunk
}
} catch (error) {
answer.value = '出错了,请稍后重试'
}
}
</script>
原理大白话解读
-
后端配合:后端接口支持分块传输(Transfer-Encoding: chunked 或 SSE),生成一点就发一点。
-
前端逐块读取:
-
fetch得到的response.body是一个ReadableStream(可读流)。 -
调用
getReader()拿到读取器,然后用while循环不断调用reader.read()。 -
每次取到一小块二进制数据(
value),用TextDecoder解码成字符串,拼接到answer.value后面。
-
-
实时渲染:由于 Vue 是响应式的,每次拼接都会触发 DOM 更新,用户就看到文字一个一个蹦出来了。
三、痛点:内容超出可视区后需手动滚动
上面的代码虽然实现了流式输出,但有一个明显的体验问题:当回答内容超过文本框的可视高度时,新出现的文字不会自动滚动到底部,用户必须手动拖动滚动条。
期望的行为是:
-
内容未超出可视区 → 无需滚动
-
内容超出可视区,且用户当前没有主动向上翻阅历史 → 自动滚动到底部,让用户始终看到最新的内容
-
如果用户向上滚动查看旧内容 → 停止自动滚动,尊重用户的阅读位置
四、自动滚动的实现方案
4.1 关键步骤
-
给
<el-input>添加ref,以便获取内部的<textarea>DOM 元素。 -
监听
textarea的scroll事件,判断用户是否在底部附近。 -
使用
watch监听answer的变化,在每次新增内容后:-
判断内容是否溢出(
scrollHeight > clientHeight) -
判断用户是否处于底部(滚动条距离底部小于 10px)
-
两个条件都满足时,才将
scrollTop设置为scrollHeight。
-
4.2 代码修改(在原有基础上添加)
修改模板部分
vue
<el-input
ref="answerTextareaRef"
v-model="answer"
type="textarea"
:autosize="{ minRows: 3, maxRows: 10 }"
placeholder="回答"
@scroll="onScroll"
/>
修改脚本部分
js
import { ref, nextTick, watch } from 'vue'
// ... 原有代码
const answerTextareaRef = ref(null)
let isUserScrollingUp = false // 标记用户是否主动向上滚动
// 监听 textarea 滚动事件
const onScroll = () => {
const textareaEl = answerTextareaRef.value?.$el?.querySelector('textarea')
if (!textareaEl) return
// 距离底部小于 10px 认为在底部
const isAtBottom = (textareaEl.scrollHeight - textareaEl.scrollTop - textareaEl.clientHeight) < 10
isUserScrollingUp = !isAtBottom
}
// 自动滚动到底部(条件满足时才滚动)
const autoScrollToBottom = async () => {
await nextTick() // 等待 DOM 更新完成
const textareaEl = answerTextareaRef.value?.$el?.querySelector('textarea')
if (!textareaEl) return
const isOverflow = textareaEl.scrollHeight > textareaEl.clientHeight // 是否溢出
if (isOverflow && !isUserScrollingUp) {
textareaEl.scrollTop = textareaEl.scrollHeight
}
}
// 监听 answer 变化,触发自动滚动
watch(answer, () => {
autoScrollToBottom()
})
// 在 ask 函数开头重置滚动标记(新问题默认用户在底部)
const ask = async () => {
isUserScrollingUp = false // 新增
// ... 原有代码
}
4.3 为什么这样写
-
nextTick:确保 Vue 已经把最新的answer渲染到<textarea>中,否则scrollHeight可能还是旧值。 -
判断溢出:没有滚动条时滚动也没意义。
-
尊重用户行为:用户向上滚动时不再强制拉回底部,只有当他主动滚回底部或发送新问题时,才恢复自动滚动。
五、更多优化建议(提升健壮性与体验)
除了自动滚动,实际项目中还建议做以下优化(按优先级排序):
🔴 高优先级
| 优化点 | 原因 |
|---|---|
修复 URL 参数未编码 :使用 encodeURIComponent(question.value) |
消息中的 &、#、中文等会破坏 URL 结构 |
添加并发锁 :用 isLoading 标志防止重复点击 |
避免同时发起多个请求,界面错乱 |
🟠 中优先级
| 优化点 | 原因 |
|---|---|
支持取消请求 :使用 AbortController + 停止按钮 |
用户可主动中断长时间生成,节省资源 |
响应式布局 :放弃硬编码 margin-left,改用 Flex/Grid |
适配不同屏幕尺寸 |
| 细化错误处理:区分网络错误、超时、用户取消等 | 给出明确提示,便于用户操作 |
| 超时控制:比如 30 秒无响应则中断并提示 | 避免永久等待 |
🟡 低优先级(增强功能)
| 优化点 | 原因 |
|---|---|
Markdown 渲染 :用 marked 库将回答转为 HTML |
AI 常返回代码块、列表,纯文本难阅读 |
| 复制回答按钮 | 方便用户保存内容 |
| 多轮对话界面:用消息数组渲染气泡 | 更像真实聊天记录 |
本地持久化会话 ID :用 localStorage 存储 memoryId |
不同会话互不干扰 |
六、完整示例(含自动滚动 + 取消请求 + 并发锁)
vue
<template>
<div class="chat-container">
<div class="input-area">
<el-input v-model="question" placeholder="请输入问题" @keyup.enter="ask" />
<el-button type="primary" @click="ask" :loading="isLoading">发送</el-button>
<el-button v-if="isLoading" @click="stopGeneration" type="warning">停止</el-button>
</div>
<el-input
ref="answerTextareaRef"
v-model="answer"
type="textarea"
:autosize="{ minRows: 5, maxRows: 15 }"
readonly
@scroll="onScroll"
/>
</div>
</template>
<script setup>
import { ref, nextTick, watch } from 'vue'
const question = ref('')
const answer = ref('')
const memoryId = ref('1')
const isLoading = ref(false)
let abortController = null
const answerTextareaRef = ref(null)
let isUserScrollingUp = false
const onScroll = () => {
const textareaEl = answerTextareaRef.value?.$el?.querySelector('textarea')
if (!textareaEl) return
const isAtBottom = (textareaEl.scrollHeight - textareaEl.scrollTop - textareaEl.clientHeight) < 10
isUserScrollingUp = !isAtBottom
}
const autoScrollToBottom = async () => {
await nextTick()
const textareaEl = answerTextareaRef.value?.$el?.querySelector('textarea')
if (!textareaEl) return
const isOverflow = textareaEl.scrollHeight > textareaEl.clientHeight
if (isOverflow && !isUserScrollingUp) {
textareaEl.scrollTop = textareaEl.scrollHeight
}
}
watch(answer, () => {
autoScrollToBottom()
})
const ask = async () => {
if (!question.value.trim() || isLoading.value) return
isLoading.value = true
isUserScrollingUp = false
answer.value = ''
if (abortController) abortController.abort()
abortController = new AbortController()
const url = `http://localhost:9000/api/chat/chat01?memoryId=${memoryId.value}&message=${encodeURIComponent(question.value)}`
try {
const response = await fetch(url, {
signal: abortController.signal,
headers: { 'Accept': 'text/html;charset=utf-8' }
})
if (!response.ok) throw new Error('请求失败')
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
answer.value += chunk
}
} catch (error) {
if (error.name === 'AbortError') {
answer.value = '已停止生成'
} else {
console.error(error)
answer.value = '出错了,请稍后重试'
}
} finally {
isLoading.value = false
abortController = null
}
}
const stopGeneration = () => {
if (abortController) {
abortController.abort()
abortController = null
isLoading.value = false
}
}
</script>
<style scoped>
.chat-container {
max-width: 900px;
margin: 20px auto;
padding: 0 20px;
}
.input-area {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.el-input { flex: 1; }
</style>
七、总结
流式调用是提升 AI 对话体验的关键技术,其核心在于前端通过 ReadableStream 分块读取后端实时生成的数据。然而仅有流式还不够,自动滚动、取消请求、错误处理等细节决定了产品的完成度。
本文提供的自动滚动方案充分考虑了用户行为(向上翻阅时不打扰),可直接应用到生产项目中。其他优化建议也可按需逐步实现。
希望这篇文章能帮助你打造一个流畅、友好的聊天机器人界面。如果你有更好的建议或疑问,欢迎在评论区交流!
喜欢本文的话,别忘了点赞、收藏、关注,你的支持是我更新的动力~