Skip to main content

打字机的节流尺度

打字机效果是流式界面最直观的部分,也是最容易做过头的地方。

问题在于:token 到达的频率和屏幕刷新的频率完全不是一个量级。每秒几十个 token,但屏幕每秒只刷约 60 次,而一次完整 Markdown 解析加渲染的成本远高于一次 setState。逐 token 渲染的结果就是——主线程被渲染占满,页面开始卡,而这恰恰是你想通过流式避免的体验。

token 到达每秒几十个,且不均匀(可能一批一起到)屏幕刷新每秒约 60 次,超过这个频率的渲染都是浪费逐 token 渲染帧率远超需要,主线程被占满按帧合并攒到下一帧一次性更新再按字数或时间兜底长输出时进一步降频节流的目标不是"看起来更像打字机",而是"不因为渲染把主线程占满"副作用:节流会让结束时的最后几个字延迟出现,流结束要强制刷新一次
图:从逐 token 到按帧,再到按量兜底

1. 三件事的频率差​

token 到达:每秒几十个,且不均匀。网络会把几个 token 攒成一个 chunk 送过来,所以到达模式是阵发的。

屏幕刷新:约 60Hz。超过这个频率的更新用户根本看不到。

渲染成本:一次全量 Markdown 解析 + 虚拟 DOM diff,在长回答上可能几十毫秒。

把这三条放一起看,结论很清楚:逐 token 渲染是在做用户看不见的工作,而且是最贵的那种。

2. 第一层:按帧合并​

最简单的做法,也是收益最大的一层:token 先入缓冲,攒到下一次动画帧再统一更新。

const raw = useRef('')
const [text, setText] = useState('')
const frame = useRef(0)

const push = (delta: string) => {
raw.current += delta // 永远追加,不丢 token
if (frame.current) return // 已有帧在排队就不再调度
frame.current = requestAnimationFrame(() => {
frame.current = 0
setText(raw.current)
})
}

关键是那个 frame.current 标志位:它保证同一帧内到达的一批 token 合并成一次更新。真正省下来的不是"从几十次降到 60 次"——token 到达本来也就每秒几十个——而是另外三件事:

  • 合并阵发:网络 chunk 是成串来的,同一帧内到达的多个 chunk 只触发一次 setState
  • 天然背压:单次渲染超过 16.6ms 时,rAF 会把下一帧推后,任务不会在队列里堆起来
  • 解耦两个频率:到达频率由网络决定,渲染频率由屏幕决定,两者不再互相拖累

对大多数场景,这一层就够了。

3. 第二层:按量兜底​

按帧合并解决的是频率,但没解决单次渲染的成本。当消息已经很长(几千字)时,即使每秒只渲染 60 次,每次都要全量重新解析整个字符串,成本仍然很高。

这时可以再加一层:不每帧都更新,而是攒够一定量(比如新增 N 个字符)或过了一定时间(比如 50ms)才更新一次。

const FLUSH_MS = 50

function makeFlusher(setText: (s: string) => void) {
let timer: number | null = null
let pending = false
return (get: () => string) => {
pending = true
if (timer) return
timer = window.setTimeout(() => {
timer = null
if (pending) { pending = false; setText(get()) }
}, FLUSH_MS)
}
}

但这一层要谨慎。 时间节流会让显示落后于实际生成,太快则失去意义,太慢则看起来一顿一顿。50ms 是个常见的起点,对应的观感是"每秒 20 次更新"——仍然比人眼能分辨的快。

真正的解法其实不是节流,而是避免全量重解析:把已完成的区块 memo 住,只重新解析正在增长的那一段。这比任何节流都有效,做法见《半截 Markdown 怎么渲染》。

4. 什么时候不需要节流​

一个反直觉的情况:短回答不需要节流。

一条 200 字的回复,全量解析可能只有几毫秒,每秒几十次也没问题。这时候加节流只会增加复杂度和"最后几个字延迟出现"的毛病。

所以更合理的策略是按长度自适应:消息短时逐帧更新(最跟手),超过某个长度后才启用更强的节流或增量解析。

这个阈值按实测定——在你的目标设备上测"多少次渲染开始导致掉帧"。

5. 节流带来的两个副作用​

5.1 结束时的尾巴​

节流意味着缓冲区里可能还有内容没渲染,而流刚好结束了。如果不处理,最后几个字会永远不显示。

