打字机效果优化:用 requestAnimationFrame 缓冲高频文字更新

你一定知道浏览器最怕的不是"大量数据",而是**"高频小动作"**。

当 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

kotlin 复制代码
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

arduino 复制代码
// 在 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. 性能避坑指南

  1. 避免 innerHTML :在流式场景下,严禁使用 innerHTML += chunk。这会导致浏览器解析整个字符串并重新构建所有 DOM 节点。请使用 textContentappendChild(TextNode)
  2. 长文本崩溃 :如果 AI 回复了 5000 字,lastChild.textContent 的性能也会下降。建议每 1000 个字封装进一个独立的 <div><span>
  3. 合并更新 :如果你的 AI 包含 Markdown 解析(如 marked.js),不要每来一个字都调用一次解析器。应该利用 rAF 的间隙,每隔 5-10 个 Token 解析一次全量 Markdown。
  4. CPU 保护 :当页面处于后台(用户切走了)时,requestAnimationFrame 会自动暂停。这正是我们要的效果:用户看不见时,不浪费一丁点 CPU 算力。

5. 效果对比

维度 直接 DOM 更新 rAF 缓冲区更新
CPU 占用 剧烈波动,易出现峰值 平滑且稳定
渲染流畅度 视觉闪烁,可能有撕裂感 丝滑,符合屏幕刷新率
主线程压力 持续高压,响应输入慢 间歇性工作,输入依旧流畅
适用场景 简单、低频更新 AI 聊天、金融实时行情

相关推荐
踩着两条虫20 小时前
VTJ.PRO 核心架构全公开!从设计稿到代码,揭秘AI智能体如何“听懂人话”
前端·vue.js·ai编程
jzlhll1231 天前
kotlin Flow first() last()总结
开发语言·前端·kotlin
用头发抵命1 天前
Vue 3 中优雅地集成 Video.js 播放器:从组件封装到功能定制
开发语言·javascript·ecmascript
蓝冰凌1 天前
Vue 3 中 defineExpose 的行为【defineExpose暴露ref变量】详解:自动解包、响应性与实际使用
前端·javascript·vue.js
奔跑的呱呱牛1 天前
generate-route-vue基于文件系统的 Vue Router 动态路由生成工具
前端·javascript·vue.js
柳杉1 天前
从动漫水面到赛博飞船:这位开发者的Three.js作品太惊艳了
前端·javascript·数据可视化
Greg_Zhong1 天前
前端基础知识实践总结,每日更新一点...
前端·前端基础·每日学习归类
We་ct1 天前
LeetCode 148. 排序链表:归并排序详解
前端·数据结构·算法·leetcode·链表·typescript·排序算法
TON_G-T1 天前
day.js和 Moment.js
开发语言·javascript·ecmascript