{T}

打字机指令与后续指令扩展方向

概述

打字机效果(逐字输出一段文本,模拟人工输入)常见于产品官网的标语、营销落地页的口号、或是后台"智能助手"的回复展示。它是一个纯表现层的动效,和业务数据无关,天然适合做成一个声明式指令 v-typing:在目标元素上写一行,文本就会逐字浮现。

但打字机指令的真正价值不止于"酷"。它是一块绝佳的试验田,用来沉淀一套"带生命周期的动效指令"模板:如何起定时器、如何在组件卸载时清理、如何支持参数(速度、是否循环)、如何用 modifiers 切换行为。把这套模板打磨好后,后续的长按、水波纹、loading 遮罩等指令都能复用同一套骨架。本文讲打字机实现,并顺势梳理指令体系的扩展方向。

学习目标

  • 掌握打字机指令的核心实现:用 setInterval 逐字切片,把累积文本写入元素。
  • 理解为什么必须在 unmounted 里清理定时器,否则会泄漏并污染已卸载的 DOM。
  • 学会用绑定值对象携带参数(速度、起始延迟),让同一指令适配多种文案节奏。
  • 能设计一个可复用的"动效指令骨架",把清理逻辑与参数解析抽象出来。
  • 梳理后续可沉淀的指令清单,明确哪些适合指令、哪些不该做。

一、打字机指令的需求与边界

打字机的需求很简单:给定一个字符串,让它在元素里从左到右逐个字符出现,间隔可调。边界也很清楚:它只负责"呈现",不负责"内容从哪来"——文案应是字符串字面量或由数据计算得到,指令不去做异步拉取。另外,打字机只对纯文本安全,绝不能把用户输入拼进 innerHTML,否则引入 XSS;每次写入都用 el.textContent,从根上杜绝脚本注入。

二、setInterval 实现逐字输出

实现思路是累加一个下标 index,每隔 speed 毫秒把 text.slice(0, index++) 写入 el.textContent。完成后清除定时器。为了支持"打完再停一会",可在收尾时留一个尾巴延迟。

ts
const vTyping: Directive<HTMLElement, any> = {
  mounted(el, binding) {
    const cfg =
      typeof binding.value === 'string'
        ? { text: binding.value, speed: 80 }
        : { speed: 80, ...binding.value }
    let index = 0
    const timer = setInterval(() => {
      index++
      el.textContent = cfg.text.slice(0, index)
      if (index >= cfg.text.length) clearInterval(timer)
    }, cfg.speed)
    el.__typingTimer = timer
  },
  unmounted(el) {
    if (el.__typingTimer) clearInterval(el.__typingTimer)
  },
}

关键点:timermounted 里创建后必须挂到元素(el.__typingTimer),这样 unmounted 才能拿到并清理。如果只在闭包里持有定时器引用而不挂到元素,卸载时就无从清理,定时器会继续跑并尝试写入一个已脱离文档的节点。

三、清理定时器与组件卸载

Vue 的 unmounted 钩子对应组件/元素销毁时机。在这个钩子里 clearInterval 是硬性要求:浏览器不会因为 DOM 移除就自动停掉你启动的定时器,它仍会在后台计时并尝试执行回调。轻则浪费 CPU,重则在回调里访问已销毁节点的属性抛出错误。

同样原则适用于所有"带副作用"的指令:动画帧(requestAnimationFrame)、事件监听、定时器、观察者(ResizeObserver/IntersectionObserver)都要在 unmounted 配对清理。把"挂载即登记、卸载即注销"作为指令编写的铁律,能避免一大类难以复现的 bug。

四、指令元数据设计

当打字机需要支持更多行为(循环播放、多段文案轮播、暂停/恢复),参数会膨胀。推荐用绑定值对象承载:v-typing="{ text, speed, loop, delay }"。指令内部对缺失字段给默认值,业务侧只传关心的字段。对于"是否启用某行为"(如 loop),也可以用 modifier 表达:v-typing.loop,语义比藏在对象里更直观。两者可并存:modifier 表达布尔开关,对象表达数值参数。

