滚动文本指令配置化、无反向模式与初始抖动修复
概述
上一节滚动文本指令已具备延迟、暂停恢复、更新重置和生命周期清理,但 duration、delay、noReverse 仍是写死的。这意味着它「功能完整」却不算通用。这一节把指令从「课程演示版」推进到「配置化版」:用 binding.value 承载数值配置、用 binding.modifiers 承载行为开关;并从 mounted + updated 连击带来的初始抖动问题中,提炼出「只在宽度真变了才重置」的去抖判断。
至此,滚动文本指令从「会滚」演进为「可配置、可暂停、可恢复、可更新、可清理」的完整交互指令。
学习目标
- 把写死的
duration/delay提炼成配置项,迈向通用能力 - 用
binding.value传数值、binding.modifiers传布尔开关,匹配指令参数设计惯例 - 在所有进度计算路径统一使用可配置值,避免分支失衡
- 用
noReverse修饰符只控制「到边界是否翻转方向」这一策略节点,而非复制整套逻辑 - 用「新旧滚动宽度比较」给
updated做去抖判断,修复初始抖动 - 确保暂停恢复时直接读写最新
state.start,避免时间差导致的闪烁
一、从固定逻辑到可配置逻辑
指令可用之后,下一步不是继续堆逻辑,而是把写死常量提炼成配置项。当前 duration 写死、delay 写死、反向滚动是否开启也写死,意味着不同场景想控制「滚动一轮用多久」「开始前等待多久」「是否反向滚动」都得改源码。真正的通用能力应允许这些由外部决定。
固定逻辑 -> 可配置逻辑
写死常量 -> 来自 binding 的参数组件或指令一旦开始复用,常量就要优先考虑参数化;配置化不是为了炫技,而是减少后续复制出多个相似版本。本节重点不是新增动画种类,而是让原有动画可以调。
二、binding.value 与 binding.modifiers 分工
自定义指令接收外部数据主要有两条通路:binding.value 和 binding.modifiers,适合承载的内容不同。binding.value 适合传具体数值(如 duration、delay),binding.modifiers 适合传布尔开关(如「要不要反向滚动」)。所以参数设计很顺手:duration、delay 放 binding.value,noReverse 放修饰符。
<div v-scroll-text="{ duration: 3000, delay: 1000 }" />
<div v-scroll-text.no-reverse="{ duration: 3000, delay: 1000 }" />mounted(el, binding) {
const duration = binding.value?.duration ?? 10000
const delay = binding.value?.delay ?? 2000
const noReverse = !!binding.modifiers.noReverse
}binding.value 可能不存在,读取时要加可选链或默认值;修饰符本质是布尔对象,取不到时是 undefined。这是典型的「对象配置 + 修饰符开关」组合用法。
三、配置值要全链路替换
把 duration 和 delay 从常量改成配置值后,最该警惕的是别只改一处、其他地方还留旧常量。当前计算链条至少涉及:是否开始滚动的判断、progress 有效时间段、正向位移、反向位移、边界切换后的重新开始。其中任何一个还用旧固定值,动画表现都会不一致。
const duration = binding.value?.duration ?? 10000
const delay = binding.value?.delay ?? 2000
const rate = maxScrollLeft / duration一旦配置项进入动画公式,就要确保所有相关分支统一使用它;默认值仍要保留,避免用户不传时失去基础行为。这种改造是「全链路替换」而非局部替换。
四、noReverse 只作用于策略节点
课程给指令增加了 noReverse 能力,它的意思不是「禁止滚动」,而是到了终点后不再反向往回滚,而是等待后重新从头开始。最值得学的是实现方式:没有复制一套「单向滚动版」逻辑,而是只抓住关键点——到边界后要不要翻转 direction。
const noReverse = !!binding.modifiers.noReverse
if ((currentScroll >= maxScrollLeft || currentScroll <= 0) && !noReverse) {
elState.direction = !elState.direction
elState.start = 0
}noReverse 控制的是边界行为而非滚动公式本身;单向滚动模式下到终点后的重启体验比往返滚动更「生硬」,课程也明确提醒了这点。修饰符非常适合这种布尔型行为开关。
五、用新旧宽度比较修复初始抖动
后半段最关键的调试是初始抖动。加 console.log 后发现:mounted 进来一次,updated 紧接着又进来一次。于是 updated 里又把 start、direction、scrollLeft 等重置了一遍,导致刚开始滚就顿一下或闪一下。这说明 updated 虽需要,但不能无条件每次重置所有动画状态。
修复加了一层关键条件:只有当新的滚动宽度和旧的滚动宽度不一样时,才执行整套餐重置。这是给 updated 做一次去抖式判断。
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。
function step(timestamp: number) {
const elState = el._state!
if (!elState.start) elState.start = timestamp
const progress = timestamp - elState.start
}动画状态里时间戳字段的读取时机非常敏感;局部变量和元素状态之间出现时间差,动画就容易轻微闪动。这说明在状态驱动动画里,「读最新状态」比「写法简洁」更重要。
七、能力演进总结
把本节和前两节放一起看,能力演进清晰:第一阶段只是让文字滚起来;第二阶段加入延迟、暂停恢复、更新重置和清理;第三阶段再把 duration、delay、noReverse 变成可配置能力,并修掉 mounted/updated 叠加带来的初始抖动。到这里,这个指令已不再是「课程小特效」,而是具备复用价值的扩展能力,能满足音频标题、歌词、跑马灯文本及不同滚动时长与等待时长的场景差异。
常见问题
| 问题 | 原因 | 解决方案 |
|---|---|---|
配了 delay 滚动开始还是会跳一下 | 只做了等待没把 delay 从位移里扣掉 | 在 currentScroll 计算里减去 delay |
加了 noReverse 效果不对 | 只切换布尔值但边界仍翻转方向 | 把 noReverse 作用在「是否翻转方向」决策点 |
| 初始化文字会抖一下 | mounted 后紧接着 updated 又重置了一遍状态 | updated 中先比较新旧宽度,不变就不重置 |
| 恢复滚动后位置不连贯 | 没保留暂停时的 oldScrollLeft | 恢复时把旧滚动值补进位移计算 |
为何 binding.value 一定要可选链 | 不传值时可能是 undefined | 用 binding.value?.duration ?? 默认值 写法 |
start 有值恢复时仍闪 | 过早解构旧值没拿最新状态 | 关键判断处直接读写 state.start 避免时间差 |