打字机效果在数字世界中,就像是在观察某人边思考边大声说出来一样。文本逐个字符出现,仿佛有一只真实的手正在敲击真实的按键。你可以在作品集的 Hero 区域、基于浏览器的终端模拟器,以及 AI 助手的聊天窗口中看到它——这些助手试图证明它们正在“输入”回复,而不是直接抓取一段预设好的文本。做得好,它能营造期待感;做得不好,则感觉像是 1987 年一台卡纸的打印机。

为什么这种模式经久不衰

计算机的信息传递是瞬时的,而人类不是。这两者之间的速度差距其实很有用。打字机效果通过模拟人类的节奏来弥合这一差距。在落地页上,它可以引导读者的视线逐词阅读标题,从而让访问者真正读到价值主张,而不是走马观花。在终端模拟器中,它营造出命令正在实时执行的错觉。在聊天机器人界面中,这种节奏暗示回复是即时生成的,而非从数据库中检索出来的。

但只有当交互机制尊重用户时,这种效果才有效。那种机械、单调、间隔完全一致的“滴答、滴答”声会让人感到很僵硬。更糟糕的是,如果实现方式忽略了辅助技术,原本有趣的视觉点缀可能会变成令人沮丧的障碍。目标不是为了拖慢用户的速度,而是通过增加适度的“摩擦力”让界面显得鲜活。

使用递归 setTimeout 来实现

setTimeout 开始,不要碰 setInterval。两者的区别比看起来要重要得多。

setInterval 是固执的。无论你的脚本或浏览器的渲染主线程正在发生什么,它都会每隔 n 毫秒触发一次。如果你的逻辑要求普通按键之间停顿 50 毫秒,而在标点符号后停顿 150 毫秒,setInterval 就无法适应。你最终不得不套用额外的条件逻辑,与竞态条件作斗争,并且由于频繁地清除和重置间隔,导致代码变成了一场状态管理的噩梦。一旦你需要变速,固定间隔就会失效。

递归 setTimeout 通过让每一步决定下一步的规则来解决这个问题。你可以把它看作一个微型状态机。你需要维护几个变量:当前字符串、当前字符索引、一个表示正在输入还是删除的布尔标志,以及一个用于循环多个字符串的 textIndex 计数器。函数追加一个字符,检查它在句子中的位置,然后根据上下文匹配的延迟时间,调度下一次自身的调用。

例如,你可以在 50 毫秒内输入大多数字符,在逗号后放慢到 150 毫秒,并在完整句子的末尾暂停 800 毫秒,然后再切换到删除模式。使用 setInterval 无法优雅地实现这一点。而使用递归 setTimeout,逻辑非常清晰:

if typing:
  append next character
  if at end of string:
    switch to pause mode
    schedule next call after 1000ms
if deleting:
  remove last character
  if string empty:
    increment textIndex
    load next string
    switch to typing mode

这种结构也让清理工作变得轻而易举。存储 timeout ID。当组件卸载或用户离开页面时,调用一次 clearTimeout 即可。不会有孤立的间隔在后台不停地跳动。

光标应该独立闪烁

闪烁的光标是一个视觉细节,而不是数据层面的问题。不要把它放进你的 JavaScript 状态引擎中。请使用附加在 ::after 伪元素上或位于文本容器末尾的专用 <span> 的独立 CSS 动画。

一个简单的 @keyframes blink 通过 step-end 时序切换 opacityborder-color,可以为你提供一个运行在合成器上的、清晰且对硬件友好的脉冲效果。JavaScript 不应该去微观管理光标的可见性。如果你在 setTimeout 递归内部切换显示属性,每输入一个字符都会强制进行不必要的样式重新计算。让 CSS 处理美学,让 JavaScript 处理序列。

使用 textIndex 计数器循环多个字符串非常简单。将你的字符串存储在数组中。当动画完成删除阶段且容器为空时,对数组长度取模并增加 textIndex,将字符指针重置为零,然后重新开始输入。这就是作品集网站在不重新加载页面的情况下,循环展示角色——["Developer", "Designer", "Writer"]——的方式。

