当前位置: 首页 > news >正文

前端实现 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>

原理大白话解读

  1. 后端配合:后端接口支持分块传输(Transfer-Encoding: chunked 或 SSE),生成一点就发一点。

  2. 前端逐块读取

    • fetch得到的response.body是一个ReadableStream(可读流)。

    • 调用getReader()拿到读取器,然后用while循环不断调用reader.read()

    • 每次取到一小块二进制数据(value),用TextDecoder解码成字符串,拼接到answer.value后面。

  3. 实时渲染:由于 Vue 是响应式的,每次拼接都会触发 DOM 更新,用户就看到文字一个一个蹦出来了。


三、痛点:内容超出可视区后需手动滚动

上面的代码虽然实现了流式输出,但有一个明显的体验问题:当回答内容超过文本框的可视高度时,新出现的文字不会自动滚动到底部,用户必须手动拖动滚动条

期望的行为是:

  • 内容未超出可视区 → 无需滚动

  • 内容超出可视区,且用户当前没有主动向上翻阅历史 → 自动滚动到底部,让用户始终看到最新的内容

  • 如果用户向上滚动查看旧内容 → 停止自动滚动,尊重用户的阅读位置


四、自动滚动的实现方案

4.1 关键步骤

  1. <el-input>添加ref,以便获取内部的<textarea>DOM 元素。

  2. 监听textareascroll事件,判断用户是否在底部附近。

  3. 使用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库将回答转为 HTMLAI 常返回代码块、列表,纯文本难阅读
复制回答按钮方便用户保存内容
多轮对话界面:用消息数组渲染气泡更像真实聊天记录
本地持久化会话 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分块读取后端实时生成的数据。然而仅有流式还不够,自动滚动、取消请求、错误处理等细节决定了产品的完成度。

本文提供的自动滚动方案充分考虑了用户行为(向上翻阅时不打扰),可直接应用到生产项目中。其他优化建议也可按需逐步实现。

希望这篇文章能帮助你打造一个流畅、友好的聊天机器人界面。如果你有更好的建议或疑问,欢迎在评论区交流!

喜欢本文的话,别忘了点赞、收藏、关注,你的支持是我更新的动力~

http://www.jsqmd.com/news/977739/

相关文章:

  • 2026成都别墅专项工程服务商评测:成都墅适美嘉科技有限公司联系、高端别墅西卡施工案例、高端别墅防水哪家好、高端地下室漏水修缮选择指南 - 优质品牌商家
  • 九大主流网盘直链下载工具LinkSwift:浏览器扩展级解决方案深度解析
  • 非标定制机械手厂家排行:安徽助力机械手/安徽助力机械臂/安徽助力臂/安徽平衡吊/安徽智能平衡吊/安徽智能提升机/选择指南 - 优质品牌商家
  • 2026年 双面胶推荐榜:无痕/PET/棉纸/耐高温/阻燃/高温柔性双面胶,胶纸/高温胶纸厂家优选推荐 - 品牌发掘
  • LinkSwift:基于JavaScript的网盘直链解析架构设计与技术实现深度解析
  • 权威认证再添实力背书!阳江融景科技斩获两项国标评定国家级一级行业资质,树立本地 GEO 优化合规标杆 - 广东科技观察
  • 告别静态数据!用ArcGIS Knowledge构建企业级时空知识图谱实战(附避坑指南)
  • 别再只盯着Shiro-550了:聊聊Shiro 1.5.1权限绕过后,如何利用Logback的JNDI注入漏洞(CVE-2019-14439)打内网
  • 开源项目发布流程中的链接同步陷阱:Balena Etcher 构建配置变更引发的404问题深度解析
  • 罗湖比亚迪4S店节假日营业吗? - myqiye
  • 如何3分钟快速完成Figma界面完整汉化?终极免费指南
  • Zotero-Style插件:科研文献管理的视觉化革命
  • 3个革命性突破:让8GB显存设备也能生成专业级AI视频
  • 2026年PDF转Excel免费工具实测:表格线、公式全保留,财务/数据分析师必存 - 时时资讯
  • 第七史诗自动化助手E7Helper:24小时解放双手的游戏管家
  • 非药物疗法迎来政策风口,汤头APP用AI技术托举全民普及
  • 免费开源图片去重工具:3步清理重复图片,释放硬盘空间终极指南
  • 别再死记硬背了!用‘棋盘与米粒’的故事和Python代码,5分钟搞懂二叉树查找为啥这么快
  • AI推荐时代618制胜攻略!携手好客搜GEO优化,靠谱产品+优质服务稳赢大促
  • 【JAVA毕设源码分享】基于vue和springboot的学生信息管理系统(程序+文档+代码讲解+一条龙定制)
  • 从淘宝买来的BC547三极管,实测竟有25%是坏的?手把手教你用晶体管测试模块避坑
  • 燕郊镇空调维修优质厂家如何选购? - myqiye
  • BBDown终极指南:快速下载B站视频的完整解决方案
  • Qwerty Learner:终极英语肌肉记忆训练与键盘输入效率提升完整指南
  • 3分钟实现零依赖RTSP视频流Web化:革命性的实时视频转换方案
  • # 2026 年 PDF 转 PPT 免费教程:3 步搞定汇报素材,排版不崩字体不乱 - 时时资讯
  • QML 进阶第二课:利用 Loader 实现高性能的“动态加载”
  • 终极方舟启动器:TEKLauncher一站式解决MOD管理与服务器搭建难题
  • 别再只盯着Shiro-550/721了:聊聊Logback JNDI注入(CVE-2019-14439)在混合漏洞中的利用
  • OpenClaw赚钱实录:从“养龙虾“到可持续变现的实践指南——OpenClaw安全部署实战:从裸奔到铁桶,成本封顶+防注入全搞定