☰
前端流式AI对话Demo:从Fetch到ReadableStream的实战指南
2026/10/14 2:11:11 网站建设 项目流程

1. 这不是“转行”,是前端能力的自然延伸——为什么一个流式对话Demo是AI入门最务实的起点

“前端转AI程序员”这个说法最近在技术社区里被反复提起,但说实话,我听到时第一反应是皱眉。不是因为它错,而是因为它太容易让人误解——仿佛要扔掉Vue和React,从头啃《深度学习》教材,再手推反向传播公式才算入了门。这完全背离了真实的技术演进路径。真正发生在我身边、也在我自己身上的是:一个熟练使用Fetch API处理REST接口的前端工程师,在某天把fetch('/api/chat')换成fetch('/api/chat/stream'),加上几行ReadableStream解析逻辑,再用<div>逐字渲染响应内容,就跑通了第一个带打字机效果的AI对话界面。整个过程不到两小时,没碰PyTorch,没配CUDA,甚至没装Python环境。

这个标题里的“第一步”,核心不在“AI”,而在“流式”二字。它精准地卡在了前端工程师最熟悉的舒适区边缘:你早已习惯处理异步数据(Promise、async/await),你天天跟DOM更新打交道(虚拟DOM、diff算法、防抖节流),你对用户交互节奏有本能直觉(加载状态、骨架屏、输入反馈)。而AI流式响应,本质上就是一种特殊形态的异步数据流——它不像传统API那样“一锤定音”返回JSON,而是像拧开一个水龙头,字符、单词、句子持续不断地涌出。你的任务,是把这股“水”接住、分段、实时呈现,同时保证滚动位置合理、光标不跳、输入框不卡顿。这恰恰是你每天都在解决的问题。

所以,这个Demo的价值,根本不是为了证明你能调通大模型API,而是为你建立一套可复用的“AI交互心智模型”:请求怎么发、响应怎么接、错误怎么兜底、UI怎么配合节奏、性能瓶颈在哪。它像一把钥匙,打开了后续所有AI集成场景的大门——智能表单自动补全、代码编辑器实时建议、文档摘要侧边栏、甚至未来可能的语音转文字实时流。我见过太多人卡在“不知道从哪开始”的焦虑里,反复看Transformer论文却写不出一行能跑的代码;也见过更多人,用这个流式Demo作为支点,两周内就给公司内部工具加进了实用的AI辅助功能。它不宏大,但足够真实;不炫技,但直击痛点。如果你现在打开编辑器,5分钟内就能让页面上出现“AI正在思考…”然后逐字打出答案,那种确定性带来的信心,远比读十篇综述都管用。

2. 核心设计思路拆解:为什么放弃WebSocket,死磕Fetch+ReadableStream

很多人看到“流式对话”,第一反应是WebSocket。毕竟实时、双向、长连接,听起来就很“AI”。但我必须坦白:在绝大多数AI对话Demo场景下,WebSocket是过度设计,甚至是陷阱。这不是技术偏见,而是基于三个硬核事实的权衡结果。

第一,服务端成本与复杂度。主流大模型API(无论是开源模型部署还是云厂商托管服务)提供的流式接口,99%都是基于HTTP/1.1或HTTP/2的Chunked Transfer Encoding。服务器只需按需生成token,通过Transfer-Encoding: chunked分块推送,无需维护长连接状态。而WebSocket要求服务端为每个用户维持一个独立连接,内存占用高、超时管理复杂、负载均衡难度陡增。某次我们团队在压测时发现,当并发连接数超过500,WebSocket服务的内存泄漏问题就开始暴露,而同等流量下,HTTP流式接口的资源消耗几乎恒定。对一个Demo而言,你不想第一天就陷入连接池配置和心跳保活的泥潭。