五、后续指令扩展方向

打字机打磨出的"动效指令骨架"可直接复用到一批指令上。下面给出两个高频成员的骨架,便于理解复用方式。

长按指令 v-longpress:监听按下事件,超过阈值(如 800ms)触发回调,期间用 setTimeout 计时,松开或移出立即清除——和打字机一样,timer 必须挂在元素上以便卸载清理。

ts
const vLongpress: Directive<HTMLElement, any> = {
  mounted(el, binding) {
    const duration = binding.value?.duration ?? 800
    const onDown = () => {
      el.__lpTimer = setTimeout(() => binding.value?.handler?.(), duration)
    }
    const onUp = () => clearTimeout(el.__lpTimer)
    el.addEventListener('mousedown', onDown)
    el.addEventListener('mouseup', onUp)
    el.addEventListener('mouseleave', onUp)
    el.__lpHandlers = { onDown, onUp }
  },
  unmounted(el) {
    clearTimeout(el.__lpTimer)
    const { onDown, onUp } = el.__lpHandlers
    el.removeEventListener('mousedown', onDown)
    el.removeEventListener('mouseup', onUp)
    el.removeEventListener('mouseleave', onUp)
  },
}

水波纹指令 v-ripple:点击时在落点生成扩散圆,用 CSS animation 完成,指令只负责创建/移除节点,同样要在 unmounted 移除事件监听。loading 遮罩 v-loading、权限 v-permission 也共享同一套生命周期约定。把它们都纳入第一篇文章讲的"指令模块化入口",注册逻辑零改动扩展。

六、测试与可访问性注意

动效指令在单测里容易被忽略,但它恰恰是副作用泄漏的高发地。测试时应在 mountedjest.advanceTimersByTime 推进定时器,断言 el.textContent 逐步增长;更重要的是在 unmounted 后断言定时器已清除——可以用 jest.clearAllTimers 配合"spy 是否仍被调用"来验证清理是否生效。

可访问性上,逐字出现的文本对屏幕阅读器并不友好:阅读器可能在打字过程中反复播报不完整片段。推荐给打字机元素加 aria-live="off" 关闭实时播报,或在旁边提供一个视觉隐藏的完整文本节点供读屏使用,确保信息不依赖动效才能被获取。动效是给眼睛看的,语义应当始终完整存在。

七、与 SSR 的兼容

如果项目跑在 Nuxt 或 SSR 模式下,指令的 mounted 只在客户端执行,服务端渲染阶段不会触发定时器,因此首屏拿到的是空文本或占位。这本身不是 bug,但要注意:不要在打字机元素上依赖它承载首屏关键信息(如 SEO 文案),因为服务端输出的 HTML 里该元素是空的,搜索引擎抓不到。

稳妥做法是给元素一个 data-typing-text 属性或在 SSR 阶段预填完整文本,等客户端 mounted 时再接管逐字动画。这样既保证 SEO 和无 JS 降级可读,又能在有 JS 时呈现动效。把"动效不应阻断信息传递"作为这类指令的设计底线。

八、小结

打字机指令看似简单,却把"起定时器、清理定时器、参数化、可测试、可降级"这套动效指令方法论完整跑了一遍。后续的长按、水波纹、loading 遮罩等都是它的变体,复用同一套骨架即可低成本扩展,不必每个动效都从零处理生命周期。


常见问题

问题原因与表现处理方式
切换路由后文案还在变卸载时未清理打字机定时器unmountedel.__typingTimer
页面出现 XSS 风险把外部文本写进 innerHTML只用 el.textContent 写入,绝不拼接 HTML
多次挂载定时器叠加同一元素重复 mounted 未去重挂载前先清旧 timer,或确保指令只挂一次
参数难扩展把速度等写死在指令里用绑定值对象携带参数,缺失字段给默认值

延伸阅读