实现基于滚动的文字加速效果:从失败到成功的 GSAP 实践

深入探讨如何使用 GSAP ScrollTrigger 和 Observer 实现文字滚动加速效果,记录从最初的失败尝试到最终成功实现的完整过程。

January 9, 2025 (9mo ago)
5 min read
2,447 views
142 likes
Category
Beginner
#welcome#introduction#blog

在现代 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 + 双信号驱动

  1. 使用 gsap.ticker 手动推进位置,彻底避开相对补间在反向/循环边界的粘滞问题。
  2. 使用 gsap.utils.wrap(-width, 0) 将 x 始终包裹在 [-width, 0) 范围,实现真正的双向无缝。
  3. 同时绑定两个“驱动信号”:
    • ScrollTrigger onUpdate 的 progress 增量(确保滚动时一定移动,哪怕 rAF 被节流)
    • Observer onChangeY 的 deltaY(wheel/touch/scroll/pointer),即时响应手势方向与强度
  4. 宽度自适应:在 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 稳定(滚动/空闲均稳定)
    • 主线程占用:低且平滑;无重建补间抖动
    • 响应:滚动或轻触立即生效,方向切换无边界停滞

经验总结

成功的关键因素

  1. 正确的工具选择:GSAP Observer 比 ScrollTrigger 更适合检测滚动方向
  2. 避免样式冲突:不要混用 CSS 动画和 JavaScript transform
  3. 状态管理:使用 gsap.getProperty() 获取实时位置,而不是依赖 React 状态
  4. 动画重建:每次加速后重新创建基础动画,确保从新位置开始

踩过的坑

  1. CSS 动画优先级:CSS 动画会覆盖 JavaScript 设置的 transform
  2. 方向检测不准确:ScrollTrigger 的 progress 差值方法不够可靠
  3. 状态同步问题:React 状态更新和 DOM 变化不同步
  4. 动画冲突:多个动画同时作用于同一元素会产生冲突

最佳实践

  1. 统一动画库:选择一个动画库(GSAP)来处理所有动画需求
  2. 实时状态获取:使用动画库提供的方法获取元素实时状态
  3. 适当的清理:在组件卸载时正确清理所有动画实例
  4. 性能考虑:设置合适的容差值避免过度触发

结语

这次实现文字滚动加速效果的经历让我深刻理解了现代 Web 动画开发的复杂性。看似简单的效果背后,往往隐藏着样式优先级、状态管理、性能优化等多个层面的挑战。

通过这个项目,我学会了:

  • 如何正确使用 GSAP 的各种插件
  • CSS 和 JavaScript 动画的协作与冲突
  • 实时状态获取和动画重建的技巧
  • 性能优化和资源清理的重要性

希望这篇文章能帮助遇到类似问题的开发者,少走一些弯路。记住,在复杂的动画实现中,选择合适的工具和方法往往比编写更多的代码更重要。


如果你对这个实现有任何疑问或改进建议,欢迎在评论区讨论!

CleanLove

Written by CleanLove

Full-stack developer passionate about modern web technologies

Initializing application
Loading page content, please wait...