打字机的节流尺度
打字机效果是流式界面最直观的部分,也是最容易做过头的地方。
问题在于:token 到达的频率和屏幕刷新的频率完全不是一个量级。每秒几十个 token,但屏幕每秒只刷约 60 次,而一次完整 Markdown 解析加渲染的成本远高于一次 setState。逐 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. 怎么验证有没有问题
不要靠"看起来挺流畅"。用这个顺序:
- Performance 面板录一段流式过程,看主线程有没有长任务(>50ms 的黄色块)
- 看帧率:流式期间是否稳定在接近 60fps
- 测长回答:用一次三千字的输出测,短回答不会暴露问题
- 测低端设备:在中端安卓机上跑一遍,差异会非常明显
- 测快速连续多条消息:历史消息累积后的渲染成本
第 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. 一句话
节流的目标不是"让打字机更好看",而是"别让渲染把主线程占满"。只要不卡,越快显示越好——用户要的是答案,不是动画。