Goals 手机端划线“提前跑完”?一次 pinSpacing=false 引发的错觉与修复
这次的 bug 乍看很简单:手机端 Goals 文案的划线和颜色变深效果没了。
但实际情况更“反直觉”:动画并没有消失,而是——在你真正看到文字之前,它已经跑完了。
本文记录从现象、排查、到最终修复的全过程,并给出一个我认为更平衡的方案:不改上游布局,改用“真实视口位置”驱动文案进度。
现象:我滚到了 Goals,却看不到划线动画
受影响组件:
src/components/goals-showcase/GoalsCardShowcase.tsx
移动端区域(lg:hidden)有三行大标题,预期效果是:
- 文字从灰色逐渐变深
- 下划线(用
background-size实现)从 0% 拉到 100%
但在移动端模拟器(Chrome / Edge)里,肉眼看到的结果是:它们一出现就是“最终态”,于是很容易误判成“动画没了”。
第一步排查:加 markers + 日志,确认 ScrollTrigger 是否在跑
我先做了最直观的验证:
- 确认 mobile 分支代码是否执行(matchMedia)
- 确认拿到 3 个 span(ref 列表)
- 给每个文字 trigger 开 markers,滚动时打印 progress
调试开关:
- 打开首页时带上
?goalsDebug=1
结果非常关键:
count: 3✅(节点拿到了)onUpdate progress一直在变化 ✅(触发器在跑)- markers 显示 trigger 的 start/end 也在正常移动 ✅
所以问题不是“ScrollTrigger 不工作”,而是——它工作得太早了。
关键线索:markers 已到 scroller-end,但屏幕还停在上一个 pinned section
当我把页面从头滚到尾,看到的 markers 截图大概是:
- Goals 的
scroller-end已经过去 - 但屏幕视觉上仍停留在上游的 pinned 动画区域
这意味着一件事:滚动条(scrollbar)在推进,但视觉内容并没有按同等比例往下“露出”。
Root Cause:上游 pinSpacing=false 造成“滚动条推进 ≠ 视觉推进”
在首页 Goals 之前有一个文字滚动 section(TextScrollSection),里面存在 pin: true 且 pinSpacing: false 的配置:
src/components/ui/text-scroll-section.tsx
当 pinSpacing: false 时:
- pinned 元素会固定在视口里
- 但 ScrollTrigger 不会在文档流中插入 spacer 占位
结果就是:
- 页面滚动值继续增加
- 但后续 section 在视觉上还没出现
- 于是后续基于 ScrollTrigger progress 的 scrub 动画,会“在你看到它之前”就跑完
这就是本次“下划线消失”的错觉来源。
两种方案:改上游布局 vs. 局部容错
方案 A(更“正统”,但影响范围大):修 pinSpacing / 手动 spacer
思路:
- 把
pinSpacing:false改成true,或手动添加等效 spacer - 让滚动条推进与视觉推进一致
优点:
- 所有后续 ScrollTrigger 的 start/end 计算更符合直觉
缺点:
- 影响布局/节奏,可能需要重新调多个 section 的视觉排版
方案 B(更平衡,影响范围小):Goals 文案使用真实视口位置驱动
思路:
- Goals 文案不再依赖 ScrollTrigger 的 progress
- 改用
getBoundingClientRect()得到元素相对视口的位置 - 把这个位置映射到 0..1 的 progress,然后写入:
style.colorstyle.backgroundSize(下划线长度)
实现要点:
- 用
requestAnimationFrame合并 scroll 事件,避免频繁计算 passive: true降低滚动阻塞
我选择了方案 B:它不要求重构上游 pinned section,并且对用户来说“看到什么就动什么”。
最终修复:用视口 progress 驱动颜色与下划线长度
对应实现位于:
src/components/goals-showcase/GoalsCardShowcase.tsx
核心代码(简化版):
const rect = el.getBoundingClientRect();
const startY = window.innerHeight * 0.85;
const endY = window.innerHeight * 0.45;
const progress = clamp01((startY - rect.top) / (startY - endY));
el.style.color = interpolatedColor(progress);
el.style.backgroundSize = `${progress * 100}% 3px`;这样不管上游滚动条怎么被 pin “消耗”,只要元素还没真实进入视口,progress 就不会提前变成 1。
验证方式
npm run dev- 打开
http://localhost:3001/en?goalsDebug=1 - 滚动到 Goals 文案区域:
- 文字应从灰变深
- 下划线从 0% 拉到 100%
如果你还在用 markers 调试其它段落:记得这段文案现在不依赖 ScrollTrigger progress,因此 markers 不再是它的“真相来源”。
总结:pinSpacing=false 不是错,但它改变了“滚动”的语义
这次的教训不是“不要用 pinSpacing=false”,而是:
- 当页面存在多个 pinned 段落,尤其有
pinSpacing:false时, - scroll 值不再等同于“内容露出进度”
对于“必须跟随真实视口呈现”的小 UI(如文案划线),更稳的做法是:
- 用视口几何(
getBoundingClientRect())当作真相 - 用 rAF 合并更新,保证性能
这类修复也更容易局部收敛,不需要牵一发动全身。