第二,前端兼容性与调试体验。fetch+ReadableStream是标准Web API,Chrome、Firefox、Safari(16.4+)、Edge全线支持,且调试极其直观:打开DevTools Network面板,直接看到每个chunk的原始内容、时间戳、大小。而WebSocket调试需要专门的WS面板,消息格式往往是二进制或自定义JSON,抓包分析成本高。更关键的是,当你在本地开发时,fetch可以无缝对接Vite/HMR热更新,修改代码后刷新页面即可重试;WebSocket则常因连接未正确关闭导致状态混乱,需要手动清理或重启服务。

第三,语义清晰与错误处理。HTTP流式响应天然携带状态码(如429限流、401鉴权失败、503服务不可用),前端可直接捕获并给出精准提示。WebSocket则需要约定一套应用层错误码,错误边界模糊——是连接断了?是消息格式错了?还是模型推理超时?排查起来像在迷宫里找路。而fetch的AbortController机制,让你能优雅地取消一个正在进行的流式请求,这对用户点击“停止生成”按钮的场景至关重要,实现起来就三行代码。

当然,WebSocket并非一无是处。如果你要做多轮对话中服务端主动推送(比如AI突然中断回答,发来一个“需要调用外部工具”的指令),或者需要极低延迟的双向实时协作(多人共同编辑AI生成内容),那WebSocket才是正解。但对“第一步”的Demo,它的价值远小于引入的复杂度。我建议你把WebSocket当作“下一步”去探索,而不是“第一步”的绊脚石。真正的高手,不是工具用得最多,而是总能选对那个刚刚好够用的工具。

3. 核心细节解析:从零构建流式响应处理器的7个关键环节

一个能稳定运行的流式对话Demo,表面看只是几行JS,背后却藏着7个必须亲手打磨的关键环节。这些环节环环相扣,任何一个疏忽都会导致“卡顿”、“乱码”、“截断”或“内存暴涨”。下面我带你逐个拆解,每一步都附上实操中的血泪教训。

3.1 请求发起:Headers与Body的精确控制

流式请求不是简单加个stream=true参数就完事。首先,Content-Type必须明确指定为application/json,避免某些网关或代理因类型不明而缓冲整个响应。其次,Accept头要设为text/event-stream或application/json,取决于后端约定;但更通用的做法是留空,让服务端根据Content-Type自行判断。最关键的是Cache-Control: no-cache,这是防止浏览器或中间代理缓存chunked响应的铁律——我曾在一个企业内网环境踩坑,因为Nginx默认启用了proxy_buffering,导致首屏渲染延迟高达8秒,最后加了这行头才解决。

const response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache', // 如果需要鉴权,Bearer Token放这里 // 'Authorization': `Bearer ${apiKey}` }, body: JSON.stringify({ messages: [{ role: 'user', content: userInput }], stream: true // 后端识别流式的关键flag }) });

提示:永远不要在body里传stream: "true"字符串,后端解析时易出错。布尔值true是标准做法。

3.2 响应校验:Status Code与Content-Type的双重守门

拿到Response对象后,第一件事不是急着读流,而是做双重校验。response.ok只能判断2xx状态码,但AI服务常返回429(限流)、400(参数错误)、500(模型崩溃)等非2xx成功码。必须显式检查response.status:

if (!response.ok) { const errorData = await response.json(); throw new Error(`API Error ${response.status}: ${errorData.message || 'Unknown'}`); }

更隐蔽的坑在Content-Type。理想情况是text/event-stream,但很多后端(尤其是FastAPI、Flask)默认返回application/json。如果后端没做特殊处理,response.body.getReader()依然能工作,但你需要自己解析JSON Lines格式(每行一个JSON对象)。务必在代码里打印response.headers.get('content-type')确认,否则后续解析逻辑会全盘错乱。

3.3 流读取:ReadableStream与TextDecoder的协同作战

response.body是一个ReadableStream,但它输出的是Uint8Array字节流,不是字符串。直接decoder.decode(chunk)会乱码。必须用TextDecoder进行UTF-8解码,且要注意decode()的stream参数:

