滚动控制:延迟执行、鼠标悬停暂停
概述
本节在上一节 scrollText 指令的基础上,增加两个重要的控制能力:延迟执行(首次加载后等待指定时间再开始滚动)和鼠标悬停暂停的精确控制。重点讲解如何在 requestAnimationFrame 动画循环中正确实现延迟启动,以及如何处理延迟期间 progress 计算的偏移问题。
延迟执行的实现思路
核心问题
在 requestAnimationFrame 的回调中,timestamp 参数持续递增。如果直接加入延迟判断,动画的 progress 会在延迟期间继续累积,导致延迟结束后出现跳跃。
时间轴分析
时间轴: |-- 延迟等待 --|---- 滚动动画 ----|-- 停顿 --|-- 反向滚动 --|
delay duration delay duration
text
错误实现(会导致跳跃)
// 错误:延迟期间 progress 继续累积
function animate(timestamp: number) {
if (timestamp < startTime + delay) {
requestAnimationFrame(animate)
return // 仅跳过本次渲染,但 timestamp 差值未补偿
}
// delay 期间的时间差被计入 progress,导致跳跃
const progress = (timestamp - startTime) / duration
el.style.transform = `translateX(${-progress * maxScroll}px)`
requestAnimationFrame(animate)
}
typescript
正确实现(补偿延迟时间)
interface ScrollState {
startTime: number | null
isPaused: boolean
isDelayed: boolean
currentOffset: number
direction: 'left' | 'right'
maxScroll: number
// ...
}
function animate(timestamp: number, el: HTMLElement, state: ScrollState, options: Options) {
if (state.isPaused) {
requestAnimationFrame((ts) => animate(ts, el, state, options))
return
}
// 首次进入:记录起始时间
if (state.startTime === null) {
state.startTime = timestamp
}
const elapsed = timestamp - state.startTime
// 延迟阶段:尚未开始滚动
if (elapsed < options.delay) {
requestAnimationFrame((ts) => animate(ts, el, state, options))
return
}
// 关键:用 (elapsed - delay) 计算 progress,补偿延迟时间
const scrollElapsed = elapsed - options.delay
const progress = Math.min(scrollElapsed / options.duration, 1)
if (state.direction === 'left') {
state.currentOffset = -progress * state.maxScroll
} else {
state.currentOffset = -state.maxScroll + progress * state.maxScroll
}
el.style.transform = `translateX(${state.currentOffset}px)`
// 到达边界
if (progress >= 1) {
handleBoundary(el, state, options)
return
}
requestAnimationFrame((ts) => animate(ts, el, state, options))
}
function handleBoundary(el: HTMLElement, state: ScrollState, options: Options) {
state.isPaused = true
setTimeout(() => {
// 切换方向并重置起始时间
state.direction = state.direction === 'left' ? 'right' : 'left'
state.startTime = null // 重置,让下一轮重新记录
state.isPaused = false
requestAnimationFrame((ts) => animate(ts, el, state, options))
}, options.delay)
}
typescript
鼠标悬停暂停的精确控制
暂停时间记录
直接设置 isPaused = true 会丢失暂停期间的时间进度。正确做法是记录暂停时刻,恢复时补偿差值:
interface ScrollState {
// ...
pauseTimestamp: number | null // 暂停时的时间戳
pauseOffset: number // 暂停时的偏移量
}
function handleMouseEnter(el: HTMLElement, state: ScrollState) {
if (!state.isPaused) {
state.isPaused = true
state.pauseTimestamp = performance.now()
state.pauseOffset = state.currentOffset
}
}
function handleMouseLeave(el: HTMLElement, state: ScrollState) {
if (state.isPaused && state.pauseTimestamp !== null) {
// 补偿暂停时长:将 startTime 向后推
const pauseDuration = performance.now() - state.pauseTimestamp
if (state.startTime !== null) {
state.startTime += pauseDuration
}
state.isPaused = false
state.pauseTimestamp = null
}
}
typescript
移动端触摸暂停
function bindPauseEvents(el: HTMLElement, state: ScrollState) {
// PC 端
el.addEventListener('mouseenter', () => handleMouseEnter(el, state))
el.addEventListener('mouseleave', () => handleMouseLeave(el, state))
// 移动端
el.addEventListener('touchstart', () => handleMouseEnter(el, state), { passive: true })
el.addEventListener('touchend', () => handleMouseLeave(el, state))
}
typescript
完整指令选项
interface ScrollTextOptions {
/** 滚动速度,影响 duration 计算,默认 50px/s */
speed?: number
/** 首次延迟时间(ms),默认 2000 */
delay?: number
/** 到达边界后停顿时间(ms),默认 2000 */
boundaryDelay?: number
/** 初始滚动方向,默认 'left' */
direction?: 'left' | 'right'
}
typescript
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
speed | number | 50 | 滚动速度(px/s),影响动画时长 |
delay | number | 2000 | 首次启动前的等待时间(ms) |
boundaryDelay | number | 2000 | 到达边界后的停顿时间(ms) |
direction | 'left' | 'right' | 'left' | 初始滚动方向 |
关键修复总结
| 问题 | 原因 | 修复方案 |
|---|---|---|
| 延迟后跳跃 | progress 计算未扣除 delay 时长 | 用 elapsed - delay 计算 scrollElapsed |
| 暂停后跳跃 | 恢复时 startTime 未补偿暂停时长 | 记录 pauseTimestamp,恢复时 startTime += pauseDuration |
| 反向后跳跃 | direction 切换但 offset 未重置 | 切换方向时重置 startTime = null |
实践要点
- 延迟执行的关键是补偿时间差,在 progress 计算中减去 delay 时长
- 鼠标悬停暂停时需记录暂停时刻,恢复时将 startTime 向后推对应时长
- 每次方向切换都应重置 startTime,让下一轮动画从零开始计时
- 使用
performance.now()替代Date.now()获取更高精度的时间戳
↑