引子
在浏览模板网站时,我偶然发现了一个特别的滚动体验——滚动时页面内容会有一种弹性的滞后跟随感,就像滑动一块果冻一样。这种细微的交互提升大大增强了网站的高级感。
经过分析源码,我发现这种效果的实现原理并不复杂,而且可以在不引入第三方库的情况下自行实现。本文将带你从零构建这个组件。
核心原理
果冻滚动的本质
这种效果基于以下三个核心概念:
// 1. 滚动目标位置(实际滚动位置)
targetY += deltaY;
// 2. 滚动当前位置(平滑后的位置)
currentY += (targetY - currentY) * ease;
// 3. 应用到 DOM
container.style.transform = `translate3d(0, ${-currentY}px, 0)`;
关键参数
| 参数 | 作用 | 推荐值 |
|---|---|---|
ease | 缓动系数,控制跟随速度 | 0.05 ~ 0.15 |
intensity | 效果强度,用于动态计算 ease | 5 ~ 10 |
实现思路图解
用户滚动 → 更新目标位置 → 每帧插值 → 应用变换 → 产生弹性跟随感
↓ ↓ ↓
targetY (target - current) transform
实现步骤
步骤 1:基础架构
首先,我们需要添加 TypeScript 类型声明,然后创建一个 React 组件来包裹内容,并处理事件:
// 全局类型声明
declare global {
interface Window {
jellyScrollTo?: (y: number) => void;
jellyScrollToElement?: (el: HTMLElement) => void;
isJellyScrollActive?: boolean;
}
}
// 自定义事件类型
interface JellyScrollEvent extends CustomEvent {
detail?: {
scrollY: number;
};
}
interface JellyScrollProps {
children: React.ReactNode;
enabled?: boolean;
desktopOnly?: boolean;
intensity?: number;
}
export function JellyScroll({
children,
enabled = true,
desktopOnly = true,
intensity = 6,
}: JellyScrollProps) {
const containerRef = useRef<HTMLDivElement>(null);
const scrollY = useRef(0);
const targetY = useRef(0);
const isActive = useRef(false);
const ease = 0.05 + (intensity * 0.006);
// 其他代码...
}
步骤 2:设备检测
这种效果在桌面端体验最佳,移动端应该保持原生滚动:
const shouldEnable = useCallback(() => {
if (!enabled) return false;
if (!desktopOnly) return true;
return window.innerWidth >= 768;
}, [enabled, desktopOnly]);
步骤 3:处理滚轮事件
const handleWheel = useCallback((e: WheelEvent) => {
if (!isActive.current) return;
e.preventDefault();
let delta = e.deltaY;
// 加速度效果:滚动越快,增量越大
const speed = Math.abs(delta);
if (speed > 80) {
delta *= 1.4;
} else if (speed > 40) {
delta *= 1.2;
}
targetY.current += delta;
if (!containerRef.current) return;
// 限制在合理范围内
const maxScroll = containerRef.current.scrollHeight - window.innerHeight;
targetY.current = Math.max(0, Math.min(targetY.current, maxScroll));
// 启动动画循环
if (!isScrolling.current) {
startAnimation();
}
}, [isActive]);
步骤 4:动画循环
使用 requestAnimationFrame 实现平滑的帧更新:
const animate = () => {
if (!containerRef.current) return;
const diff = targetY.current - scrollY.current;
// 应用缓动
const step = Math.min(Math.abs(diff) * ease, Math.abs(diff));
if (Math.abs(diff) > 0.1) {
scrollY.current += diff > 0 ? step : -step;
} else {
scrollY.current = targetY.current;
isScrolling.current = false;
}
// 限制范围
const maxScroll = containerRef.current.scrollHeight - window.innerHeight;
scrollY.current = Math.max(0, Math.min(scrollY.current, maxScroll));
// 应用变换 - 使用 translate3d 触发 GPU 加速
containerRef.current.style.transform = `translate3d(0, ${-scrollY.current}px, 0)`;
// 触发自定义事件,供其他组件使用
const event = new CustomEvent('jelly-scroll', {
detail: { scrollY: scrollY.current }
});
window.dispatchEvent(event);
if (isScrolling.current) {
requestAnimationFrame(animate);
}
};
步骤 5:处理样式
// 激活时设置样式
const el = containerRef.current;
el.style.position = 'fixed';
el.style.top = '0';
el.style.left = '0';
el.style.width = '100%';
el.style.willChange = 'transform'; // 提示浏览器优化
document.body.style.overflow = 'hidden';
document.body.style.height = '100%';
性能优化
1. 避免闭包陷阱
初始实现时遇到了 React 的闭包陷阱问题——在 useCallback 中引用的 animate 函数可能是旧版本的。解决方案是使用 ref 来持有函数引用:
const animateRef = useRef<() => void>();
const setupAnimate = useCallback(() => {
const animate = () => {
// 动画逻辑
};
animateRef.current = animate;
}, [ease]);
// 使用时
requestAnimationFrame(animateRef.current as () => void);
2. GPU 加速
使用 translate3d 而非 translateY:
// 正确:触发 GPU 加速
container.style.transform = `translate3d(0, ${-scrollY}px, 0)`;
// 不推荐:仅 CPU 渲染
container.style.transform = `translateY(${-scrollY}px)`;
3. 使用 will-change
提前告知浏览器该元素会有变换:
container.style.willChange = 'transform';
与其他组件协作
1. 导航栏锚点跳转
果冻滚动效果会影响正常的锚点跳转,需要让导航栏支持果冻滚动。特别要注意位置计算问题——在应用了 transform 变换的容器中,getBoundingClientRect 返回的是相对于视口的位置,需要特殊处理:
window.jellyScrollToElement = (el: HTMLElement) => {
if (!containerRef.current || !isActive) {
el.scrollIntoView({ behavior: 'smooth' });
return;
}
let offsetTop = 0;
let currentEl: HTMLElement | null = el;
// 使用 offsetTop 逐层累加计算位置(更可靠)
while (currentEl && currentEl !== containerRef.current) {
if (currentEl.offsetParent) {
offsetTop += currentEl.offsetTop;
currentEl = currentEl.offsetParent as HTMLElement;
} else {
break;
}
}
// Fallback:如果 offsetTop 计算失败,使用临时移除 transform 的方法
if (offsetTop === 0) {
const originalTransform = containerRef.current.style.transform;
containerRef.current.style.transform = 'translate3d(0, 0, 0)';
const elementRect = el.getBoundingClientRect();
const containerRect = containerRef.current.getBoundingClientRect();
offsetTop = elementRect.top - containerRect.top;
containerRef.current.style.transform = originalTransform;
offsetTop += scrollY.current;
}
// 减去导航栏高度偏移
const headerHeight = 64;
offsetTop = Math.max(0, offsetTop - headerHeight);
if (window.jellyScrollTo) {
window.jellyScrollTo(offsetTop);
}
};
导航栏的点击处理:
const handleScrollClick = useCallback(
(e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
if (!isHomePage) return;
e.preventDefault();
const el = document.getElementById(id);
if (el) {
if (window.jellyScrollToElement) {
window.jellyScrollToElement(el);
} else {
el.scrollIntoView({ behavior: 'smooth' });
}
}
},
[isHomePage]
);
2. 导航栏活动区域检测(小横条显示)
在传统滚动中,我们常用 IntersectionObserver 检测元素可见性。但在果冻滚动中,由于使用了 transform 而非真正滚动,需要使用自定义事件和手动位置检查:
const checkActiveSection = useCallback(() => {
if (!isHomePage) return;
let foundActive = false;
// 检查 Hero 区域(无小横条)
const heroEl = document.getElementById('hero');
if (heroEl) {
const heroRect = heroEl.getBoundingClientRect();
const heroMiddle = heroRect.top + heroRect.height / 2;
if (heroMiddle >= 0 && heroMiddle <= window.innerHeight * 0.7) {
setActiveSection('');
setIndicatorStyle(prev => ({ ...prev, opacity: 0 }));
foundActive = true;
}
}
// 检查其他区域
if (!foundActive) {
for (const item of NAV_ITEMS) {
const el = document.getElementById(item);
if (!el) continue;
const rect = el.getBoundingClientRect();
const elementMiddle = rect.top + rect.height / 2;
if (elementMiddle >= window.innerHeight * 0.2 &&
elementMiddle <= window.innerHeight * 0.7) {
setActiveSection(item);
foundActive = true;
break;
}
}
}
}, [isHomePage]);
useEffect(() => {
if (!isHomePage) return;
// 原生滚动监听
const handleNativeScroll = () => {
if (!window.isJellyScrollActive) {
currentScrollY.current = window.scrollY;
checkActiveSection();
}
};
window.addEventListener('scroll', handleNativeScroll);
// 果冻滚动自定义事件监听
const handleJellyScroll = (e: Event) => {
const jellyEvent = e as CustomEvent<{ scrollY: number }>;
if (jellyEvent.detail?.scrollY !== undefined) {
currentScrollY.current = jellyEvent.detail.scrollY;
checkActiveSection();
}
};
window.addEventListener('jelly-scroll', handleJellyScroll);
// 初始化检查(用 setTimeout 避免同步 setState 警告)
setTimeout(checkActiveSection, 0);
return () => {
window.removeEventListener('scroll', handleNativeScroll);
window.removeEventListener('jelly-scroll', handleJellyScroll);
};
}, [isHomePage, checkActiveSection]);
3. 返回顶部按钮
让返回顶部按钮支持果冻滚动:
interface ScrollToTopButtonProps {
useJellyScroll?: boolean;
}
export function ScrollToTopButton({ useJellyScroll = false }) {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const toggleVisibility = (scrollPos?: number) => {
const currentY = scrollPos !== undefined ? scrollPos : window.scrollY;
setIsVisible(currentY > 300);
};
// 监听原生滚动
window.addEventListener('scroll', () => !useJellyScroll && toggleVisibility());
// 监听果冻滚动的自定义事件
const handleJellyScroll = (e: Event) => {
const jellyEvent = e as JellyScrollEvent;
if (useJellyScroll && jellyEvent.detail?.scrollY !== undefined) {
toggleVisibility(jellyEvent.detail.scrollY);
}
};
window.addEventListener('jelly-scroll', handleJellyScroll);
return () => {
// 清理监听
};
}, [useJellyScroll]);
const scrollToTop = () => {
if (useJellyScroll && window.jellyScrollTo) {
window.jellyScrollTo(0);
} else {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
};
// 渲染按钮...
}
4. 固定定位元素
固定定位的元素应该放在果冻滚动容器外面:
export function ClientPortfolioWrapper({ children }) {
return (
<>
{/* 固定定位组件放在滚动容器外面 */}
<MouseGlow />
<Header />
<ClientChatWidget />
{/* 果冻滚动容器 */}
<JellyScroll>
<main>{children}</main>
<Footer />
</JellyScroll>
<ScrollToTopButton useJellyScroll={true} />
</>
);
}
踩坑记录
坑 1:锚点链接只能向下跳转
问题:点击导航栏链接只能向下滚动,无法向上滚动到更早的区域。
原因:使用了 getBoundingClientRect() 计算位置,但没有考虑容器当前已经应用的 transform 变换。在有 transform 时,该方法返回的位置与真实偏移量不符。
解决方案:
- 优先使用
offsetTop逐层累加,这个属性不受 transform 影响 - 作为 fallback,临时移除 transform 后计算位置,然后恢复
- 确保两种方法都减去导航栏高度偏移
坑 2:导航栏小横条(活动区域指示器)消失
问题:在使用果冻滚动时,导航栏下方指示当前区域的小横条不显示了。
原因:原来的实现使用了 IntersectionObserver,但果冻滚动使用的是 transform 而非真正的页面滚动,IntersectionObserver 无法正确工作。
解决方案:
- 移除 IntersectionObserver,改用手动计算
getBoundingClientRect()来检测元素位置 - 监听
jelly-scroll自定义事件,在果冻滚动时也更新活动状态 - 保持对原生 scroll 事件的监听,以便在果冻滚动未启用时也能正常工作
常见问题
1. 为什么不直接使用 CSS scroll-behavior?
scroll-behavior: smooth 只能让锚点跳转平滑,无法产生持续的弹性跟随感。
| 特性 | CSS scroll-behavior | 自定义果冻滚动 |
|---|---|---|
| 点击锚点平滑 | ✅ | ✅ |
| 滚轮弹性跟随 | ❌ | ✅ |
| 可控效果强度 | ❌ | ✅ |
| 事件集成 | ❌ | ✅ |
2. 如何禁用效果?
只需将 enabled prop 设为 false 即可,组件会自动回退到原生滚动。
总结
果冻滚动效果的实现并不复杂,核心在于:
- 理解插值动画:目标位置与当前位置的平滑插值
- 性能优化:GPU 加速、requestAnimationFrame
- 渐进增强:只在桌面端启用,移动端保持原生体验
- 生态协作:通过自定义事件与其他组件配合