const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; // 关键:stream: true 表示数据可能不完整,需累积到buffer buffer += decoder.decode(value, { stream: true }); // 按换行符分割完整行(SSE格式)或按JSON行分割 const lines = buffer.split('\n'); // 保留最后一行(可能不完整)到下次循环 buffer = lines.pop() || ''; for (const line of lines) { if (line.trim() === '') continue; processLine(line); // 解析单行 } }

注意:decoder.decode(value, { stream: true })中的stream: true是核心。它告诉解码器:“这段字节可能不是完整的UTF-8字符,请别急着报错,等下一批数据来一起解”。没有它,遇到跨字节的中文字符就会抛DOMException。

3.4 行解析:SSE vs JSON Lines的识别与容错

AI流式响应主要有两种格式:Server-Sent Events(SSE)和JSON Lines。SSE以data: {...}开头,每行一个字段;JSON Lines则是每行一个独立JSON对象。你的解析器必须能自动识别并兼容两者。

function processLine(line) { // 先尝试SSE格式:data: {"delta": {"content": "hi"}} if (line.startsWith('data: ')) { const jsonStr = line.slice(6).trim(); if (jsonStr && jsonStr !== '[DONE]') { try { const data = JSON.parse(jsonStr); appendToChat(data.delta?.content || ''); } catch (e) { console.warn('SSE parse failed:', line); } } } // 再尝试JSON Lines格式:{"choices":[{"delta":{"content":"hi"}}]} else { try { const data = JSON.parse(line); const content = data.choices?.[0]?.delta?.content; if (content !== undefined) { appendToChat(content); } } catch (e) { console.warn('JSON Lines parse failed:', line); } } }

实操心得:永远用try...catch包裹JSON.parse()。模型输出偶尔会包含非法字符(如未转义的换行符),不加防护会导致整个流中断。日志里记下失败行,方便后续分析模型输出质量。

3.5 UI更新:虚拟DOM的最小化重绘策略

逐字渲染听起来简单,但直接element.innerHTML += char是性能杀手。每次赋值都会触发浏览器重排重绘,当每秒涌入20+字符时,页面会明显卡顿。正确姿势是批量更新:

let pendingContent = ''; let updateTimer = null; function appendToChat(char) { pendingContent += char; // 防抖:16ms内只更新一次(约60fps) if (updateTimer) clearTimeout(updateTimer); updateTimer = setTimeout(() => { chatElement.textContent += pendingContent; pendingContent = ''; // 强制滚动到底部,但避免频繁调用 chatElement.scrollTop = chatElement.scrollHeight; }, 16); }

更进一步,可以用requestIdleCallback在浏览器空闲时更新,或结合IntersectionObserver只在消息区域可见时渲染。但对于Demo,上述防抖已足够。

3.6 错误兜底:AbortController与网络异常的优雅降级

用户点击“停止”按钮时,不能只停UI,必须终止网络请求,否则后台还在浪费算力。AbortController是唯一标准方案:

const controller = new AbortController(); // 发起请求时传入 const response = await fetch('/api/chat', { signal: controller.signal, // ...其他配置 }); // 停止按钮点击事件 stopButton.addEventListener('click', () => { controller.abort(); // 立即终止fetch setIsLoading(false); });

同时,必须监听AbortError并静默处理,避免弹出无关错误提示。网络异常(如断网)则需在fetch外层加try...catch,并提供“重试”按钮,而不是让用户面对白屏。

3.7 内存管理:避免闭包引用导致的流式数据堆积

这是最容易被忽视的深坑。reader.read()返回的value是Uint8Array,如果在processLine中不小心把它存进某个全局数组或闭包变量,GC无法回收,内存会随对话时间线性增长。我曾在线上环境监控到单个页面内存占用突破1GB,根源就是一段调试代码把所有chunk都console.log()了——console.log会持有对象引用!

解决方案只有两条:一是严格审查所有变量作用域,确保value只在当前循环内使用;二是定期清理DOM中不再需要的旧消息节点(比如只保留最近10轮对话),用chatElement.removeChild(oldNode)而非innerHTML = ''。

4. 完整实操流程:从创建项目到部署上线的12个步骤

现在,让我们把前面所有细节串起来,走一遍可立即复现的完整流程。我以Vite + React为例(Vue用户可自行替换对应语法),所有步骤均经过实测,耗时约45分钟。

4.1 初始化项目与依赖安装

# 创建Vite项目 npm create vite@latest ai-chat-demo -- --template react cd ai-chat-demo npm install # 安装关键依赖(仅用于类型定义和工具) npm install -D @types/web

注意:不需要安装任何AI SDK!纯原生Fetch即可。@types/web提供ReadableStream等新API的TypeScript定义,避免TS报错。

4.2 创建基础UI组件:ChatInterface.jsx

import { useState, useRef, useEffect } from 'react'; export default function ChatInterface() { const [messages, setMessages] = useState([]); const [inputValue, setInputValue] = useState(''); const [isLoading, setIsLoading] = useState(false); const messagesEndRef = useRef(null); const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }; useEffect(scrollToBottom, [messages]); const handleSubmit = async (e) => { e.preventDefault(); if (!inputValue.trim() || isLoading) return; // 添加用户消息 const userMessage = { role: 'user', content: inputValue }; setMessages(prev => [...prev, userMessage]); setInputValue(''); setIsLoading(true); try { // 调用流式API await streamChat(inputValue, setMessages); } catch (error) { setMessages(prev => [ ...prev, { role: 'assistant', content: `❌ 请求失败: ${error.message}` } ]); } finally { setIsLoading(false); } }; return ( <div className="flex flex-col h-screen max-w-4xl mx-auto p-4"> <div className="flex-1 overflow-y-auto mb-4 space-y-4"> {messages.map((msg, i) => ( <div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}> <div className={`max-w-[80%] rounded-lg px-4 py-2 ${ msg.role === 'user' ? 'bg-blue-500 text-white rounded-tr-none' : 'bg-gray-200 text-gray-800 rounded-tl-none' }`}> {msg.content} </div> </div> ))} {isLoading && ( <div className="flex justify-start"> <div className="bg-gray-200 text-gray-800 rounded-lg rounded-tl-none px-4 py-2"> <span className="animate-pulse">AI正在思考…</span> </div> </div> )} <div ref={messagesEndRef} /> </div> <form onSubmit={handleSubmit} className="flex gap-2"> <input type="text" value={inputValue} onChange={(e) => setInputValue(e.target.value)} placeholder="输入问题..." className="flex-1 border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" disabled={isLoading} /> <button type="submit" disabled={isLoading || !inputValue.trim()} className="bg-blue-500 text-white rounded-lg px-6 py-2 hover:bg-blue-600 disabled:opacity-50" > 发送 </button> </form> </div> ); }

4.3 编写核心流式处理函数:streamChat.js

// src/utils/streamChat.js export async function streamChat(userInput, updateMessages) { const controller = new AbortController(); try { const response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache', }, body: JSON.stringify({ messages: [{ role: 'user', content: userInput }], stream: true }), signal: controller.signal }); if (!response.ok) { const errorData = await response.json(); throw new Error(`${response.status}: ${errorData.message || 'Request failed'}`); } const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; let assistantMessage = { role: 'assistant', content: '' }; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { if (!line.trim()) continue; // 统一解析逻辑:兼容SSE和JSON Lines let content = null; // 尝试SSE if (line.startsWith('data: ')) { const jsonStr = line.slice(6).trim(); if (jsonStr && jsonStr !== '[DONE]') { try { const data = JSON.parse(jsonStr); content = data.delta?.content; } catch (e) {} } } // 尝试JSON Lines else { try { const data = JSON.parse(line); content = data.choices?.[0]?.delta?.content; } catch (e) {} } if (content !== null && content !== undefined) { assistantMessage.content += content; // 批量更新UI,避免高频重绘 updateMessages(prev => { const newMessages = [...prev]; const lastMsg = newMessages[newMessages.length - 1]; if (lastMsg?.role === 'assistant') { newMessages[newMessages.length - 1] = assistantMessage; } else { newMessages.push(assistantMessage); } return newMessages; }); } } } } catch (error) { if (error.name === 'AbortError') { console.log('Stream aborted by user'); return; } throw error; } finally { reader.releaseLock(); } }

4.4 配置Vite代理:绕过CORS,直连后端

在vite.config.js中添加代理,将/api/chat转发到你的AI后端(假设运行在http://localhost:8000):

import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], server: { proxy: { '/api': { target: 'http://localhost:8000', // 替换为你的后端地址 changeOrigin: true, secure: false, rewrite: (path) => path.replace(/^\/api/, '') } } } });

提示:生产环境必须用Nginx反向代理,禁止前端直接暴露后端地址。此处仅为开发便利。

4.5 启动前端与后端

# 启动前端 npm run dev # 启动后端(以FastAPI为例) # pip install fastapi uvicorn python-multipart # 创建 main.py from fastapi import FastAPI, Request, Response from fastapi.responses import StreamingResponse import asyncio import json app = FastAPI() @app.post("/chat") async def chat(request: Request): data = await request.json() user_input = data["messages"][-1]["content"] # 模拟流式响应 async def stream_generator(): words = ["Hello", ", ", "I", " ", "am", " ", "an", " ", "AI", "."] for word in words: await asyncio.sleep(0.3) # 模拟模型推理延迟 chunk = { "choices": [{"delta": {"content": word}}] } yield f"data: {json.dumps(chunk)}\n\n" yield "data: [DONE]\n\n" return StreamingResponse( stream_generator(), media_type="text/event-stream", headers={"Cache-Control": "no-cache"} )
# 启动后端 uvicorn main:app --reload --port 8000

4.6 测试与验证:关键检查点清单

启动后,打开浏览器,执行以下验证:

  1. Network面板检查:发送消息后,在Network中找到/api/chat请求,确认:

    • Status Code为200
    • Response Headers包含content-type: text/event-stream或application/json
    • Response Body中能看到连续的data: {...}或JSON行,且有换行符分隔
  2. Console日志检查:打开DevTools Console,确认无Uncaught (in promise) TypeError等解析错误

  3. UI行为检查:

    • 输入框在发送后立即禁用
    • “AI正在思考…”提示出现
    • 答案逐字/逐词出现,无跳动、无闪烁
    • 滚动条自动跟随最新消息
    • 点击“停止”按钮后,UI停止更新,且Network中请求状态变为(canceled)
  4. 边界测试:

    • 连续快速点击发送(应防抖,不重复请求)
    • 输入超长文本(应正常流式返回,不卡死)
    • 断网后重连(应有友好错误提示)

4.7 生产环境构建与部署

# 构建静态文件 npm run build # 部署到任意静态托管服务(Vercel、Netlify、Nginx) # 以Nginx为例,配置反向代理 # /etc/nginx/conf.d/ai-chat.conf server { listen 80; server_name your-domain.com; location / { root /var/www/ai-chat-demo/dist; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://localhost:8000/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; # 关键:禁用缓冲,确保流式传输 proxy_buffering off; proxy_cache off; } }

注意:Nginx必须设置proxy_buffering off,否则会缓存整个响应再吐给前端,彻底破坏流式效果。

4.8 性能优化:Lighthouse评分提升技巧

运行Lighthouse审计,重点关注Performance分数。针对流式场景,可做三项优化:

  1. 预连接DNS:在index.html的<head>中添加

    <link rel="preconnect" href="http://your-backend.com">
  2. 字体加载优化:避免@font-face阻塞渲染,用font-display: swap

  3. 代码分割:将streamChat函数动态导入,减少首屏JS体积

    const handleSubmit = async (e) => { e.preventDefault(); // ...省略 const { streamChat } = await import('./utils/streamChat'); await streamChat(inputValue, setMessages); };

4.9 安全加固:防止XSS与滥用

流式响应内容直接插入DOM是XSS高危区。必须对content做HTML转义:

function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // 在appendToChat中调用 chatElement.innerHTML += escapeHtml(content);

同时,后端必须限制单次请求最大token数、设置IP限流(如100次/小时),并在响应头添加X-Content-Type-Options: nosniff。

4.10 日志与监控:前端埋点实践

在streamChat函数中加入轻量日志:

// 记录请求耗时 const startTime = Date.now(); // ...流式处理... const duration = Date.now() - startTime; console.log(`[AI Stream] Duration: ${duration}ms, Tokens: ${assistantMessage.content.length}`); // 上报错误(简易版) if (error) { fetch('/api/log', { method: 'POST', body: JSON.stringify({ type: 'stream_error', message: error.message, timestamp: new Date().toISOString() }) }); }

4.11 可访问性(a11y)增强:屏幕阅读器支持

为流式更新的内容添加ARIA属性:

<div aria-live="polite" aria-label={`AI回复:${assistantMessage.content}`} className="sr-only" > {assistantMessage.content} </div>

aria-live="polite"确保屏幕阅读器在空闲时播报新内容,不打断用户当前操作。

4.12 后续扩展路线图:从Demo到产品

完成这个Demo后,你的能力地图已经打开。下一步可按优先级推进:

方向关键动作技术要点
多轮对话维护messages数组,每次请求携带全部历史注意token长度限制,需做历史压缩
模型切换前端提供下拉菜单,请求时传model: 'gpt-4'后端路由分发,不同模型不同endpoint
上下文感知解析用户消息中的文件上传,提取文本前端File API + 后端PDF/DOCX解析
插件调用当AI回复含{"tool_call": "weather"}时,前端触发对应API定义工具Schema,前端解析并执行
离线缓存用Workbox缓存常用问答对Service Worker + Cache API

记住,每一个扩展点,都始于你今天写的这几十行流式处理代码。它不是终点,而是你AI工程化能力的发射台。

5. 常见问题与排查技巧实录:那些让我熬夜到凌晨的Bug

在带新人做这个Demo时,我整理了一份高频问题速查表。这些问题看似琐碎,但每一个都曾让我在深夜对着控制台抓狂。分享出来,帮你绕过这些坑。

5.1 问题:页面卡死,CPU飙升至100%

现象:发送消息后,浏览器无响应,任务管理器显示该标签页CPU占满。

排查思路:

  • 打开DevTools Performance面板,录制一段卡顿时的操作,查看火焰图中哪个函数占用最高
  • 检查是否在processLine中意外创建了无限循环(如while (true)没break)
  • 检查buffer变量是否在每次循环后被正确清空

根因与修复: 最常见的原因是buffer处理逻辑错误。例如:

// ❌ 错误:没有pop出最后一行,buffer越积越多 const lines = buffer.split('\n'); for (const line of lines) { /* ... */ } // lines包含不完整行,buffer未更新 // ✅ 正确:pop出最后一行并赋值回buffer const lines = buffer.split('\n'); buffer = lines.pop() || ''; // 关键! for (const line of lines) { /* ... */ }

5.2 问题:中文显示为乱码(如我是AI)

现象:AI回复的中文变成一堆看不懂的符号。

排查思路:

  • console.log(decoder.decode(value))打印原始解码结果
  • 检查response.headers.get('content-type')是否包含charset=utf-8
  • 查看后端返回的原始字节流(用curl -v)

根因与修复: 根本原因是TextDecoder未指定编码或stream: true缺失。必须确保:

const decoder = new TextDecoder('utf-8'); // 显式指定utf-8 // ... decoder.decode(value, { stream: true }); // stream: true不能少

如果后端返回的Content-Type是text/plain,需强制用new TextDecoder('utf-8'),不能依赖header。

5.3 问题:响应中途停止,只显示前几个字

现象:AI只输出“你好”,然后戛然而止,Network中请求状态为finished但无错误。

排查思路:

  • 检查后端日志,是否模型推理抛出异常后静默退出
  • 在reader.read()后打印done和value.length
  • 检查buffer是否在最后一次循环中还有残留

根因与修复: 通常是后端未正确发送[DONE]标记,或前端未处理done === true后的buffer残留。修复如下:

while (true) { const { done, value } = await reader.read(); if (done) { // 处理buffer中剩余的最后一行 if (buffer.trim()) { processLine(buffer); } break; } // ...正常处理 }

5.4 问题:滚动条不自动到底部,新消息被遮挡

现象:消息追加后,需要手动滚动才能看到最新内容。

排查思路:

  • 检查scrollIntoView是否在DOM更新后调用
  • 查看chatElement.scrollHeight和scrollTop的值是否匹配

根因与修复:scrollIntoView需在DOM重排后执行。用requestAnimationFrame确保时机:

useEffect(() => { requestAnimationFrame(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }); }, [messages]);

5.5 问题:多次发送后,内存占用持续增长

现象:打开任务管理器,页面内存从50MB涨到500MB。

排查思路:

  • Chrome DevTools Memory面板,录制Heap Snapshot,对比发送前后的差异
  • 搜索Uint8Array、ArrayBuffer实例数量

根因与修复:value被意外闭包引用。检查所有processLine调用点,确保没有类似debugLog.push(value)的代码。用console.table()代替console.log(),避免隐式引用。

5.6 问题:移动端键盘弹出后,输入框被遮挡

现象:iOS Safari中,点击输入框,键盘弹出,输入框被顶出可视区。

根因与修复: 这是iOS Webkit的已知问题。在handleSubmit中强制滚动:

const handleSubmit = async (e) => { e.preventDefault(); // ...省略 // iOS hack: 强制滚动到输入框 if (/iPad|iPhone|iPod/.test(navigator.userAgent)) { inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); } };

5.7 问题:Vite HMR热更新后,流式请求失效

现象:修改代码保存,Vite自动刷新,但之后的流式请求全部失败。

根因与修复: HMR会销毁旧模块,但AbortController或reader可能未被正确清理。在组件卸载时取消请求:

useEffect(() => { return () => { if (controller) controller.abort(); }; }, []);

5.8 问题:Nginx部署后,流式响应变成一次性返回

现象:本地开发正常,Nginx部署后,所有内容等10秒后一次性刷出。

根因与修复: Nginx默认开启proxy_buffering。必须在location中关闭:

location /api/ { proxy_buffering off; # 关键! proxy_cache off; # ...其他配置 }

5.9 问题:CORS错误,但明明配置了Access-Control-Allow-Origin

现象:fetch报错No 'Access-Control-Allow-Origin' header,但后端已设置。

根因与修复: 流式响应需额外允许Content-Type和Cache-Control头:

# FastAPI示例 @app.middleware("http") async def add_cors_headers(request: Request, call_next): response = await call_next(request) response.headers["Access-Control-Allow-Origin"] = "*" response.headers["Access-Control-Allow-Methods"] = "POST, GET, OPTIONS" response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization, Cache-Control" return response

5.10 问题:AI回复中包含Markdown,前端未渲染

现象:AI返回**加粗文字**,但页面显示为纯文本。

根因与修复: 这是功能需求,非Bug。若需渲染,用react-markdown库:

npm install react-markdown
import ReactMarkdown from 'react-markdown'; <ReactMarkdown>{assistantMessage.content}</ReactMarkdown>

但注意XSS风险,需配置remarkPlugins过滤危险HTML。


这份排查清单,是我过去半年在多个项目中踩坑、填坑、再

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

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

立即咨询