在现代 AI 产品中,打字机效果已经成为提升交互体验的标配。它模拟真实的逐字输出,让用户感觉到"AI 正在思考并生成回答",而不是一次性地将所有结果展示出来。
本文将从最简单的实现方式开始,逐步介绍如何设计一个能适配复杂业务场景的高级打字机效果。
1. 什么是打字机效果?
打字机效果指的是逐字输出文本内容,而不是整段文字一次性渲染。例如大模型流式返回时,前端会模拟"AI 正在打字"的感觉。
2. 基础实现:setTimeout + substring
最常见的实现方式:
scss
let index = 0;
function typeWriter(text) {
if (index < text.length) {
output.textContent += text[index++];
setTimeout(() => typeWriter(text), 30);
}
}
这种方法可以完成最基本的打字机效果,但在真实的 AI 场景中会遇到以下问题:
- 复杂格式:包含代码块、链接、Markdown 标识时容易被截断或渲染异常。
- 长文本性能:数千字符输出会频繁触发 DOM 更新,造成卡顿。
- 速度体验:固定间隔不符合真实打字节奏,也无法适应流式输出的不均匀性。
- 状态控制:无法处理暂停、取消、提前结束等交互需求。
因此,简单方案在真实产品中无法满足复杂业务要求。
3. 高级实现:核心技术点
3.1 指数衰减公式控制速度
人类感知速度变化的规律符合心理学中的韦伯-费希纳定律:
感知变化与刺激强度呈对数关系,不是线性的。
在打字机效果中,我们希望:
- 开始时稍慢 → 用户有"进入感"
- 随后加快 → 避免长时间等待
- 最后趋于稳定 → 保持流畅
指数衰减公式可以很好地模拟这种节奏:
ini
function exponentialDecay(x) {
const asymptote = 4; // 最低延迟 (ms)
const rate = 0.01; // 衰减速率
const initial = 30; // 初始延迟
return asymptote + (initial - asymptote) * Math.exp(-rate * (x - 1));
}
它的曲线特点:
- 前期下降快,后期趋近于
asymptote
- 适合**"先慢后快"**的自然打字体验
长文本优化 :
当剩余字符数很大时,速度趋于极限,需要额外的批量输出策略:
scss
if (left.length > 300) {
// 批量输出,减少 setTimeout 次数
item.answer += left.slice(0, 5);
} else {
// 精细输出
item.answer += left[0];
}
这样可以在数千字符的输出中,兼顾性能与体验。
3.2 特殊标识正则匹配
在 AI 输出中常包含一些"原子块",必须一次性渲染,否则会破坏格式:
- Markdown 链接
[title](url)
- HTML 标签
<a>xxx</a>
实现方式:
scss
const sourceReg = /[.*?](.*?)/g;
if (tail.match(sourceReg)) {
item.answer += match[0]; // 一次性输出完整链接
next();
}
这样可以避免逐字输出导致的"半截标签"问题。
3.3 状态机式逻辑管理
在真实产品中,输出内容可能包含不同类型:
text
→ 逐字输出code
→ 一次性输出完整块(避免闪烁)link
→ 按原子块输出stream end
→ 触发结束逻辑
用状态机管理输出逻辑:
go
switch (item.type) {
case 'text': handleText(); break;
case 'code': renderFull(); break;
case 'link': insertLink(); break;
}
同时支持:
- 暂停 / 恢复
- 提前终止
- 结束回调
5. 总结
在实际的 AI 产品中,打字机效果已经不仅仅是一个 setTimeout
循环。为了兼顾性能、用户体验和复杂业务需求,我们最终选择了:
✅ 指数衰减公式 → 模拟自然速度变化
✅ 正则匹配 → 支持链接、Markdown、代码块
✅ 批量输出策略 → 解决长文本性能问题
✅ 状态机逻辑 → 提供暂停、恢复、结束控制
这种混合方案更适合真实的流式输出场景,既能保持流畅体验,也能满足复杂的产品需求。