前端打字机效果:流式输出从0到1手把手教学
2026/6/8 23:21:41 网站建设 项目流程

摘要

本文介绍了通过前端调用 AI 聊天接口的两种方式:正常调用(一次性返回完整回答)与流式调用(逐字输出)。首先展示了在浏览器地址栏直接测试接口的效果。然后分别给出基于 Vue 3 + Element Plus 的代码实现:正常调用通过封装的chat接口一次性获取答案;流式调用则使用fetch+ReadableStream逐块读取后端返回的数据,并实时拼接到页面,实现“打字机”效果。文章总结了流式调用的核心原理——后端分块生成、前端循环读取并累加显示。最后对比了两者的优缺点,并提供了后续优化建议,涵盖界面布局、并发控制、错误处理、请求取消、Markdown 渲染等方向,为开发者构建更流畅的聊天交互提供了实用参考。

目录

一. 目前已有的东西

二.通过前端调用该接口

1.正常调用

优点

缺点

2.流式调用

优点

缺点

后续优化建议


一. 目前已有的东西

下面我们直接在浏览器的地址栏调用一下这个接口,效果如下

二.通过前端调用该接口

1.正常调用

<template> <el-input v-model="question" style="width: 200px;margin-left:700px" placeholder="请输入问题" /> <el-button type="primary" @click="ask">发送</el-button> <br> <el-input v-model="answer" type="textarea" :autosize="{ minRows: 3, maxRows: 10 }" style="width: 800px;margin-left:450px" placeholder="回答" /> </template> <script setup> import { ref } from 'vue' import { chat } from '@/api/chatApi'; //变量1:用户的提问 const question = ref('') //变量2:后端接口返回的答案 const answer = ref('') //变量3:入参 const dto = ref( { 'memoryId':1, 'message':'' } ) //方法1:调用后端接口,获得回答 const ask = async()=>{ dto.value.message = question.value; const resp = await chat(dto.value); answer.value = resp; } </script> <style scoped> /* 这里可以添加组件专用的样式 */ /* scoped 属性确保样式只作用于当前组件 */ </style>

效果测试:

优点

  • 实现极其简单– 后端只要正常返回完整 JSON 或文本,前端await response.json()response.text()一行代码就能拿到全部数据。

  • 调试方便– 抓包看到一个完整响应,很直观;不需要处理碎片拼接。

  • 数据完整性好– 不会出现半个字符、断包的问题,天然就是完整的。

  • 适用于短小数据– 如果结果长度只有几个字符,一次性返回反而更干净。

缺点

  • 用户等待时间长– 比如 AI 生成 1000 字需要 10 秒,用户只能盯着空白屏幕或转圈圈,容易烦躁甚至关掉页面。

  • 内存占用高– 后端要先把 1000 字全部存到内存,再一次性发送;前端也要等全部收到才能显示,大响应会消耗更多内存。

  • 无法提前反馈– 即使用户看到前半段已经觉得不对,也不能取消,因为后端还在默默生成后半段。

  • 超时风险– 如果生成时间过长,网关或浏览器可能触发超时断开连接,导致全部白费。

2.流式调用

