滚动指令 scrollText 测试及修复初次滚动跳跃 bug
问题描述
在实现 scrollText 指令的延迟启动功能后,测试发现:首次滚动时文字从初始位置突然跳跃到偏移位置,而非平滑滚动。延迟等待结束后,动画没有从 offset = 0 开始,而是直接跳到了一个非零偏移量。
问题定位
根因分析
在 requestAnimationFrame 回调中,timestamp 是从页面加载开始持续递增的高精度时间戳。当我们在延迟阶段直接 return 跳过渲染时,延迟期间的时间差并未从 progress 计算中扣除:
// 问题代码:延迟后 progress 直接用了 timestamp - startTime
// delay 期间累积了 2-3 秒的时间差,导致 progress 瞬间超过预期值
function animate(timestamp: number) {
if (!startTime) startTime = timestamp
// 延迟阶段:仅 return,未补偿时间
if (timestamp < startTime + delay) {
requestAnimationFrame(animate)
return
}
// 问题:此时 timestamp - startTime 已包含 delay 时长
const progress = (timestamp - startTime) / duration
// progress 瞬间 > 0,导致跳跃
}
typescript
时间线对比
正常期望: |--delay(2s)--|--滚动(progress: 0→1)--|
实际行为: |--delay(2s)--|--跳跃(progress直接≈0.5+)--|
↑ delay 时长被计入 progress
text
修复方案
方案一:补偿延迟时长
在 progress 计算中减去 delay 时长:
function animate(timestamp: number, state: ScrollState, options: Options) {
if (state.startTime === null) {
state.startTime = timestamp
}
const elapsed = timestamp - state.startTime
// 延迟阶段
if (elapsed < options.delay) {
requestAnimationFrame((ts) => animate(ts, state, options))
return
}
// 修复:减去 delay,只计算滚动阶段的耗时
const scrollElapsed = elapsed - options.delay
const progress = Math.min(scrollElapsed / options.duration, 1)
updatePosition(state, progress)
if (progress < 1) {
requestAnimationFrame((ts) => animate(ts, state, options))
}
}
typescript
方案二:延迟结束后重置 startTime
在延迟结束时将 startTime 重置为当前 timestamp,使 progress 从 0 开始计算:
function animate(timestamp: number, state: ScrollState, options: Options) {
if (state.startTime === null) {
state.startTime = timestamp
}
const elapsed = timestamp - state.startTime
if (elapsed < options.delay) {
// 延迟结束前,重置 startTime 为延迟结束的时刻
// 这样下一帧的 elapsed 将从接近 0 开始
if (elapsed >= options.delay - 16) {
// 提前一帧重置
state.startTime = timestamp - options.delay
}
requestAnimationFrame((ts) => animate(ts, state, options))
return
}
// 此时 elapsed - delay ≈ 0,progress 从 0 开始
const scrollElapsed = elapsed - options.delay
const progress = Math.min(scrollElapsed / options.duration, 1)
updatePosition(state, progress)
requestAnimationFrame((ts) => animate(ts, state, options))
}
typescript
方案三(推荐):独立的 startScrollTime
将延迟结束时刻单独记录,使逻辑更清晰:
interface ScrollState {
startTime: number | null // 动画整体起始时间
scrollStartTime: number | null // 实际滚动起始时间(延迟结束后)
isPaused: boolean
currentOffset: number
direction: 'left' | 'right'
maxScroll: number
}
function animate(timestamp: number, state: ScrollState, options: Options) {
if (state.startTime === null) {
state.startTime = timestamp
}
const elapsed = timestamp - state.startTime
// 延迟阶段
if (elapsed < options.delay) {
requestAnimationFrame((ts) => animate(ts, state, options))
return
}
// 延迟结束,记录滚动起始时间
if (state.scrollStartTime === null) {
state.scrollStartTime = timestamp
}
// progress 基于 scrollStartTime 计算,从 0 开始
const scrollElapsed = timestamp - state.scrollStartTime
const progress = Math.min(scrollElapsed / options.duration, 1)
updatePosition(state, progress)
if (progress >= 1) {
handleBoundary(state, options)
return
}
requestAnimationFrame((ts) => animate(ts, state, options))
}
function handleBoundary(state: ScrollState, options: Options) {
state.isPaused = true
setTimeout(() => {
state.direction = state.direction === 'left' ? 'right' : 'left'
// 重置滚动起始时间,让下一轮动画从 0 开始
state.startTime = null
state.scrollStartTime = null
state.isPaused = false
}, options.boundaryDelay)
}
typescript
修复效果对比
| 指标 | 修复前 | 修复后 |
|---|---|---|
| 延迟后首帧 progress | 约 0.3-0.5(跳跃) | 精确为 0(平滑) |
| 视觉效果 | 突然跳跃到中间位置 | 从起始位置平滑滚动 |
| 动画时长 | 实际比预期短 | 精确符合设定 |
测试要点
// 单元测试验证
describe('scrollText 指令', () => {
it('延迟结束后应从 offset=0 开始滚动', async () => {
const el = document.createElement('div')
el.style.width = '100px'
el.textContent = '很长的文本内容'.repeat(10)
// Object.defineProperty(el, 'scrollWidth', { value: 300 })
// Object.defineProperty(el, 'clientWidth', { value: 100 })
scrollText.mounted(el, { value: { delay: 100, speed: 50 } })
// 等待延迟结束
await new Promise(r => setTimeout(r, 150))
// 验证首帧偏移量接近 0
const transform = el.style.transform
const match = transform.match(/translateX\((.+?)px\)/)
const offset = Math.abs(parseFloat(match![1]))
expect(offset).toBeLessThan(2) // 允许 1-2px 误差
})
})
typescript
实践要点
requestAnimationFrame的timestamp参数从页面加载持续递增,不会因return而暂停- 延迟阶段的
return仅跳过渲染,时间差仍会累积到 progress 计算中 - 推荐使用独立的
scrollStartTime记录实际滚动起始时间,逻辑最清晰 - 方向切换时需同时重置
startTime和scrollStartTime,避免下一轮动画跳跃
↑