一个可交互的 React Hook playground:把一段经久不衰的打字机算法剥离出业务依赖,配上 SSE 模拟器与组件库式 demo,让“自适应节奏”看得见。

项目地址:mosuzi/typewriter

起因

最近在研究 AI 流式对话场景下的打字机实现,翻到一段在生产环境跑了很久的算法,越看越觉得里面藏了不少与“流式 + 视觉节奏”相关的思考。可惜原始代码里夹杂着 deepmergerandomCharsuseLastData 这些非核心的东西,把真正有趣的算法藏得很深。

于是我把它抽成一个独立 playground,剥掉所有业务杂质,再配一个 SSE 模拟器和一个仿组件库 demo 风格的聊天室页面,方便随时调参看波形。下面是这次重构和演示项目的一些笔记。

为什么需要打字机?

LLM 推理是 token-by-token 推送的,但底层 SSE 流的节奏其实非常糟糕:

  • 大爆发:模型在 prefill 完成、KV cache 命中或者并发降下来时,会一次性吐出几百字。
  • 慢空窗:网络抖动、tokenizer 拆词、服务端 batching 都可能把下一段延迟 300–800ms。
  • 块大小间隔都是高度不规则的。

如果界面照搬底层节奏,用户看到的就是“突然贴一大段 → 长时间空白 → 又贴一大段”的跳变,而不是“匀速地写出来”。打字机这一层的价值,就是在底层流和用户视觉之间加一个节流缓冲器

  • 积压不严重时,用一个让人舒服的速度匀速吐字。
  • 积压变大时,悄悄加速但不至于突变。
  • 流结束后,尾巴要快速但优雅地补完。
  • 渲染中途,不能切坏 markdown 结构,否则上层 markdown parser 会瞎渲染。

核心算法:自适应反馈环

useTypewriter 内部维护两根指针:

  • queue.current:完整目标串,每次新 chunk 来时累加。
  • cursorIndex.current:已经“打”出来的位置。

每个 setInterval(speed) tick 干这几件事:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const now = Date.now();
const duration = now - startTime.current;
const theoreticalSpeed = duration ? queue.current.length / duration : Infinity;
const elapsedTime = now - lastTime.current;

const expectedIntervals = Math.floor(elapsedTime / speedRef.current);
const theoreticalWords = theoreticalSpeed * elapsedTime;
let adjustedWords = Math.max(
expectedIntervals * wordsRef.current,
lastWords.current,
);

if (adjustedWords < theoreticalWords - 2) {
adjustedWords += 2;
} else if (adjustedWords > theoreticalWords) {
adjustedWords = Math.round(theoreticalWords / 2);
} else {
adjustedWords = Math.round(theoreticalWords);
}

翻译成人话:

  1. 理论平均速度用整段流的总长度除以总耗时算,单位是 chars/ms。这等价于“如果模型现在停止推送,需要把当前已积压的全部内容刚好打完”的速度。
  2. 把这个理论速度乘上本次 tick 的实际间隔,得到“理论上这一格应该吐多少字”。
  3. 当前步长远低于理论速度,差距大于 2,则 +2 平滑加速;超过理论速度,则主动减速到 theoreticalWords / 2,避免一冲到底;接近理论速度,则直接锚到理论值。
  4. lastWords.current 做下界,保证速度不会回退。视觉上“越来越慢”是非常违和的。

为什么是 +2 而不是更激进?因为打字机是给人眼看的,每秒 16 到 30 字符是中英文阅读的舒适区,过快就会变成“贴段落”,失去了打字机本来的意义。/2 减速则是为了避免追赶完毕后的过冲。

skipRules:边界保护

