你一定知道浏览器最怕的不是“大量数据”,而是**“高频小动作”**。
当 AI 通过 SSE(Server-Sent Events)流式返回数据时,由于网络包的大小不一,可能会出现一秒内触发几十次甚至上百次 DOM 更新的情况。如果你直接在接收到消息时就修改 innerText,浏览器会陷入不断的 Reflow(回流) 和 Repaint(重绘) 循环中。这不仅会让风扇狂转,还会导致输入框卡顿、动画掉帧,甚至让你的 AI Prompt Manager 看起来像个廉价的半成品。
1. 核心矛盾:网络频率 vs. 屏幕频率
- 网络推送:可能每 10ms 就来一个 Token。
- 屏幕刷新:大多数显示器是 60Hz(约每 16.7ms 刷新一次)。
- 后果:如果在 16.7ms 内你更新了 5 次 DOM,浏览器实际上只能显示最后一次,前 4 次的计算全是浪费的 CPU 垃圾时间。
2. 优化方案:构建“文字缓冲区”
我们的思路是:不要来一个字蹦一个字,而是建立一个中间缓冲区(Buffer) 。无论网络多快,我们都只跟着浏览器的 requestAnimationFrame (rAF) 节奏走。
实战代码实现
JavaScript
class TypewriterBuffer {
constructor(container) {
this.container = container;
this.buffer = ""; // 待渲染的文字池
this.isRendering = false;
}
// 1. 接收网络推送的数据
push(chunk) {
this.buffer += chunk;
this.requestRender();
}
// 2. 触发渲染逻辑
requestRender() {
if (this.isRendering) return; // 已经在跑了,别催
this.isRendering = true;
requestAnimationFrame(() => {
this.flush();
});
}
// 3. 执行真正的 DOM 更新
flush() {
if (this.buffer.length > 0) {
// 这里的逻辑可以更复杂:比如一次只从 buffer 拿 2 个字,模拟更自然的打字感
const fragment = document.createTextNode(this.buffer);
this.container.appendChild(fragment);
this.buffer = ""; // 清空缓冲
}
this.isRendering = false;
}
}
// 使用方式
const logger = new TypewriterBuffer(document.getElementById('ai-response'));
sse.onmessage = (e) => logger.push(e.data);
3. 进阶:模拟“真人打字”的韵律感
AI 现在的流式输出有时太快,快到人眼根本读不过来。资深开发会在这里加一点“演技”:即使数据已经到了,我们也匀速释放。
JavaScript
// 在 flush 中增加步长控制
flush() {
const step = Math.ceil(this.buffer.length / 5); // 动态步长,量大时快点,量小时慢点
const textToAppend = this.buffer.slice(0, step);
this.buffer = this.buffer.slice(step);
this.container.lastChild.textContent += textToAppend;
if (this.buffer.length > 0) {
requestAnimationFrame(() => this.flush()); // 递归渲染剩余字符
} else {
this.isRendering = false;
}
}
4. 性能避坑指南
- 避免
innerHTML:在流式场景下,严禁使用innerHTML += chunk。这会导致浏览器解析整个字符串并重新构建所有 DOM 节点。请使用textContent或appendChild(TextNode)。 - 长文本崩溃:如果 AI 回复了 5000 字,
lastChild.textContent的性能也会下降。建议每 1000 个字封装进一个独立的<div>或<span>。 - 合并更新:如果你的 AI 包含 Markdown 解析(如
marked.js),不要每来一个字都调用一次解析器。应该利用 rAF 的间隙,每隔 5-10 个 Token 解析一次全量 Markdown。 - CPU 保护:当页面处于后台(用户切走了)时,
requestAnimationFrame会自动暂停。这正是我们要的效果:用户看不见时,不浪费一丁点 CPU 算力。
5. 效果对比
| 维度 | 直接 DOM 更新 | rAF 缓冲区更新 |
|---|---|---|
| CPU 占用 | 剧烈波动,易出现峰值 | 平滑且稳定 |
| 渲染流畅度 | 视觉闪烁,可能有撕裂感 | 丝滑,符合屏幕刷新率 |
| 主线程压力 | 持续高压,响应输入慢 | 间歇性工作,输入依旧流畅 |
| 适用场景 | 简单、低频更新 | AI 聊天、金融实时行情 |