{T}

滚动文本指令配置化、无反向模式与初始抖动修复

概述

上一节滚动文本指令已具备延迟、暂停恢复、更新重置和生命周期清理,但 durationdelaynoReverse 仍是写死的。这意味着它「功能完整」却不算通用。这一节把指令从「课程演示版」推进到「配置化版」:用 binding.value 承载数值配置、用 binding.modifiers 承载行为开关;并从 mounted + updated 连击带来的初始抖动问题中,提炼出「只在宽度真变了才重置」的去抖判断。

至此,滚动文本指令从「会滚」演进为「可配置、可暂停、可恢复、可更新、可清理」的完整交互指令。

学习目标

  • 把写死的 duration / delay 提炼成配置项,迈向通用能力
  • binding.value 传数值、binding.modifiers 传布尔开关,匹配指令参数设计惯例
  • 在所有进度计算路径统一使用可配置值,避免分支失衡
  • noReverse 修饰符只控制「到边界是否翻转方向」这一策略节点,而非复制整套逻辑
  • 用「新旧滚动宽度比较」给 updated 做去抖判断,修复初始抖动
  • 确保暂停恢复时直接读写最新 state.start,避免时间差导致的闪烁

一、从固定逻辑到可配置逻辑

指令可用之后,下一步不是继续堆逻辑,而是把写死常量提炼成配置项。当前 duration 写死、delay 写死、反向滚动是否开启也写死,意味着不同场景想控制「滚动一轮用多久」「开始前等待多久」「是否反向滚动」都得改源码。真正的通用能力应允许这些由外部决定。

code
固定逻辑 -> 可配置逻辑
写死常量 -> 来自 binding 的参数

组件或指令一旦开始复用,常量就要优先考虑参数化;配置化不是为了炫技,而是减少后续复制出多个相似版本。本节重点不是新增动画种类,而是让原有动画可以调。

二、binding.value 与 binding.modifiers 分工

自定义指令接收外部数据主要有两条通路:binding.valuebinding.modifiers,适合承载的内容不同。binding.value 适合传具体数值(如 durationdelay),binding.modifiers 适合传布尔开关(如「要不要反向滚动」)。所以参数设计很顺手:durationdelaybinding.valuenoReverse 放修饰符。

Vue SFC
<div v-scroll-text="{ duration: 3000, delay: 1000 }" />
<div v-scroll-text.no-reverse="{ duration: 3000, delay: 1000 }" />
ts
mounted(el, binding) {
  const duration = binding.value?.duration ?? 10000
  const delay = binding.value?.delay ?? 2000
  const noReverse = !!binding.modifiers.noReverse
}

binding.value 可能不存在,读取时要加可选链或默认值;修饰符本质是布尔对象,取不到时是 undefined。这是典型的「对象配置 + 修饰符开关」组合用法。

三、配置值要全链路替换

durationdelay 从常量改成配置值后,最该警惕的是别只改一处、其他地方还留旧常量。当前计算链条至少涉及:是否开始滚动的判断、progress 有效时间段、正向位移、反向位移、边界切换后的重新开始。其中任何一个还用旧固定值,动画表现都会不一致。

ts
const duration = binding.value?.duration ?? 10000
const delay = binding.value?.delay ?? 2000
const rate = maxScrollLeft / duration

一旦配置项进入动画公式,就要确保所有相关分支统一使用它;默认值仍要保留,避免用户不传时失去基础行为。这种改造是「全链路替换」而非局部替换。

四、noReverse 只作用于策略节点

课程给指令增加了 noReverse 能力,它的意思不是「禁止滚动」,而是到了终点后不再反向往回滚,而是等待后重新从头开始。最值得学的是实现方式:没有复制一套「单向滚动版」逻辑,而是只抓住关键点——到边界后要不要翻转 direction

ts
const noReverse = !!binding.modifiers.noReverse

if ((currentScroll >= maxScrollLeft || currentScroll <= 0) && !noReverse) {
  elState.direction = !elState.direction
  elState.start = 0
}

noReverse 控制的是边界行为而非滚动公式本身;单向滚动模式下到终点后的重启体验比往返滚动更「生硬」,课程也明确提醒了这点。修饰符非常适合这种布尔型行为开关。

五、用新旧宽度比较修复初始抖动

后半段最关键的调试是初始抖动。加 console.log 后发现:mounted 进来一次,updated 紧接着又进来一次。于是 updated 里又把 startdirectionscrollLeft 等重置了一遍,导致刚开始滚就顿一下或闪一下。这说明 updated 虽需要,但不能无条件每次重置所有动画状态。

修复加了一层关键条件:只有当新的滚动宽度和旧的滚动宽度不一样时,才执行整套餐重置。这是给 updated 做一次去抖式判断。

ts
updated(el) {
  const newScrollWidth = el.scrollWidth - el.clientWidth

  if (elState.maxScrollLeft !== newScrollWidth) {
    elState.maxScrollLeft = newScrollWidth
    elState.start = 0
    elState.direction = true
    elState.oldScrollLeft = 0
    el.scrollLeft = 0
  }
}

updated 不等于「每次都重置」;只有当真正影响滚动模型的值变化了才应重置动画状态。这类抖动通常不是公式错了,而是生命周期处理太激进。

六、时间戳基准 start 必须读最新状态

课程还调了一处细 bug,核心和时间戳有关:把 start 从元素状态解构出来后,再拿局部值判断和赋值,语法没错,但在对时序极敏感的动画里,拿到的可能已经不是此刻最新的 start,恢复滚动时就会出现轻微闪烁或进度不准。

最终处理很明确:不要过早把 start 解构出来反复复用,而是在关键判断处直接读取并写回 elState.start

ts
function step(timestamp: number) {
  const elState = el._state!
  if (!elState.start) elState.start = timestamp
  const progress = timestamp - elState.start
}

动画状态里时间戳字段的读取时机非常敏感;局部变量和元素状态之间出现时间差,动画就容易轻微闪动。这说明在状态驱动动画里,「读最新状态」比「写法简洁」更重要。

七、能力演进总结

把本节和前两节放一起看,能力演进清晰:第一阶段只是让文字滚起来;第二阶段加入延迟、暂停恢复、更新重置和清理;第三阶段再把 durationdelaynoReverse 变成可配置能力,并修掉 mounted/updated 叠加带来的初始抖动。到这里,这个指令已不再是「课程小特效」,而是具备复用价值的扩展能力,能满足音频标题、歌词、跑马灯文本及不同滚动时长与等待时长的场景差异。


常见问题

问题原因解决方案
配了 delay 滚动开始还是会跳一下只做了等待没把 delay 从位移里扣掉currentScroll 计算里减去 delay
加了 noReverse 效果不对只切换布尔值但边界仍翻转方向noReverse 作用在「是否翻转方向」决策点
初始化文字会抖一下mounted 后紧接着 updated 又重置了一遍状态updated 中先比较新旧宽度,不变就不重置
恢复滚动后位置不连贯没保留暂停时的 oldScrollLeft恢复时把旧滚动值补进位移计算
为何 binding.value 一定要可选链不传值时可能是 undefinedbinding.value?.duration ?? 默认值 写法
start 有值恢复时仍闪过早解构旧值没拿最新状态关键判断处直接读写 state.start 避免时间差

延伸阅读