<template> <el-input v-model="question" style="width: 200px;margin-left:700px" placeholder="请输入问题" /> <el-button type="primary" @click="ask">发送</el-button> <br> <el-input v-model="answer" type="textarea" :autosize="{ minRows: 3, maxRows: 10 }" style="width: 800px;margin-left:450px" placeholder="回答" /> </template> <script setup> import { ref } from 'vue' const question = ref('') const answer = ref('') const memoryId = ref('1') // 你的记忆ID //点击发送按钮时执行的函数 const ask = async () => { //如果问题为空或者只有空格,就不发送 if (!question.value.trim()) return //清空上一次的回答 answer.value = '' //构建请求 URL(注意你的后端路径是 /api/chat/chat01) const url = `http://localhost:9000/api/chat/chat01?memoryId=${memoryId.value}&message=${question.value}` try { //使用 fetch 发送 GET 请求,注意后端返回的是流(stream) const response = await fetch(url, { method: 'GET', headers: { 'Accept': 'text/html;charset=utf-8'// 告诉后端我接受UTF-8文本 } }) //如果HTTP状态码不是200之类,就报错 if (!response.ok) throw new Error('请求失败') // ★★★ 流式输出的核心原理开始(记这一段代码逻辑即可) ★★★ // response.body 是一个 ReadableStream(可读流),类似一个水管 // getReader() 拿到这个水管的“读取器”,可以一点一点从水管里取水(数据) const reader = response.body.getReader() // 创建文本解码器,把后端传过来的二进制数据(比如UTF-8编码的字节)转成字符串 const decoder = new TextDecoder('utf-8') // 无限循环,直到所有数据都被读完 while (true) { // 从流中读取一块数据,done表示是否已经读完,value是这一块的二进制数据(Uint8Array) const { done, value } = await reader.read() // 如果后端传完了所有数据,就退出循环 if (done) break // 把这一小块二进制数据解码成字符串(stream: true 表示可能还没结束,中间可能会有不完整的字符,解码器会处理) const chunk = decoder.decode(value, { stream: true }) // 把这一小块字符串拼接到已有的 answer 后面 —— 这就是“流式输出”的效果: // 后端每吐一个字,前端的文本框就多一个字,看起来就像打字机一样 answer.value += chunk } } catch (error) { console.error(error) answer.value = '出错了,请稍后重试' } } </script> <style scoped> /* 这里可以添加组件专用的样式 */ /* scoped 属性确保样式只作用于当前组件 */ </style>

效果测试:

原理总结:

  • 1. 后端不是一次性把全部回答算完再返回,而是生成一点就发送一点(比如每生成一个词就发一次)
  • 2. 前端 fetch 接收到这种分块传输的数据,通过 reader.read() 一次次拿取每个数据块
  • 3. 每次拿到一个小块就立刻显示到页面上,不断累加,用户就看到文字逐渐出现
  • 4. 如果后端是 AI 模型(比如 ChatGPT),通常就是用这种方式实现“逐字输出”

优点

  • 用户体验好– 不用干等十几秒,能看到文字“一个字一个字往外蹦”,感觉系统在实时思考,心里有底。

  • 减少焦虑– 如果内容很长,一次性等太久用户可能以为程序卡死了;流式输出能让用户知道“还在工作中”。

  • 可中途取消– 如果发现回答不对,用户可以提前中断请求,节省带宽和计算资源。

  • 内存占用低– 前端不用等整个巨大响应全部到齐再渲染,后端的生成结果也可以边产生边发送,不占用大量内存。

缺点

  • 实现复杂– 后端需要支持分块传输(比如 Server-Sent Events 或直接写流),前端要用ReadableStream反复读取,还要处理断网、错误重试等。

  • 网络开销稍大– 每个小块都有一些额外的 HTTP 分块头部,总数据量会比一次性输出略大一点点(但通常可忽略)。

  • 不适合所有场景– 如果数据本来就很短(比如几十个字),流式输出的优势不明显,反而增加复杂度。

后续优化建议

①界面设置优化

  • 放弃硬编码margin-left,改用 Flex/Grid 实现响应式居中,并限制容器最大宽度(如 900px)
    (原因:固定像素偏移在不同屏幕尺寸下会错位,Flex/Grid 能自适应各种设备,提升界面兼容性)

  • 增加“停止生成”按钮,配合AbortController让用户能主动中断流式输出
    (原因:流式输出可能耗时较长,用户若发现回答偏离预期或等待不耐烦,应能主动取消,避免资源浪费和不良体验)

  • 发送后清空输入框,并在请求期间禁用发送按钮,避免并发重复请求
    (原因:连续点击会同时发起多个请求,造成前端显示错乱、后端压力增大;禁用按钮是标准的交互保护)

  • 当回答内容超过可视区域时,自动滚动到最新输出位置(使用nextTick操作滚动条)
    (原因:流式输出时用户通常关注最新出现的文字,自动滚动可省去手动下拉的麻烦,符合阅读习惯)

  • 增加“复制回答”按钮,方便用户一键获取内容
    (原因:用户常需要将 AI 的回答粘贴到其他地方,提供复制按钮能减少选中和拷贝的操作成本)