流结束时必须强制刷新一次,并且用完整的原始数据渲染(而不是补齐后的字符串)。这一步在《半截 Markdown 怎么渲染》里也提过——补齐用的临时围栏不能留在最终消息里。

5.2 与打字机动画的冲突​

有些实现会额外加"逐字显示"的动画(控制每帧显示多少字符,营造匀速打字的观感)。这和节流是两套独立的机制,容易互相打架:

  • 节流控制的是"多久把新到的内容推给渲染"
  • 打字机动画控制的是"已收到的内容以多快速度显示出来"

如果两者都开,会出现"内容到达了但动画还没播到"的积压,流结束后还要继续播一段。

建议是二选一。 流式输出本身已经有打字机效果了(内容本来就是逐渐出现的),额外加匀速动画通常是为了"看起来更平滑",代价是延迟和复杂度。除非你的场景确实需要匀速观感,否则不要加。

6. 打字机不是目的​

值得说清楚一件事:打字机效果的存在理由不是"像人打字",而是"让等待变得可感知"。

用户看到内容在增长,就知道系统在工作、没有卡死。这是它的全部价值。

一旦明白了这一点,很多纠结就没有了:

  • 要不要匀速?不需要,只要能看到增长就行
  • 要不要加光标闪烁?可选,它强化"还在进行"的信号
  • 慢一点是不是更好?不是,用户想要的是快,不是好看

真正的体验指标是首字延迟——从用户点发送到看到第一个字的时间。这个数字对体验的影响,远大于打字机动画是否平滑。

7. 长输出时的额外处理​

一次生成几千字时,即使渲染优化到位,DOM 节点数也会增长到影响滚动性能。

三条可选:

内容分块。 把长回答按段落拆成多个消息块,而不是一个超长消息。这样每块的渲染成本可控,也便于单独重试。

折叠历史。 已完成的早期段落收起,只保留最近几段展开。

限制历史消息的渲染。 见《虚拟列表原理与不定高处理》里追加的那一节——正在生成的那条不进虚拟化,历史消息才虚拟化。

8. 怎么验证有没有问题​

不要靠"看起来挺流畅"。用这个顺序:

  1. Performance 面板录一段流式过程,看主线程有没有长任务(>50ms 的黄色块)
  2. 看帧率:流式期间是否稳定在接近 60fps
  3. 测长回答:用一次三千字的输出测,短回答不会暴露问题
  4. 测低端设备:在中端安卓机上跑一遍,差异会非常明显
  5. 测快速连续多条消息:历史消息累积后的渲染成本

第 3 和第 4 条最容易跳过,也最容易漏出问题。开发机上的流畅不代表用户设备上的流畅。

9. 另一条互补的路:把更新降级​

rAF 节流管的是"多久渲染一次",但每一次渲染仍然是同步、不可打断的。一次全量解析 + diff 如果是 40ms,用户在这 40ms 里点"停止"按钮就是没反应的——输入框也会卡。

React 的并发特性解决的是另一个维度:把这次更新标记成低优先级,让浏览器先处理输入。

const [isPending, startTransition] = useTransition()

useEffect(() => {
return subscribeToStream((text) => {
// 高优先级:输入框、停止按钮的交互
setTextNow(text)
// 低优先级:整段消息体的解析与渲染,可被打断
startTransition(() => setRenderedText(text))
})
}, [])

三条边界必须知道:

  • 它不能替代 memo。低优先级只是"可以被打断",重复的解析该省还是要省
  • 连续高频 transition 会让 isPending 长期为 true,如果你用它在流式期间显示 loading,会一直转圈
  • 对 useSyncExternalStore 驱动的外部 store 无效(比如 Zustand 的某些用法),那种情况要在 store 侧做节流

实践里两者叠用:rAF 控频率,memo 控单次成本,transition 控优先级。缺一个都会在长回答 + 低端机上暴露出来。

10. 清单​

  • token 入缓冲,按帧合并更新
  • 已完成的区块做了 memo,避免全量重解析
  • 长消息有额外的降频或增量解析
  • 流结束强制刷新,用原始数据渲染
  • 打字机动画与节流二选一,不叠加
  • 关注首字延迟,而不是动画平滑度
  • 长输出有分块或折叠策略
  • 在低端设备和长回答上测过帧率
  • 输入框与停止按钮在长回答流式期间仍然跟手

11. 一句话​

节流的目标不是"让打字机更好看",而是"别让渲染把主线程占满"。只要不卡,越快显示越好——用户要的是答案,不是动画。