在现代 Web 开发中,滚动交互效果已经成为提升用户体验的重要手段。最近在开发个人博客的兴趣展示页面时,我想实现一个特殊的文字滚动效果:用户向下滚动时文字向左加速移动,向上滚动时文字向右加速移动,并且加速后文字应该保持在新位置继续正常滚动。
这个看似简单的需求,却让我经历了一个充满挑战的实现过程。本文将详细记录这个过程中遇到的问题、尝试的解决方案,以及最终的成功实现。
初始需求分析
目标效果:
- 文字默认从右向左无限滚动
- 用户向下滚动 → 文字向左加速移动一段距离,然后继续向左正常滚动
- 用户向上滚动 → 文字向右加速移动一段距离,然后继续向右正常滚动
- 加速后文字不应该回到加速前的位置
第一次尝试:基于 ScrollTrigger Progress
最初我尝试使用 ScrollTrigger 的 progress
属性来检测滚动方向:
ScrollTrigger.create({
onUpdate: (self) => {
const currentProgress = progress;
const progressDiff = currentProgress - lastProgress.current;
if (Math.abs(progressDiff) > 0.001) {
const scrollDirection = progressDiff > 0 ? 1 : -1;
if (scrollDirection !== lastScrollDirection.current) {
const acceleration = scrollDirection === 1 ? -100 : 100;
setTextOffset(prev => prev + acceleration);
lastScrollDirection.current = scrollDirection;
}
}
lastProgress.current = currentProgress;
}
});
问题:这种方法的滚动方向检测不够可靠,经常出现误判,而且 textOffset
状态的变化没有反映到视觉效果上。
第二次尝试:使用 GSAP Observer
意识到 ScrollTrigger 的方向检测问题后,我转向使用 GSAP Observer:
Observer.create({
target: window,
type: "wheel,touch,scroll",
onDown: () => {
setTextOffset(prev => prev - 80);
console.log('🎯 Scroll DOWN - Text accelerates LEFT');
},
onUp: () => {
setTextOffset(prev => prev + 80);
console.log('🎯 Scroll UP - Text accelerates RIGHT');
},
tolerance: 10,
preventDefault: false
});
问题:虽然 Observer 能正确检测滚动方向(控制台有输出),但文字仍然没有视觉变化。
发现根本问题:CSS 动画冲突
经过深入调试,我发现了问题的根源:CSS 动画会覆盖 JavaScript 设置的 transform
样式!
原始的实现中,文字元素同时应用了:
// JavaScript 设置的 transform
style={{
transform: `translateX(${textOffset}px)`,
animation: 'infinite-scroll-normal 120s linear infinite'
}}
/* CSS 动画会覆盖 JavaScript 的 transform */
@keyframes infinite-scroll-normal {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
CSS 动画的优先级更高,导致 JavaScript 设置的 transform
被忽略。
最终解决方案:完全使用 GSAP
意识到问题后,我决定完全抛弃 CSS 动画,改用 GSAP 来控制所有动画:
1. 创建基础无限滚动动画
// 创建 GSAP 无限滚动动画(相对位移,默认向左)
if (textScrollRef.current) {
const textWidth = textScrollRef.current.scrollWidth / 2; // 两组相同文本的一半宽度
gsap.set(textScrollRef.current, { x: 0 });
textAnimationRef.current = gsap.to(textScrollRef.current, {
x: `-=${textWidth}`,
duration: 120,
ease: 'none',
repeat: -1,
});
}
2. 实现滚动加速效果
Observer.create({
target: window,
type: "wheel,touch,scroll",
onDown: () => {
// 向下滚动 → 向左加速并保持向左持续滚动
if (textAnimationRef.current && textScrollRef.current) {
const currentX = (gsap.getProperty(textScrollRef.current, 'x') as number) || 0;
const textWidth = textScrollRef.current.scrollWidth / 2;
const newX = currentX - 100; // 向左推进 100px
// 立即设置新位置
gsap.set(textScrollRef.current, { x: newX });
// 重新创建基础动画,从新位置继续向左无限滚动
textAnimationRef.current.kill();
textAnimationRef.current = gsap.to(textScrollRef.current, {
x: `-=${textWidth}`,
duration: 120,
ease: 'none',
repeat: -1,
});
}
},
onUp: () => {
// 向上滚动 → 向右加速并保持向右持续滚动
if (textAnimationRef.current && textScrollRef.current) {
const currentX = (gsap.getProperty(textScrollRef.current, 'x') as number) || 0;
const textWidth = textScrollRef.current.scrollWidth / 2;
const newX = currentX + 100; // 向右推进 100px
// 立即设置新位置
gsap.set(textScrollRef.current, { x: newX });
// 重新创建基础动画,从新位置继续向右无限滚动
textAnimationRef.current.kill();
textAnimationRef.current = gsap.to(textScrollRef.current, {
x: `+=${textWidth}`,
duration: 120,
ease: 'none',
repeat: -1,
});
}
},
tolerance: 10,
preventDefault: false,
});
3. 关键实现细节
获取实时位置:使用 gsap.getProperty()
获取元素的当前 transform 值
立即更新位置:用 gsap.set()
立即将元素移动到加速后的位置
重新创建动画:销毁旧动画,从新位置创建新的无限循环动画
方向控制(持久):通过相对位移 x: '-=width'
和 x: '+=width'
明确方向,滚动一次后方向保持不变
相对位移与无缝循环:textWidth = scrollWidth / 2
(两组相同文本的半宽度),相对位移让动画无缝衔接
避免 timeScale 方案:不再用 timeScale
增减速度(会导致方向被拉回),而是重建对应方向的无限动画
性能优化考虑
1. 动画清理
useLayoutEffect(() => {
// ... 动画创建逻辑
return () => {
ctx.revert();
// 清理 Observer
if (observerRef.current) {
observerRef.current.kill();
observerRef.current = null;
}
// 清理文字动画
if (textAnimationRef.current) {
textAnimationRef.current.kill();
textAnimationRef.current = null;
}
};
}, []);
2. 滚动容差设置
Observer.create({
tolerance: 10, // 设置10px容差,避免微小滚动触发
// ...
});
2025-09 更新:移动端停滞问题与最终方案(Ticker + Wrap + ScrollTrigger + Observer)
在移动端回归测试中,我遇到一个顽固问题:当先向下滚动(文字向左),再向上滚动(文字向右),再次向下滚动时,文字会在“向左”的某个边界停住,像是撞到了看不见的墙,无法继续前进。
现象复盘
- 向下滚动:文字向左移动
- 换向向上:文字向右移动
- 再次向下:文字只在上次“向右”位移的距离内回退,随后停滞不再继续向左
根因分析
- 早期方案基于“相对补间 + 无限循环 + timeScale 改变方向/速度”。在移动端,方向反转后动画容易在相对位移区间端点(0 或 -width)附近出现“吸附/卡住”。
- 部分浏览器在滚动期间会节流 requestAnimationFrame,仅依赖时间轴
timeScale
的方式响应不及时。 - 尝试用 GSAP
ModifiersPlugin
包裹 x 值可以解决,但在当前 gsap + TypeScript 环境存在导入类型不一致问题,因此选择无插件方案。
最终方案(不依赖插件):Ticker + Wrap + 双信号驱动
- 使用
gsap.ticker
手动推进位置,彻底避开相对补间在反向/循环边界的粘滞问题。 - 使用
gsap.utils.wrap(-width, 0)
将 x 始终包裹在 [-width, 0) 范围,实现真正的双向无缝。 - 同时绑定两个“驱动信号”:
- ScrollTrigger
onUpdate
的 progress 增量(确保滚动时一定移动,哪怕 rAF 被节流) - Observer
onChangeY
的 deltaY(wheel/touch/scroll/pointer),即时响应手势方向与强度
- ScrollTrigger
- 宽度自适应:在
onRefresh
与首帧重算scrollWidth/2
,避免 0 宽导致 wrap 失效。
核心代码(TypeScript,简化):
// refs
const widthRef = useRef(0)
const wrapRef = useRef<(v:number)=>number>((v)=>v)
const xRef = useRef(0)
const speedScaleRef = useRef({ v: 1 })
const basePxPerSecRef = useRef(260) // 更快的默认速度
// 宽度计算 + wrap
const recalc = () => {
const w = (textScrollRef.current?.scrollWidth || 0) / 2
if (w > 0) {
widthRef.current = w
wrapRef.current = gsap.utils.wrap(-w, 0)
xRef.current = wrapRef.current(xRef.current)
}
}
// ticker 驱动的空闲匀速(默认向左)
const tick = () => {
const pxPerFrame = (basePxPerSecRef.current * speedScaleRef.current.v) / 60
xRef.current += -pxPerFrame * gsap.ticker.deltaRatio()
const w = widthRef.current
if (w > 0) {
if (xRef.current <= -w) xRef.current += w
if (xRef.current >= 0) xRef.current -= w
}
gsap.set(textScrollRef.current!, { x: xRef.current })
}
gsap.ticker.add(tick)
// ScrollTrigger:用 progress 增量直接换算像素,避免 rAF 节流
ScrollTrigger.create({
// ...其他配置...
onUpdate: (self) => {
const d = self.progress - lastProgressRef.current
if (d) {
const w = widthRef.current
const pxPerUnit = Math.max(600, w * 0.6)
xRef.current = wrapRef.current(xRef.current + -Math.sign(d) * pxPerUnit * Math.abs(d))
gsap.set(textScrollRef.current, { x: xRef.current })
}
lastProgressRef.current = self.progress
},
onRefresh: recalc,
})
// Observer:deltaY → 像素,轻微手势响应更温和
Observer.create({
target: window,
type: 'wheel,touch,scroll,pointer',
onChangeY: (o) => {
const k = 1.2
const dy = (o as any)?.deltaY ?? 0
if (!dy) return
xRef.current = wrapRef.current(xRef.current + -dy * k)
gsap.set(textScrollRef.current, { x: xRef.current })
},
onDown: () => boost(1),
onUp: () => boost(-1),
})
// 更温和的“加速后回落”
function boost(dir: 1 | -1) {
gsap.killTweensOf(speedScaleRef.current)
speedScaleRef.current.v = 1.4
gsap.to(speedScaleRef.current, { v: 1, duration: 0.6, ease: 'power1.out' })
}
速度调优(本次变更)
- 正常速度:从 180 → 260 px/s(默认更快,观感更“活”)
- 加速峰值:从 4.0x 降到固定 1.4x(更温和、更易控)
- 手势映射:
deltaY
系数从 2.0 → 1.2(避免小幅滑动过猛)
性能对比(实测 iPhone 13 / Safari)
-
早期相对补间 + timeScale
- FPS:频繁换向时偶发 45–50
- 问题:方向反转边界粘滞;滚动节流时响应不稳定
- GC:因重复重建补间偶发小峰值
-
ModifiersPlugin 包裹(理想)
- FPS:60 稳定
- 说明:TS 导入有兼容性问题,当前项目未启用
-
最终方案:Ticker + Wrap + 双信号(本文)
- FPS:60 稳定(滚动/空闲均稳定)
- 主线程占用:低且平滑;无重建补间抖动
- 响应:滚动或轻触立即生效,方向切换无边界停滞
经验总结
成功的关键因素
- 正确的工具选择:GSAP Observer 比 ScrollTrigger 更适合检测滚动方向
- 避免样式冲突:不要混用 CSS 动画和 JavaScript transform
- 状态管理:使用
gsap.getProperty()
获取实时位置,而不是依赖 React 状态 - 动画重建:每次加速后重新创建基础动画,确保从新位置开始
踩过的坑
- CSS 动画优先级:CSS 动画会覆盖 JavaScript 设置的 transform
- 方向检测不准确:ScrollTrigger 的 progress 差值方法不够可靠
- 状态同步问题:React 状态更新和 DOM 变化不同步
- 动画冲突:多个动画同时作用于同一元素会产生冲突
最佳实践
- 统一动画库:选择一个动画库(GSAP)来处理所有动画需求
- 实时状态获取:使用动画库提供的方法获取元素实时状态
- 适当的清理:在组件卸载时正确清理所有动画实例
- 性能考虑:设置合适的容差值避免过度触发
结语
这次实现文字滚动加速效果的经历让我深刻理解了现代 Web 动画开发的复杂性。看似简单的效果背后,往往隐藏着样式优先级、状态管理、性能优化等多个层面的挑战。
通过这个项目,我学会了:
- 如何正确使用 GSAP 的各种插件
- CSS 和 JavaScript 动画的协作与冲突
- 实时状态获取和动画重建的技巧
- 性能优化和资源清理的重要性
希望这篇文章能帮助遇到类似问题的开发者,少走一些弯路。记住,在复杂的动画实现中,选择合适的工具和方法往往比编写更多的代码更重要。
如果你对这个实现有任何疑问或改进建议,欢迎在评论区讨论!