②代码逻辑优化

  • 修复 URL 参数未编码的严重 bug:必须用encodeURIComponent(question.value)包裹消息内容
    (原因:用户消息中可能包含&#、空格、中文等特殊字符,直接拼接到 URL 会破坏参数结构,导致后端接收到错误的消息;encodeURIComponent可将这些字符转为安全的百分号编码)

  • 添加isLoading锁标志,防止用户在请求进行中再次点击发送
    (原因:未加锁时,用户多次点击会创建多个并发请求,后面返回的数据会互相覆盖显示区域,且后端同一 memoryId 可能被打乱上下文)

  • 引入AbortController实现真正的请求取消,并在组件卸载(onUnmounted)时自动 abort
    (原因:用户切换页面或关闭组件时,应立刻断开网络连接,避免已卸载的组件尝试更新状态导致内存泄漏或控制台报错)

  • 增加超时控制(如 30 秒),避免因后端无响应而永久等待
    (原因:网络抖动或后端服务故障可能导致 fetch 永不完成,用户界面会一直处于加载中状态;超时后可给出明确提示并重置 UI)

  • 细化错误处理:区分网络错误、HTTP 错误、用户主动取消、超时等场景,给出不同提示
    (原因:不同的错误原因需要不同的用户反馈(例如“网络断开” vs “已停止生成”),笼统的“出错了”不利于用户判断下一步操作)

  • 流式数据格式适配:如果后端返回的不是纯文本(如 SSE 格式的data: {...}),需要按约定解析再拼接
    (原因:后端可能返回标准 SSE 事件流或 JSON 分块,直接拼接原始字符串会导致显示多余格式字符,需要前端根据协议提取实际文本内容)

③功能扩展建议

  • 支持多轮对话:将消息存储为数组(messages),渲染对话气泡,并利用memoryId维持后端上下文
    (原因:单 answer 字段每次提问会覆盖上一轮回答,无法形成对话历史;数组结构可完整展示用户与 AI 的交流过程)

  • 对回答内容进行 Markdown 渲染(如使用marked库),提升代码块、列表等内容的可读性(注意 XSS 防护)
    (原因:AI 生成的回答常含 Markdown 语法,纯文本显示会暴露原始标记符号,渲染后更符合阅读习惯且支持语法高亮)

  • 本地持久化记忆 ID:用localStorage生成或获取唯一sessionId,允许用户手动“新建会话”
    (原因:固定 memoryId 会导致所有用户和所有对话共用同一个上下文,互相干扰;为每个会话分配独立 ID 可隔离不同对话)

  • 加入自动重试机制:网络临时故障时自动重试 1~2 次,提升稳定性
    (原因:短暂的网络波动或后端重启可能导致第一个数据块失败,自动重试能在不打扰用户的情况下恢复请求,提高成功率)

④优先级参考

  • 🔴 高优先级:URL 编码、并发锁(防止重复请求)
    (原因:这两项直接影响功能的正确性和稳定性,不修复会导致请求错乱或界面卡死)

  • 🟠 中优先级:取消请求、响应式布局、加载状态、错误细化
    (原因:显著提升用户体验和代码健壮性,但不是最紧急的致命问题)

  • 🟡 低优先级:Markdown 渲染、复制按钮、多轮对话界面、重试机制
    (原因:属于增强型功能,可在核心功能稳定后逐步添加)

以上就是本篇文章的全部内容,喜欢的话可以留个免费的关注呦~~~

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询