滚动文本指令延迟控制、暂停恢复与状态重置
概述
上一节搭出了滚动文本指令的基础模型:单行、溢出才滚、requestAnimationFrame() 驱动、到边界切换方向。但那还是「能跑版」。这一节补齐真正可复用的交互细节:开始前延迟、resize 后重算、鼠标移入暂停/移出恢复、移动端按住暂停、内容更新后从头重滚、组件卸载后清理动画与监听。
这些细节都属于同一个问题:指令现在不只在做「滚动」,而是在维护一个完整的动画状态机。核心状态是 delay、direction、oldScrollLeft、start,外加 animating 逻辑锁。
学习目标
- 把
delay纳入动画进度计算,而非只用定时器包裹,避免滚动突变 - 在
delay与direction叠加后把位移公式拆开按方向分别求值,提升可维护性 - 在
resize时先重算maxScrollLeft,不需要滚动就彻底重置状态 - 用
animating逻辑锁实现requestAnimationFrame()的「暂停」,而非硬性终止 API - 用
oldScrollLeft记录暂停位置,保证恢复时从原位继续而非闪跳 - 用
updated重置内容切换后的滚动状态,用beforeUnmount清理监听与动画
一、从能滚到可用的细节清单
基础版能跑,但距离可复用还差一段距离。这一节要补的是更真实的交互细节:开始前延迟、resize 后重算、鼠标悬停暂停、移动端按住暂停、内容更新后重新从头滚、组件销毁后停止动画。这些细节看似分散,其实都是同一个问题——指令现在是在维护一个完整的动画状态。
基础版:能滚
完整版:能滚、能等、能停、能恢复、能重置、能清理动画功能进入细节阶段后,最重要的是状态管理而不是单个公式;这些状态最好逐步统一挂到元素本身,不要再分散成一堆临时变量。
二、延迟要纳入进度计算
课程首先补「延迟开始滚动」。直觉上会先等几秒再开始动画,但如果只在逻辑上延迟执行而不把延迟时间纳入 progress 计算,文本会从起点突然跳到后面某位置——因为动画进度实际已在悄悄流逝。
正确做法是两步:开始阶段判断 timestamp < start + delay 时直接 return;真正计算滚动距离时把 delay 从 progress 里扣掉。这样动画才从真正的 0 位置开始,而不是延迟完突然补跑一段进度。
const delay = 2000
function step(timestamp: number) {
if (!elState.start) elState.start = timestamp
if (timestamp < elState.start + delay) {
requestAnimationFrame(step)
return
}
const progress = timestamp - elState.start
const currentScroll = (progress - delay) * rate
}延迟是动画状态的一部分,不是简单外层定时器;不把 delay 纳入位移计算就会出现明显跳帧感。课程既处理了开始延迟,也处理了反向滚动时的再次延迟。
三、叠加 delay 与 direction 后拆开位移公式
随着 delay 和 direction 叠加,原先那条位移公式越来越臃肿。课程做了一次重要整理:先声明 let currentScroll,再根据 direction 分支分别计算。因为现在不只有正向/反向差别,还多了 delay、offset、oldScrollLeft,继续塞进一条表达式后面调试暂停恢复几乎不可能看清。
let currentScroll = 0
if (elState.direction) {
currentScroll = ((progress - delay) * rate) + oldScrollLeft
} else {
const offset = oldScrollLeft ? maxScrollLeft - oldScrollLeft : 0
currentScroll = (duration + delay - progress - offset) * rate
}动画公式一旦出现多个变量耦合就应及时拆开;可读性在这类调试密集型逻辑里非常重要。这一步实质是在为后面的暂停恢复扫清障碍。
四、resize 先判断还需不需要滚
窗口尺寸变化时,最先要重算的不是「继续算下去」,而是先回答更基础的问题:现在这个元素还需要滚动吗?窗口变宽后内容可能已经完全放得下,这时最正确的行为不是保留旧滚动位置,而是 scrollLeft = 0、start = 0、direction = true。也就是说 resize 是一次状态重算,而不是简单续播。
const resizeHandler = () => {
elState.maxScrollLeft = el.scrollWidth - el.clientWidth
if (elState.maxScrollLeft <= 0) {
elState.start = 0
elState.direction = true
el.scrollLeft = 0
}
}
window.addEventListener("resize", resizeHandler)resize 后最先重算 maxScrollLeft;不需要滚动时立即把状态清回初始值;监听清理也不能省。
五、用 animating 逻辑锁实现暂停
课程补「鼠标移入暂停、移出恢复」和「移动端按住暂停」时,关键前提是:requestAnimationFrame() 本身不像定时器那样有天然暂停句柄。所以做法不是去中断这个 API,而是在元素状态里增加 animating,在 step() 最前面判断,如果不允许动画就直接 return。
function step(timestamp: number) {
if (!elState.animating) return
// 后续滚动逻辑
}这很像给动画主循环加一个逻辑闸门:鼠标移入时关掉,移出时再打开并重新 requestAnimationFrame(step)。这里暂停的是「逻辑推进」,不是硬性终止浏览器 API;这种逻辑锁思路非常适合复杂动画状态控制。
六、用 oldScrollLeft 保证恢复不闪跳
调试出典型问题:鼠标移入停住了,移出后虽又开始滚,但明显闪一下像从别的位置重启。根源是恢复时重置了 start,却没记录「停住时已经滚到哪儿了」。课程引入 oldScrollLeft:暂停时把当前 el.scrollLeft 存下来,恢复时加回后续位移计算。对于反向滚动还要先算 offset = maxScrollLeft - oldScrollLeft。
const pauseScroll = () => {
elState.animating = false
elState.oldScrollLeft = el.scrollLeft
}
const resumeScroll = () => {
if (elState.animating) return
elState.animating = true
elState.start = 0
requestAnimationFrame(step)
}只重置 start 不记录旧位置,恢复动画会产生明显跳变;正向和反向滚动在恢复时的偏移处理不一样。这一步是整个暂停恢复体验是否丝滑的核心。
七、updated 与 beforeUnmount 不能省
最后补两个关键生命周期。第一是 updated:标题切换后如果只在 mounted 初始化滚动,新标题会沿用旧标题的滚动进度,明显不对。所以 updated 里要统一重置 scrollLeft、start、direction、oldScrollLeft 并重新计算 maxScrollLeft。
第二是 beforeUnmount:这套逻辑挂了 resize、mouseenter/leave、touchstart/end,还在持续跑 requestAnimationFrame,组件销毁时必须清理,否则动画在后台跑、已销毁元素还在访问状态、控制台不停报错。
updated(el) {
el.scrollLeft = 0
elState.maxScrollLeft = el.scrollWidth - el.clientWidth
elState.start = 0
elState.direction = true
elState.oldScrollLeft = 0
},
beforeUnmount(el) {
elState.animating = false
el.scrollLeft = 0
window.removeEventListener("resize", elState.resizeHandler)
el.removeEventListener("mouseenter", elState.pauseScroll)
el.removeEventListener("mouseleave", elState.resumeScroll)
el.removeEventListener("touchstart", elState.pauseScroll)
el.removeEventListener("touchend", elState.resumeScroll)
}内容更新时重置滚动状态是标题滚动非常重要的体验细节;动画型指令的 beforeUnmount 清理比普通样式类指令更重要。课程调试「切到别的页面后动画还在跑」,本质就是清理没做好。
常见问题
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 加延迟后文本一开始突然跳一下 | 延迟只拦住执行没扣除动画进度 | 在 currentScroll 计算里减去 delay |
| 鼠标移出后滚动会闪一下 | 只重置 start 没保留暂停时的旧位置 | 增加 oldScrollLeft 并在恢复时补进位移 |
| 屏幕变宽后已放得下仍在滚 | resize 后没重算 maxScrollLeft | 重算后若 <= 0 就把状态和 scrollLeft 全部重置 |
| 切到别的页面控制台还在打印 | 卸载时没停止动画和清理监听 | beforeUnmount 关掉 animating 并移除所有监听 |
| 标题切换后新标题从中间开始滚 | 只在 mounted 初始化没处理更新 | updated 中重置 scrollLeft、start、direction |
| 移动端按住暂停没效果 | 没绑定触摸事件 | 补上 touchstart 暂停和 touchend 恢复 |