前端实现 AI 聊天流式输出(打字机效果)及自动滚动优化
摘要
本文详细介绍如何通过 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分块读取后端实时生成的数据。然而仅有流式还不够,自动滚动、取消请求、错误处理等细节决定了产品的完成度。
本文提供的自动滚动方案充分考虑了用户行为(向上翻阅时不打扰),可直接应用到生产项目中。其他优化建议也可按需逐步实现。
希望这篇文章能帮助你打造一个流畅、友好的聊天机器人界面。如果你有更好的建议或疑问,欢迎在评论区交流!
喜欢本文的话,别忘了点赞、收藏、关注,你的支持是我更新的动力~
