打字机效果优化:用 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 聊天、金融实时行情

相关推荐
小村儿8 小时前
连载13- 内部Tools,Claude Code 怎么真正"动"你的代码
前端·后端·ai编程
IT_陈寒8 小时前
Python的线程池把我坑惨了,原来异步不是万能的
前端·人工智能·后端
ANnianStriver8 小时前
PetLumina 07 — 宠物管理升级与 JavaScript 大数精度修复
开发语言·javascript·ai编程·宠物
初一初十8 小时前
vue3茶叶商城网站vue网页vuejs前端
前端·javascript·vue.js·vscode·前端框架
kyriewen9 小时前
前端性能优化:LCP 从 4s 到 0.9s 的 5 个核心手段(附配置代码)
前端·javascript·性能优化
xiaofeichaichai9 小时前
Proxy与Reflect
前端·javascript
小蜜蜂dry9 小时前
nestjs实战-权限二:角色模块
前端·后端·nestjs
rm1099 小时前
【js逆向】webpack自吐算法记录
javascript
AskHarries9 小时前
权限模型:Shell、Browser、文件读写的安全边界
服务器·前端·网络
小蜜蜂dry9 小时前
nestjs实战-权限一: 菜单模块
前端·后端·nestjs