破坏幻觉的错误

在业余的实现中,有三个错误会反复出现。

使用 setInterval 我们已经讨论过变量速度的问题,但还有一个更微妙的问题。如果你的 DOM 更新出现延迟——例如,因为浏览器正在进行布局偏移(layout shift)绘制——setInterval 仍会持续触发。这可能导致重叠写入、重复字符,或者写入速度快于浏览器渲染速度。递归式的 setTimeout 会在当前步骤完成后才考虑下一步。

忘记转义 HTML。 如果你的源字符串包含尖括号,并且你正在逐个字符通过 innerHTML 注入内容,你会把标签拆成两半。浏览器会先看到 <,然后是 <s,接着是 <st。这会阻止正确的标签解析,并可能导致 DOM 节点损坏或出现意外的样式级联。如果你想显示字面上的字符,请先对其进行转义,或者更好的做法是使用 textContent 而不是 innerHTML。如果你确实需要在打字机输出中包含带样式的 <span>,请预处理字符串,以便在开始字符循环之前准确知道标签的开始和结束位置。

忽略无障碍性(Accessibility)。 屏幕阅读器并不喜欢逐个字母地朗读。随着你的脚本将每个新字符添加到 DOM 中,某些辅助技术会再次宣布整个节点,导致单词片段像机关枪一样断断续续地响起。对于任何依赖听觉导航的人来说,这都是一场噩梦。解决方法并不复杂:为包含完整最终文本的容器添加一个 aria-label。你也可以使用 aria-hidden="true" 将动画元素从辅助技术中完全隐藏,并为屏幕阅读器提供一个视觉上隐藏的静态副本。无论采用哪种方式,请直接为用户提供完整的句子,而不是强迫他们忍受你的“表演”。

完善你版本的方案

一旦核心循环运行正常,你就可以添加额外的功能。但在基础部分稳固之前,请抵制添加额外功能的冲动。

按键音效。 每个字符发出细微的点击声可能会让人感到愉悦,但网页上的音频是一个雷区。使用 Web Audio API 或带有短缓冲区的轻量级 Audio 元素。稍微改变播放速率(在 0.95 到 1.05 之间),这样相同的点击声听起来就不会显得太机械。务必遵守浏览器的自动播放策略,并提供静音开关。没有什么比在早上 9 点自动播放打字声的个人作品集页面更能赶走用户了。

多行打字。 真实的终端窗口会自动换行。如果你的文本跨越了换行符,除非你的布局是可预测的,否则简单的 border-right 光标会跳动得很尴尬。按换行符拆分字符串,并在各自的 <span> 中渲染每一行,或者使用一个跟踪内容末尾的定位伪元素。注意文本换行问题;如果容器宽度发生变化,实现为行内边框(inline border)的光标可能会与文本脱离。考虑使用 white-space: pre-wrap 和等宽字体(monospaced font)来实现终端风格,因为固定宽度的字符会让光标计算变得更加可预测。

实时 Markdown 渲染。 这是最棘手的地方。如果你输入 **bold**,你有一个选择:是按原样渲染星号,还是即时将其转换为加粗样式。如果你选择后者,在过程中从 textContent 切换到 innerHTML 意味着你的文本节点边界发生了变化。由于 HTML 标签会改变底层的 DOM 树,光标位置会变成一个繁琐的记账难题。一种更安全的方法是正常输入原始 Markdown 字符串,然后在完整字符串显示在屏幕上后触发一次渲染。如果你确实需要实时格式化,请维护两个层:一个隐藏的已输入缓冲区和一个解析后的视觉覆盖层。

反向效果。 删除文本并不一定意味着逐个字符退格。你可以模拟“全选,然后删除”的重置操作,在下一个字符串开始输入前瞬间清空字段。这感觉很生硬。或者,以每字符 30 毫秒的速度缓慢退格可以营造紧张感。将两者结合起来:快速退格删除错别字,停顿一下,然后以正常速度继续删除。这种变化能增加效果的“人性化”感。

核心总结

打字机效果是一种表面看起来微不足道,但在构建完成后才会显露其复杂性的 UI 点缀。从