打字机指令循环执行与示例页组织
概述
单段文案的打字机只是起点。在落地页里,往往要"打完一段、停顿、删掉、再打另一段",形成轮播式的效果(比如"我们提供 网站建设 / 小程序开发 / 品牌设计"逐条切换)。这就要求打字机指令支持循环:一段打完进入暂停,然后反向删除,再打下一句,周而复始。本文在上一篇单段打字机的基础上,把它扩展成可循环的多段轮播,并讨论如何用示例页组织这些不同参数下的效果,方便团队预览与选型。
学习目标
- 掌握循环打字机的状态机:打字 → 停留 → 删除 → 切换到下一句 → 再打字。
- 学会用
setTimeout串联阶段,而不是嵌套setInterval,让每段节奏独立可控。 - 理解暂停/恢复/销毁三态,确保组件卸载时整条定时器链被切断。
- 能组织一个示例页,把不同速度、是否循环、不同文案的指令用法集中展示。
- 认识到过度动效的成本,知道何时该关掉循环改用静态文案。
一、循环执行的状态机
循环打字机有四个阶段:打字(逐字增加)、停留(打完停顿)、删除(逐字减少)、切换(跳到下一句开头)。把它建模成一个阶段变量 phase,配合 setTimeout 在不同阶段调度下一步,比用单一 setInterval 更清晰,因为各阶段的等待时长不同(打字按字速、停留几百毫秒、删除略快)。
type Phase = 'typing' | 'pausing' | 'deleting' | 'switching'
function run(el: HTMLElement, texts: string[], speed = 80) {
let i = 0
let index = 0
let phase: Phase = 'typing'
const timers: ReturnType<typeof setTimeout>[] = []
const step = () => {
if (phase === 'typing') {
index++
el.textContent = texts[i].slice(0, index)
if (index >= texts[i].length) {
phase = 'pausing'
timers.push(setTimeout(step, 1200))
} else timers.push(setTimeout(step, speed))
} else if (phase === 'deleting') {
index--
el.textContent = texts[i].slice(0, index)
if (index <= 0) {
i = (i + 1) % texts.length
phase = 'typing'
timers.push(setTimeout(step, 300))
} else timers.push(setTimeout(step, speed / 2))
} else if (phase === 'pausing') {
phase = 'deleting'
timers.push(setTimeout(step, 0))
}
}
step()
return timers
}这里把"停留"作为独立阶段,从 typing 收尾进入 pausing,再转 deleting,避免把停留时长混进打字计时里。多段文案通过 i = (i + 1) % texts.length 环形切换。
二、暂停、恢复与销毁
示例页常需要"暂停预览"按钮,让评审人员定格看某一段。实现上可引入一个 paused 标志:在 step 开头判断,若 paused 为真则不再 setTimeout 推进,等恢复时重新调用 step 续上。销毁则是最重要的一环:组件卸载时必须把所有挂起的 setTimeout 全部 clearTimeout。
function clearAll(timers: ReturnType<typeof setTimeout>[]) {
timers.forEach((t) => clearTimeout(t))
timers.length = 0
}把 timers 数组挂到元素(el.__typingTimers),unmounted 时一次性清空。由于循环模式下定时器是链式派生的,只要切断当前这条链并清理已排队的,就不会再有新回调产生。遗漏任一就会在后台持续跑、污染已卸载节点。
三、示例页组织
当指令支持多种参数组合(速度、循环、多段、停留时长),最好的文档就是一组可交互示例。示例页按"卡片"组织:每张卡片标题说明场景,下方是直接可看的打字机效果,再附一小段用法注释。至少覆盖四类:单段静态打字、多段循环轮播、快速打字(speed 小)、慢速强调(speed 大)。
示例页本身不承载业务,它有两个作用:一是给设计/产品一个"所见即所得"的选型入口,二是给开发者一份活的用法参考,避免每次都翻源码猜参数。把它放在组件库的 examples 目录下,和指令源码同仓维护,改了指令逻辑立刻能在示例页验证。
四、用 IntersectionObserver 控制启停
循环打字机的副作用是后台持续计时。如果示例页有多个循环实例,或者列表里误用了循环,定时器会线性膨胀。更优雅的做法是只在元素进入视口时才启动、离开视口就暂停:用 IntersectionObserver 监听元素可见性,可见时 run(),不可见时 clearAll()。
const io = new IntersectionObserver((entries) => {
entries.forEach((e) => {
if (e.isIntersecting) el.__typingTimers = run(el, texts, speed)
else clearAll(el.__typingTimers || [])
})
})
io.observe(el)
el.__io = io这样既省 CPU,也避免"用户根本没看到却在后台打字"的浪费。unmounted 里除了 clearAll 还要 io.disconnect(),否则观察者本身也会泄漏。
五、无障碍与性能取舍
_prefers-reduced-motion 媒体查询表示用户偏好减少动效。对此类用户应直接显示完整文案、跳过逐字动画,否则快速闪烁的字符会诱发不适。实现上在 mounted 里读取 window.matchMedia('(prefers-reduced-motion: reduce)'),为真则 el.textContent = text 后直接返回。
性能层面,循环效果只用在"hero 区、标语位"等少数视觉锚点;列表、卡片等批量渲染的位置用静态文案或仅在进入视口时启动。动效是手段不是目的,当它开始拖累可读性(用户读不完就被删掉重打)或性能时,应当果断降级。
六、移动端输入法与失焦边界
在移动端,打字机元素如果恰好是某个可聚焦的输入区附近,循环动画的定时器不会受输入法弹起影响,但元素的重排可能让动画看起来"跳帧"。更隐蔽的边界是页面切到后台(visibilitychange):此时 setTimeout 仍会按计划触发,但用户根本看不到,纯属浪费。可在 document.hidden 为真时暂停、回到前台时恢复,和 IntersectionObserver 配合形成双保险。
另外,循环打字机的文案若包含中文标点或全角字符,slice 按 JS 字符(UTF-16 码元)切分会偶尔劈开代理对,出现乱码字符。稳妥做法是把字符串转成数组(Array.from(text))再按码点切片,避免 surrogate pair 被截断。这是中文项目里极易踩到、却很少被文档提及的坑。
常见问题
| 问题 | 原因与表现 | 处理方式 |
|---|---|---|
| 卸载后仍在轮播 | 链式 setTimeout 未全部清理 | 把所有 timer 收进数组,unmounted 统一 clearTimeout |
| 多段切换跳变突兀 | 删除阶段和切换阶段没过渡 | 在 deleting 收尾加短暂停顿再切换 |
| 列表里大量循环卡顿 | 每行各起一套定时器 | 批量位置改用静态文案或视口触发 |
| 暂停后无法续上 | 暂停时直接丢掉了推进链 | 用 paused 标志阻断推进,恢复时重新 step() |
延伸阅读
- 上一篇:打字机指令与后续指令扩展方向
- 下一篇:权限指令与响应式角色订阅
- 相关:打字机指令与后续指令扩展方向