{T}

滚动文本指令延迟控制、暂停恢复与状态重置

概述

上一节搭出了滚动文本指令的基础模型:单行、溢出才滚、requestAnimationFrame() 驱动、到边界切换方向。但那还是「能跑版」。这一节补齐真正可复用的交互细节:开始前延迟、resize 后重算、鼠标移入暂停/移出恢复、移动端按住暂停、内容更新后从头重滚、组件卸载后清理动画与监听。

这些细节都属于同一个问题:指令现在不只在做「滚动」,而是在维护一个完整的动画状态机。核心状态是 delaydirectionoldScrollLeftstart,外加 animating 逻辑锁。

学习目标

  • delay 纳入动画进度计算,而非只用定时器包裹,避免滚动突变
  • delaydirection 叠加后把位移公式拆开按方向分别求值,提升可维护性
  • resize 时先重算 maxScrollLeft,不需要滚动就彻底重置状态
  • animating 逻辑锁实现 requestAnimationFrame() 的「暂停」,而非硬性终止 API
  • oldScrollLeft 记录暂停位置,保证恢复时从原位继续而非闪跳
  • updated 重置内容切换后的滚动状态,用 beforeUnmount 清理监听与动画

一、从能滚到可用的细节清单

基础版能跑,但距离可复用还差一段距离。这一节要补的是更真实的交互细节:开始前延迟、resize 后重算、鼠标悬停暂停、移动端按住暂停、内容更新后重新从头滚、组件销毁后停止动画。这些细节看似分散,其实都是同一个问题——指令现在是在维护一个完整的动画状态。

code
基础版:能滚
完整版:能滚、能等、能停、能恢复、能重置、能清理

动画功能进入细节阶段后,最重要的是状态管理而不是单个公式;这些状态最好逐步统一挂到元素本身,不要再分散成一堆临时变量。

二、延迟要纳入进度计算

课程首先补「延迟开始滚动」。直觉上会先等几秒再开始动画,但如果只在逻辑上延迟执行而不把延迟时间纳入 progress 计算,文本会从起点突然跳到后面某位置——因为动画进度实际已在悄悄流逝。

正确做法是两步:开始阶段判断 timestamp < start + delay 时直接 return;真正计算滚动距离时把 delayprogress 里扣掉。这样动画才从真正的 0 位置开始,而不是延迟完突然补跑一段进度。

ts
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 后拆开位移公式

随着 delaydirection 叠加,原先那条位移公式越来越臃肿。课程做了一次重要整理:先声明 let currentScroll,再根据 direction 分支分别计算。因为现在不只有正向/反向差别,还多了 delayoffsetoldScrollLeft,继续塞进一条表达式后面调试暂停恢复几乎不可能看清。

ts
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 = 0start = 0direction = true。也就是说 resize 是一次状态重算,而不是简单续播。

ts
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

ts
function step(timestamp: number) {
  if (!elState.animating) return
  // 后续滚动逻辑
}

这很像给动画主循环加一个逻辑闸门:鼠标移入时关掉,移出时再打开并重新 requestAnimationFrame(step)。这里暂停的是「逻辑推进」,不是硬性终止浏览器 API;这种逻辑锁思路非常适合复杂动画状态控制。

六、用 oldScrollLeft 保证恢复不闪跳

调试出典型问题:鼠标移入停住了,移出后虽又开始滚,但明显闪一下像从别的位置重启。根源是恢复时重置了 start,却没记录「停住时已经滚到哪儿了」。课程引入 oldScrollLeft:暂停时把当前 el.scrollLeft 存下来,恢复时加回后续位移计算。对于反向滚动还要先算 offset = maxScrollLeft - oldScrollLeft

ts
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 里要统一重置 scrollLeftstartdirectionoldScrollLeft 并重新计算 maxScrollLeft

第二是 beforeUnmount:这套逻辑挂了 resizemouseenter/leavetouchstart/end,还在持续跑 requestAnimationFrame,组件销毁时必须清理,否则动画在后台跑、已销毁元素还在访问状态、控制台不停报错。

ts
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 中重置 scrollLeftstartdirection
移动端按住暂停没效果没绑定触摸事件补上 touchstart 暂停和 touchend 恢复

延伸阅读