前端实现 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 库将回答转为 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 分块读取后端实时生成的数据。然而仅有流式还不够,自动滚动、取消请求、错误处理等细节决定了产品的完成度。

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

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

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