打字机效果优化:用 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 节点。请使用 textContent 或 appendChild(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 聊天、金融实时行情

相关推荐
鬼手点金4 小时前
nanoGPT/nanochat 能做出 DeepSeek/Qwen 这样的大模型吗?
服务器·前端·javascript·bug·openclaw
软件技术NINI5 小时前
美妆商城html+Css+js 4页
javascript·css·html
蛋蛋的就会顺顺的5 小时前
hot100 p0——栈,贪心算法,动态规划
java·前端·javascript·算法·leetcode·力扣
程序员Sunday5 小时前
TypeScript 的 any、unknown、never 有什么区别?
前端·面试·typescript
朱涛的自习室5 小时前
70 美金的 Beyond Compare,我让 Gemini 4 和 DeepSeek 造个开源免费版(Day 2)
android·前端·人工智能
码艺-Alimjan6 小时前
Tauri 源码备份 · 打包 & 解压 总结,源码收集之路
前端·javascript·vue.js
小马不起床6 小时前
零基础玩转 FastAPI_11:前端后台管理页面
前端·状态模式·fastapi
子非鱼a7 小时前
【文件上传】一个普普通通的edu16
前端
美狐美颜SDK开放平台7 小时前
直播APP如何实现美白滤镜功能?视频美颜SDK技术开发方案详解
前端·数据库·人工智能·音视频·美颜sdk·直播美颜sdk·视频美颜sdk
pride.li8 小时前
第03章 Python 教程-基础语法:注释与变量
服务器·前端·后端·python·算法·计算机视觉