如果只考虑速度,光标随时可能停在 ![alt](url) 的中间,渲染层就会拿到 ![alt](url 这种半成品 markdown,结果就是 image 标签短暂错位、链接误识别。

所以 hook 还要做“结构感知截断”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const calculateNextStep = (defaultLength: number): number => {
let step = defaultLength;
for (const rule of skipRulesRef.current) {
const matchObj = rule.exec(queue.current.slice(cursorIndex.current));
if (matchObj && matchObj.index + matchObj[0].length > 0) {
// 步长卡在匹配区间内,推到匹配末尾
if (step > matchObj.index && step < matchObj[0].length + matchObj.index) {
step = matchObj.index + matchObj[0].length;
} else if (
step > matchObj.index &&
step > matchObj.index + matchObj[0].length
) {
// 步长一次性吞掉了整段匹配,继续往后滚动直到不再交叉
// ...
}
}
}
return step;
};

默认 skipRules 包含:

  • /[A-Za-z]+\b/:不切坏英文单词。
  • /!\[.*]\(.+\)/:不切坏 markdown 图片。
  • /\s+/:不切坏连续空白。
  • /\d\.//-\s.//#*\s/:不切坏列表或标题前缀。

第二个分支,也就是“步长完全覆盖一次匹配”的场景,容易写挂。原始实现里特意写了一个内嵌 while 来处理多个匹配相互堆叠的情况:步长可能横跨好几个英文单词,每个都得依次校验是否需要再扩展。这一段我在移植时逐行保留,没有重写。

enqueue 的小心思:替换 vs. 追加

1
2
3
4
5
6
7
8
9
10
const enqueue = useCallback((val = "") => {
if (!val || typeof val !== "string") return;
if (!val.startsWith(queue.current)) {
cursorIndex.current = 0;
startTime.current = Date.now();
lastTime.current = startTime.current;
lastWords.current = 0;
}
queue.current = val;
}, []);

业务里 enqueue 是被父组件每次 SSE chunk 触发后用“累积串”调用的:

  • 正常流式:每次新串 = 旧串 + delta,startsWith 命中,cursor 继续推进。
  • 流被中断重置或用户重发:新串完全不同,startsWith 失败,把 cursor 和时间窗一起重置,相当于打字机重新进入“冷启动”状态。

这种“用 startsWith 判断是替换还是追加”的小技巧很优雅:调用方不用关心状态,只管把“当前完整内容”丢进来即可,hook 自己分辨。

配套:SSE 模拟器

要可视化打字机的行为,必须能模拟“块大小不规则 + 间隔不规则”的流。我写了一个 useSSESimulator

1
2
3
4
5
useSSESimulator({
chunkSizeRange: [4, 24],
intervalRange: [80, 220],
onChunk: (accumulated) => enqueue(accumulated),
});

实现非常朴素:递归地 setTimeout,再用 randomInt(min, max) 决定切片大小和延迟,每次产出累积串回调出去。把它和 useTypewriter 串起来,就能复刻真实 SSE 流的形态。

chunkSizeRange 拉到 [80, 200]intervalRange 拉到 [300, 600],就能很直观地看到打字机的“追赶”行为:刚收到一大块时步长悄悄加大,等积压消化完后又自然回落到匀速节奏。

Playground 设计

布局参考 Ant Design / MUI 的组件文档页:左侧大块的 preview card 跑实时聊天,右侧 360px 的 props 控制面板,preview 下方一个折叠式代码片段框,整体看起来像是个组件 demo 页。

1
2
3
4
5
6
7
flowchart LR
Sidebar[控制面板] --> Sim[useSSESimulator]
Sim -->|chunk| Acc[累积字符串]
Acc -->|enqueue| Hook[useTypewriter]
Hook -->|text| Bubble[Bot 消息气泡]
Bubble --> Preview[聊天预览卡片]
Sidebar -.pause/resume/clear.-> Hook

控制面板暴露的参数:

  • speed(10–200ms)/ words(1–8):打字机本身。
  • chunk size / interval 区间滑块:SSE 模拟器节奏。
  • skipRules 三个开关:英文单词、markdown 图片、空白,直观验证截断保护。
  • 4 条预设回复:纯文本、含图片 markdown、代码块、长段落压力测试。
  • 实时统计:queue 长度、已渲染长度、滞后字符数、有效 c·s⁻¹。

最有意思的玩法是关掉「Markdown 图片」开关,再发送一遍带图片的预设:你能亲眼看到 ![alt](...) 在中途被切碎,对应的 image 标签短暂渲染错乱,然后下一个 tick 才补完。打开开关,问题立刻消失。

工程踩坑:[paused] 依赖与 clear() 的隐性陷阱

这次重构里踩了一个原版就有但日常用不到的坑:

useEffect 创建定时器时,依赖数组只写了 [paused]

1
2
3
4
5
6
7
useEffect(() => {
if (paused) return;
queueTimer.current = setInterval(() => {
// ...
}, speedRef.current);
return () => clearInterval(queueTimer.current);
}, [paused]);

所以只有 paused 状态变化时,effect 才会重启。其它选项,也就是 speedwordsskipRules,我用 ref 同步进去,不重启 interval。这是一个 trade-off:避免打字过程中频繁拆建定时器导致节奏抖动。

但这导致 clear() 函数的最初实现埋了个雷:

1
2
3
4
5
6
7
// 错误版本
const clear = () => {
clearInterval(queueTimer.current);
setText("");
queue.current = "";
setPaused(false);
};

setPaused(false)paused 已经是 false 时会被 React 合并成 no-op,effect 不会重新运行。于是前面杀掉的定时器永远回不来,之后任何 enqueue 都只往 queue.current 里写,但没人读。playground 表现就是“点发送,没字上屏,有效速度恒为 0”。

修复也很简单:clear() 不要主动 clearInterval。主循环开头本来就有 if (!queue.current.length) return; 的空跑保护,让定时器空转完全无害;而 pause/resume 路径走 [paused] 依赖会自然销毁和重建定时器,不会泄漏。

1
2
3
4
5
6
7
8
9
const clear = useCallback(() => {
setText("");
queue.current = "";
cursorIndex.current = 0;
lastWords.current = 0;
startTime.current = Date.now();
lastTime.current = startTime.current;
setPaused(false);
}, []);

业务侧之所以一直没人发现,是因为业务上 clear() 只在卸载或场景切换时调用一次,调用完整个组件树都重建了,根本走不到“clear 后再 enqueue”的路径。但在 playground 里,发送、清理、再发送是非常常用的循环,bug 立刻露馅。

这种“业务里活得好好的代码,到了 playground 里立刻爆炸”的情况,恰恰是写 playground 的副产品收益之一。

怎么跑

1
2
3
4
cd typewriter
pnpm install
pnpm dev
# http://localhost:5173/chat-room

推荐玩法:

  1. 选「长段落 · 压力测试」,点击发送,观察自适应节奏。
  2. 把 chunk size 调成 [80, 200]、interval 调成 [300, 600],模拟“大爆发 + 慢空窗”,能直观看到加速和减速的过渡。
  3. 流到一半点暂停,文字定格;再点恢复,继续追上 queue。
  4. 切到「Markdown · 含图片」预设,关掉「Markdown 图片」开关再发送一次,![]() 会被切坏;打开开关再来一次,丝滑无瑕疵。

一些不太重要但值得记的小事

  • 不引入 deepmerge:原版选项是扁平结构,{ ...DEFAULT, ...props } 完全够用。生产代码里因为历史包袱保留了 deepmerge,移植时果断扔掉。
  • 不引入 randomChars:原版数组里塞了一堆 []()_*~ 看起来像是为某种“随机打字机花字”准备的,但算法里从未读过。死代码。
  • words 解释:每个 tick 至少前进的字符数。乘上 expectedIntervals 后作为 adjustedWords 的下界。值越大越激进。
  • speed 不响应运行时变化:依赖 [paused] 决定,speed 改了不会立即重启。想立刻生效,pause 一下再 resume 即可。这是实现的天然限制,没去打补丁。

整段算法不长,加上所有边界处理也就 200 行;但它把一个“流式渲染 + 视觉节流 + 结构感知”三合一的小问题做得相当扎实。希望这个 playground 能帮到任何一个正在为自家 AI 产品调打字机节奏的人。