第一章:LLM 的“早恋”与 React 的“草率”
各位好,欢迎来到“前端异步地狱”的特别研讨会。
今天我们不聊 Redux 的状态树有多深,也不聊 Webpack 的打包速度有多慢。我们聊点刺激的——当那个不知疲倦的 LLM(大语言模型)开始说话时,你该如何优雅地在 React 中接住它抛过来的每一个 Token。
想象一下这个场景:你的聊天界面是一个狭窄的走廊,而 LLM 是一个甚至不懂得“插队”的中二病少年。它一边在那儿打字,一边往你的管道里扔数据。有时候它很流畅,有时候它会把 JSON 的闭合大括号 } 扔到单词 Hello 的中间去。
如果你是那种直接在 useEffect 里写 setText(text + chunk) 的“热血青年”,恭喜你,你很快就会走进我今晚要讲的第一座坟墓——竞态条件与渲染风暴。
第二章:把水倒进漏水的桶里——逐个字符渲染的灾难
很多新手,甚至有些老鸟,在面对流式响应时,最直观的解决方案是这样的:
// ❌ 灾难现场:不要这样做!
const [content, setContent] = useState("");
useEffect(() => {
const eventSource = new EventSource("/api/chat");
eventSource.onmessage = (event) => {
// 每次收到一个 token 就触发一次重渲染
setContent(prev => prev + event.data);
};
}, []);
看起来挺对吧?数据来了,加进去,React 更新 DOM。但这就像是你试图用一只手指去堵住喷涌的水龙头。每一个 Token 的到达都会触发一次重渲染,而 React 的重渲染又需要调度、执行 Diff 算法、更新 DOM 节点。
如果你的 LLM 朋友是个语速快的,每秒能给你吐 50 个 Token,你的浏览器每秒就要执行 50 次昂贵的 DOM 操作。结果就是:你的文本在闪烁,光标在跳舞,用户的眼睛在怀疑人生。 这种“狗血剧”式的渲染,不仅性能极差,而且用户根本读不清你在说什么,因为文字在还没等你读完的时候就已经被新的文字顶走了。
这就是我们要解决的核心矛盾:高频率、无序、且非原子性的输入,如何与低频、有序、原子性的 DOM 渲染机制相匹配?
第三章:React 18 的救命稻草——useTransition
在 React 18 之前,我们要么忍受卡顿,要么自己写防抖。但现在,React 给了我们一把名为 useTransition 的瑞士军刀。它的核心哲学就两个字:“懒”。
如果你把渲染普通文本视为“高优先级任务”,那么把渲染 LLM 的逐字流视为“低优先级任务”,React 就会帮你把那 50 次渲染请求合并、排队,变成每 16ms(一帧)执行一次。这叫批处理。
让我们来重构一下上面的代码,戴上 startTransition 的眼镜:
import { useState, useTransition } from 'react';
const ChatStream = () => {
const [text, setText] = useState("");
const [isPending, startTransition] = useTransition(); // 获取“懒人”助手
useEffect(() => {
const eventSource = new EventSource("/api/chat");
eventSource.onmessage = (event) => {
// 1. 定义一个新的状态更新
const nextText = text + event.data;
// 2. 把这个更新扔给 startTransition
// React:嘿,兄弟,这事儿不急,你先去处理 UI 线程上的点击事件,这文本慢慢加。
startTransition(() => {
setText(nextText);
});
};
return () => eventSource.close();
}, [text]);
return (
<div>
{/* 只有 isPending 为 true 时,我们才显示一个加载圈或者禁止用户输入 */}
{isPending && <div>模型正在思考... (请勿打扰)</div>}
<p>{text}</p>
</div>
);
};
效果如何?
当你开始打字输入新消息时,React 会瞬间响应你的输入(高优先级),因为 isPending 状态锁住了“渲染流”。而在后台,LLM 的文本正在通过 useTransition 悄悄地流进来。
但是!useTransition 只是把“慢”的事情变慢了,它并没有解决“每次更新都触发全量 Diff”的问题。如果文本已经很长了,每增加一个 Token 就要把整个 10000 字的字符串重新 Diff 一遍,内存和 CPU 还是会炸。
第四章:缓冲区与消息队列——打造你的“高速公路”
要真正驾驭 LLM,我们不能只做“快递员”,我们要做“调度中心”。我们需要一个中间层,一个缓冲区。
核心策略:消息队列
我们不应该在数据到达的瞬间就立即更新 UI。我们应该让数据进入一个队列,然后以可控的速率(比如每秒 10-20 次)从队列中取出一批数据,一次性渲染到界面上。
让我们写一个稍微复杂一点的 Hook,专门处理这个缓冲逻辑。
import { useState, useEffect, useRef } from 'react';
const useStreamBuffer = (streamUrl, chunkSize = 5, flushInterval = 16) => {
const [content, setContent] = useState("");
const bufferRef = useRef([]); // 这里是我们的消息桶
const timerRef = useRef(null);
const abortControllerRef = useRef(null);
// 启动流
useEffect(() => {
abortControllerRef.current = new AbortController();
bufferRef.current = []; // 清空旧缓冲区
const eventSource = new EventSource(streamUrl);
eventSource.onmessage = (event) => {
// 1. 接收数据 -> 2. 放入缓冲区
bufferRef.current.push(event.data);
// 如果缓冲区满了,或者我们在急切模式下,立即触发一次渲染
if (bufferRef.current.length >= chunkSize) {
flushBuffer();
}
};
return () => {
eventSource.close();
abortControllerRef.current.abort();
clearTimeout(timerRef.current);
};
}, [streamUrl]);
// 定时刷新:确保即使数据很慢,也不会超过一帧的时间
useEffect(() => {
timerRef.current = setInterval(() => {
if (bufferRef.current.length > 0) {
flushBuffer();
}
}, flushInterval);
return () => clearInterval(timerRef.current);
}, [flushInterval]);
const flushBuffer = () => {
const chunks = bufferRef.current.splice(0, bufferRef.current.length);
const newContent = chunks.join('');
// 这里使用 useTransition 或者直接更新都行,取决于你的复杂度
setContent(prev => prev + newContent);
};
return content;
};
这段代码妙在哪里?
- 解耦: API 的响应速度(可能很快,也可能很慢)与 React 的渲染帧率(恒定)被分开了。
- 吞吐量控制:
chunkSize决定了我们一次渲染多少个字符。比如设置为 20,那么每秒收到 100 个 Token,我们只渲染 5 次。这不仅减少了重渲染次数,还降低了 GPU 的压力。 - 平滑度: 你会发现,
flushBuffer里的文字不是一个个蹦出来的,而是像滚屏一样连贯地滑过去。
第五章:处理“乱序”——JSON 里的终结符
这可是个技术难题。LLM 有时候是个没礼貌的读者。它会先读完一个长句,然后突然冒出一个 JSON 的 { "key": "value" },而这个 JSON 可能被拆成了 {, {"key":, value"}, } 陆续传过来。
如果你只是简单的拼接字符串,界面就会变成这样:
"Hello world {"key": "value"}
这太丑了,甚至会导致 Markdown 解析器崩溃。我们需要一个状态机。
我们需要追踪我们的缓冲区,判断它是否是一个“不完整的句子”或“不完整的 JSON 对象”。
策略:状态追踪与完整性校验
我们可以维护一个内部缓冲字符串,并在渲染前检查它。如果字符串以 {, [, ", 或者某些特殊字符结尾,我们就假设它还没结束,继续在后台排队,直到我们看到一个合法的终结符(比如 } 或者 .(句号+空格))。
这听起来很复杂,但其实可以用一个简单的 while 循环配合正则表达式来实现。
// 一个简单的分块渲染逻辑示例
const processStream = (rawBuffer) => {
// 尝试匹配常见的 Markdown 或 JSON 结束符
// 这里的正则比较简陋,实际生产中可能需要更复杂的库
// 1. 先尝试匹配标准的 Markdown 段落结束 (Double newline)
// 2. 再尝试匹配 JSON 结束
// 3. 或者单词边界 (Word boundary)
const regex = /(nn|ns*n|}s*|. s*$)/g;
let match;
const chunks = [];
let lastIndex = 0;
// 简单的状态机模拟
while ((match = regex.exec(rawBuffer)) !== null) {
const chunk = rawBuffer.slice(lastIndex, match.index);
if (chunk) chunks.push(chunk);
lastIndex = regex.lastIndex;
}
if (lastIndex > 0) {
// 剩下的缓冲区是不完整的,或者是最后一个完整的块
const remaining = rawBuffer.slice(lastIndex);
if (remaining.trim()) {
chunks.push(remaining);
}
// 清空 rawBuffer,保留 remaining 在下一次循环继续处理
return { chunks, remaining };
}
// 如果没找到任何结束符,把整个 buffer 返回为 chunks
return { chunks: [rawBuffer], remaining: "" };
};
通过这种方式,我们确保了传给 React 渲染的,永远是语法完整的块。这极大地提高了用户体验,因为用户不会看到半截的代码块。
第六章:视觉上的“节流阀”——requestAnimationFrame
有时候,仅仅依靠定时器(setInterval)还不够完美。因为浏览器的刷新率不一定总是 60Hz。如果你的 LLM 说话极快,在两帧之间塞进了 200 个 Token,而你只执行了 2 次渲染,那么剩下的 198 个 Token 就会积压在队列里。
这时候,我们要引入requestAnimationFrame。这是浏览器原生的 API,它保证我们在下一次屏幕刷新之前执行代码。这就像是电影的帧率,无论是 24Hz 还是 120Hz,我们的渲染节奏永远追得上屏幕的刷新。
让我们把之前的定时器逻辑升级一下:
const flushBuffer = () => {
const chunks = bufferRef.current.splice(0, bufferRef.current.length);
const newContent = chunks.join('');
// 使用 requestAnimationFrame 保证在下一帧绘制前执行
requestAnimationFrame(() => {
setContent(prev => prev + newContent);
});
};
为什么这很重要?因为在流式传输中,我们不仅要处理“量”,还要处理“时”。requestAnimationFrame 让我们的渲染节奏与用户的视觉刷新率同步,消除了那种“抽搐”的感觉。
第七章:Markdown 高亮的“陷阱”——Diff 与重新解析
终于,文本流是平滑的了,JSON 也是完整的了。但还有一个大 BOSS:Markdown 语法高亮。
你不会想让用户看到一堆纯文本 **bold**,或者 <div>content</div>。你需要 Prism.js 或者 react-syntax-highlighter。
但是,这里有一个巨大的坑。
React 的工作原理是 Diff。如果你把长文本变成了一堆 <span> 标签(用于高亮),每次你追加 10 个字符,React 就会去 Diff 这 10 个字符对应的 10 个 <span> 节点。而且,因为 React 认为文本内容变了,它很可能会直接删除旧的 <span> 树,然后重新解析整个字符串生成新的 <span> 树。
这会导致一个经典问题:光标跳动。
每当文本更新时,如果整个组件树被卸载重装,滚动条就会跳到最顶端;或者光标会闪烁。
解决方案:文本差分
不要每次都把整个字符串扔给解析器。我们要做的是增量 Diff。
对于简单的场景,我们可以只在追加的 10 个字符范围内,重新进行语法高亮,然后将其作为一个“幽灵块”或者直接替换 DOM 中对应的部分。对于复杂的场景(比如长文章),你需要维护一个“虚拟 DOM”层,只在用户滚动到可视区域时,才去解析高亮那一段。
不过,为了代码演示的简洁性(4000 字限制,咱们不整那么玄乎),我们这里用一种稍微偷懒但有效的策略:只渲染完整的 Markdown 块。
// 这是一个简化的思路,实际需要配合上面的 processStream
// 只有当 buffer 处理完一个完整的代码块或段落时,才进行高亮渲染
const StreamComponent = () => {
const content = useStreamBuffer('/api/chat');
// 这里使用 dangerouslySetInnerHTML 是最简单的,
// 但出于安全考虑,生产环境应该用 markdown-it 或 remark
// 并且要确保 content 不包含 XSS
return (
<div
dangerouslySetInnerHTML={{ __html: marked.parse(content) }}
className="prose"
/>
);
};
注意: 上面这行代码虽然简单,但通常不够快。因为 marked.parse 是同步的 CPU 密集型操作。如果你的文本很长,会阻塞主线程,导致输入延迟。
真正的专家级做法: 将文本渲染和 Markdown 解析解耦。
- 将原始文本存入 State。
- 使用
useMemo或者更高级的库,在后台异步解析 Markdown。 - 在解析结果准备好之前,先展示纯文本,解析完毕后再切换 DOM。
第八章:并发策略的终极形态——deferStream (React 19 预览)
虽然 React 19 还没全面普及,但它的方向非常明确:Defer Streaming。
想象一下,现在的策略是:数据来了 -> 进缓冲区 -> 取出来 -> 渲染。
未来的策略(或者你可以手动模拟)是:数据来了 -> 进缓冲区 -> 不渲染 -> 等待组件挂载后,再像电影逐帧播放一样,把缓冲区里的数据“流”出来。
这种策略允许你实现极其丝滑的“打字机效果”,不仅仅是文本出现的动画,还包括光标闪烁、高亮块逐一出现的动画。这完全打破了 React 的传统渲染模式,进入了一种“数据驱动动画”的领域。
// React 19 风格的伪代码概念
const Message = () => {
const { data } = useDeferredStream('/api/chat');
return (
<div>
<span>{data.text}</span>
<Cursor blink />
</div>
);
};
第九章:实战演练——手写一个健壮的 Chat 组件
好了,理论讲得差不多了,口水也干了。让我们把上面所有的策略揉在一起,写一个完整的、可以拿去面试(或者拿去忽悠客户)的组件。
这个组件将包含:
- 自定义 Hook:处理数据流。
- 缓冲机制:防止渲染风暴。
- Markdown 支持:让代码看起来专业。
- 错误处理:万一 LLM 挂了怎么办?
import { useState, useEffect, useTransition } from 'react';
import ReactMarkdown from 'react-markdown';
// 自定义 Hook:处理 LLM 流
const useLLMStream = (url) => {
const [content, setContent] = useState("");
const [isPending, startTransition] = useTransition();
const [error, setError] = useState(null);
// 缓冲区
const bufferRef = useRef([]);
// 定时器引用
const timerRef = useRef(null);
useEffect(() => {
const eventSource = new EventSource(url);
bufferRef.current = []; // 重置
// 消息到达事件
eventSource.onmessage = (event) => {
// 1. 将数据推入缓冲区
bufferRef.current.push(event.data);
// 2. 如果缓冲区有足够的数据(或者你想实时显示一点),立即刷新
// 这里我们稍微激进一点,只要有数据就尝试解析一个完整块
// 实际项目中,这里可以加一个 isParsing 状态来防止并发解析冲突
if (bufferRef.current.length > 0) {
// 简单的触发刷新,实际逻辑中需要拆分完整的 Markdown 块
flushBuffer();
}
};
// 错误处理
eventSource.onerror = (err) => {
setError("Connection lost. The AI ran away.");
eventSource.close();
clearTimeout(timerRef.current);
};
return () => {
eventSource.close();
clearTimeout(timerRef.current);
};
}, [url]);
// 定时器:如果数据太慢,至少每 50ms 刷新一次,防止死等
useEffect(() => {
timerRef.current = setInterval(() => {
if (bufferRef.current.length > 0) {
flushBuffer();
}
}, 50);
return () => clearInterval(timerRef.current);
}, []);
const flushBuffer = () => {
// 拿出所有数据并清空缓冲区
const chunks = bufferRef.current.splice(0, bufferRef.current.length);
const nextContent = chunks.join('');
// 使用 useTransition 标记为低优先级
startTransition(() => {
setContent(prev => prev + nextContent);
});
};
return { content, isPending, error };
};
// UI 组件
const ChatInterface = () => {
const { content, isPending, error } = useLLMStream('/api/chat-stream');
if (error) return <div className="error">{error}</div>;
return (
<div className="chat-window">
{isPending && <div className="status">Thinking...</div>}
<div className="message-content">
{/* 这里为了性能,不建议用 react-markdown 的同步解析做高亮,
除非你能确保文本长度可控。这里只做基础渲染 */}
<ReactMarkdown>{content}</ReactMarkdown>
</div>
</div>
);
};
第十章:防抖、去抖与“你的屏幕死了吗?”
在结束之前,我想再啰嗦一句关于并发中的防抖。
有时候,如果你在流式传输过程中让用户点击“停止生成”,然后 API 的流还在源源不断地发数据过来。React 的更新循环可能会因为 useTransition 而变得不可预测。
策略:AbortController
一定要使用 AbortController。当用户点击停止时,不仅要关闭 EventSource,还要清空我们的 bufferRef,并停止任何正在进行的 setInterval。
const stopGeneration = () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
if (timerRef.current) {
clearInterval(timerRef.current);
}
// 清空缓冲区,防止停止后突然弹出一大段文字
bufferRef.current = [];
setContent("");
};
结语:构建流式体验的艺术
处理 LLM 的响应式流渲染,本质上是一场对秩序的争夺战。
数据是混乱的(无序的 Token),API 是不可控的(有时快有时慢),而 React 是死板的(必须遵守渲染周期)。作为前端工程师,你的工作就是用 useTransition 这种并发原语,用自定义的缓冲队列,用精心设计的正则分块策略,在混乱中建立秩序。
不要只满足于“文字能出来”。要去追求“文字滑出来的感觉”。要去追求“代码块高亮是完整的”。去追求“当用户疯狂输入时,输入框从不卡顿”。
这就是 React 驱动的 LLM 流渲染的精髓。现在,去写代码吧,让那个中二病的 AI 对象跟你愉快地聊天!