某公司React页面滚动太生硬被用户吐槽 教你用原生方式实现丝滑缓震效果 无需引入第三方库
话说上周我司的产品又收到了几条难听的反馈,”页面滚动跟卡顿了一样”,”跳来跳去的体验很差”,看着用户截图里那个生硬的滚动效果,我也是心里一紧。说实话,之前我们压根没在意这个细节,觉得滚动嘛,浏览器原生支持不就完了?但用户用脚投票告诉我们——差得远呢。
今天就把我们这次修复的经验毫无保留地分享出来,纯原生方案,零依赖,直接上手就能用。
为什么默认的滚动这么”硬”
先别急着写代码,咱们得先搞清楚问题出在哪。浏览器原生的滚动行为其实挺粗暴的,尤其是当你用 window.scrollTo() 或者操作 scrollTop 属性时,那个滚动就是一条直线冲过去,没有任何缓冲、没有弹性,也没有任何速度感。
想象一下,你开车从高速直接刹停,跟踩刹车后滑行停止,哪种感觉更舒服?当然是后者。CSS 里有个 scroll-behavior: smooth 确实能改善一部分体验,但它有个致命缺陷——在 React 这种组件化框架里,很多滚动其实是程序驱动的,比如点击导航跳转到锚点、列表加载更多时自动滚动、或者某些弹窗出现的滚动锁定。这些场景下,smooth 的行为往往不够精确,甚至在某些浏览器里表现不一致。
更关键的是,用户说”生硬”,很多时候不是滚动太快,而是缺少那种”有质感”的减速感。就像我们日常滑手机,为什么这么上瘾?就是因为那种阻尼感,它让你感觉像是在控制一个有物理属性的物体,而不是在切换画面。
动手实现:从原理到代码
要做出丝滑的滚动效果,核心思路其实很简单——不要一下子把滚动位置改到目标值,而是分多帧逐步逼近。这就好比人走路,不可能瞬间瞬移到目的地,而是一步一步迈过去的。
我们先来写一个最基础的缓动函数。在编程里,”缓动”(Easing)就是一个数学公式,用来描述速度随时间变化的曲线。常见的缓动类型有好几种:
- easeOut:开始快,结束时慢,最常见的”减速”效果
- easeIn:开始慢,结束时快
- easeInOut:中间快,两头慢
- elastic:带弹跳效果,有点像蹦床
对于滚动来说,easeOut 类型是最自然的选择,因为它模拟了摩擦力的感觉——速度越来越慢,最后自然停下。
// 基础的 easeOutQuint 缓动函数
// 传入 0~1 之间的 progress,返回 0~1 之间的 eased progress
function easeOutQuint(t) {
return 1 - Math.pow(1 - t, 5);
}
// 如果你想体验不同的手感,可以换这几个:
function easeOutCubic(t) {
return 1 - Math.pow(1 - t, 3);
}
function easeOutExpo(t) {
return t === 1 ? 1 : 1 - Math.pow(2, -10 * t);
}
function easeOutBounce(t) {
if (t < 1 / 2.75) {
return 7.5625 * t * t;
} else if (t < 2 / 2.75) {
return 7.5625 * (t -= 1.5 / 2.75) * t + 0.75;
} else if (t < 2.5 / 2.75) {
return 7.5625 * (t -= 2.25 / 2.75) * t + 0.9375;
} else {
return 7.5625 * (t -= 2.625 / 2.75) * t + 0.984375;
}
}
看到没有,这些函数其实就几行数学公式,但效果差别巨大。easeOutQuint 的”5次方”让减速感非常明显,适合长距离滚动;easeOutExpo 在接近终点时会变得极慢,有一种”优雅停止”的感觉;而 easeOutBounce 嘛,就是那种带弹跳的趣味性效果,适合做一些有趣的交互场景,比如回到顶部按钮。
有了缓动函数,接下来就是核心逻辑——用 requestAnimationFrame 驱动滚动动画。这个 API 是浏览器专门为动画设计的,它会在每次屏幕刷新前执行回调,通常对应 60fps 的刷新率,比 setInterval 要流畅得多,也更省资源。
class SmoothScroller {
constructor(options = {}) {
this.duration = options.duration || 600; // 动画持续时间,毫秒
this.easing = options.easing || easeOutQuint;
this.onComplete = options.onComplete || null;
this.onStart = options.onStart || null;
this.animationId = null;
this.startTime = null;
this.startY = 0;
this.targetY = 0;
this.scrollContainer = window;
}
// 开始滚动到目标位置
scrollTo(targetY, options = {}) {
// 取消正在进行的动画,避免冲突
this.cancel();
this.startY = this.scrollContainer === window
? window.scrollY
: this.scrollContainer.scrollTop;
this.targetY = targetY;
this.startTime = performance.now();
// 可以通过 options 覆盖单次设置的参数
if (options.duration) this.duration = options.duration;
if (options.easing) this.easing = options.easing;
if (this.onStart) this.onStart();
this.animationId = requestAnimationFrame(this.tick.bind(this));
}
// 每一帧的更新逻辑
tick(currentTime) {
const elapsed = currentTime - this.startTime;
// progress 从 0 到 1,表示动画完成了多少
const rawProgress = Math.min(elapsed / this.duration, 1);
// 应用缓动函数
const easedProgress = this.easing(rawProgress);
// 计算当前应该滚动到的位置
const currentY = this.startY + (this.targetY - this.startY) * easedProgress;
// 执行滚动
if (this.scrollContainer === window) {
window.scrollTo(0, currentY);
} else {
this.scrollContainer.scrollTop = currentY;
}
// 如果还没完成,继续下一帧
if (rawProgress < 1) {
this.animationId = requestAnimationFrame(this.tick.bind(this));
} else {
// 动画结束,确保位置精确(防止浮点误差)
if (this.scrollContainer === window) {
window.scrollTo(0, this.targetY);
} else {
this.scrollContainer.scrollTop = this.targetY;
}
if (this.onComplete) this.onComplete();
}
}
// 取消动画,用于用户中途点击了其他导航等情况
cancel() {
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
}
}
这个 SmoothScroller 类设计得比较通用,它支持自定义持续时间、缓动函数,还有开始和结束的回调。最关键的是 cancel() 方法——用户如果在动画过程中点击了其他链接,我们必须立即取消当前动画,不然就会出现”滚到一半突然跳走”的诡异体验。
在 React 中优雅地使用
光有工具类还不够,我们得把它包装成 React 组件,这样才能真正集成到项目里。这里我有两个方案供你选择,取决于你的具体场景。
方案一:自定义 Hook
如果你只是需要在某个组件里实现滚动,比如点击按钮滚到顶部,或者滚动到某个锚点,用 Hook 是最简洁的方式:
import { useRef, useCallback } from 'react';
export function useSmoothScroll(options = {}) {
const scrollerRef = useRef(null);
// 懒初始化,避免每次渲染都创建新实例
if (!scrollerRef.current) {
scrollerRef.current = new SmoothScroller(options);
}
const scrollTo = useCallback((targetY, scrollOptions) => {
scrollerRef.current.scrollTo(targetY, scrollOptions);
}, []);
const scrollToElement = useCallback((element, offset = 0) => {
if (!element) return;
const rect = element.getBoundingClientRect();
const targetY = window.scrollY + rect.top + offset;
scrollTo(targetY);
}, [scrollTo]);
const scrollToTop = useCallback((offset = 0) => {
scrollTo(offset, options);
}, [scrollTo, options]);
// 组件卸载时清理
useRef(() => {
scrollerRef.current?.cancel();
});
return { scrollTo, scrollToElement, scrollToTop };
}
用起来非常简单:
function HomePage() {
const { scrollToTop, scrollToElement } = useSmoothScroll({
duration: 800,
easing: easeOutExpo
});
const heroRef = useRef(null);
const featuresRef = useRef(null);
const pricingRef = useRef(null);
return (
<div>
<nav>
<button onClick={() => scrollToTop(0)}>顶部</button>
<button onClick={() => scrollToElement(heroRef)}>英雄区</button>
<button onClick={() => scrollToElement(featuresRef, -80)}>特性</button>
<button onClick={() => scrollToElement(pricingRef)}>定价</button>
</nav>
<section ref={heroRef} id="hero">...</section>
<section ref={featuresRef} id="features">...</section>
<section ref={pricingRef} id="pricing">...</section>
</div>
);
}
注意到我给 featuresRef 传了 -80 的 offset,这是因为我们的导航栏有 80px 高,如果不调整的话,锚点内容会被导航栏遮住。这个小细节用户能感觉到,但如果不处理,体验上就会显得”对不准”。
方案二:可复用的 ScrollTo 组件
如果你的项目里有很多地方需要滚动到特定锚点,做一个通用组件会更省事:
import { useEffect, useRef } from 'react';
import { useSmoothScroll } from './useSmoothScroll';
/**
* 点击后平滑滚动到指定元素的组件
*
* @param {string} targetId - 目标元素的 id
* @param {number} offset - 滚动偏移量,默认 0
* @param {object} options - SmoothScroller 的配置项
*/
export function SmoothScrollTo({
targetId,
offset = 0,
options = {},
children,
className
}) {
const { scrollToElement } = useSmoothScroll(options);
const handleClick = (e) => {
e.preventDefault();
const target = document.getElementById(targetId);
scrollToElement(target, offset);
};
return (
<a
href={`#${targetId}`}
onClick={handleClick}
className={className}
>
{children}
</a>
);
}
然后在你的导航栏里这样用:
<SmoothScrollTo targetId="hero" offset={-80} options={{ duration: 700 }}>
首页
</SmoothScrollTo>
<SmoothScrollTo targetId="features" offset={-80} options={{ duration: 700 }}>
功能
</SmoothScrollTo>
<SmoothScrollTo targetId="pricing" offset={-80} options={{ duration: 700 }}>
价格
</SmoothScrollTo>
代码量几乎为零,但体验提升巨大。而且因为 SmoothScroller 实例是 useRef 保存的,不会因为组件重渲染而重建,性能非常稳定。
进阶:列表无限滚动时的”吸附”效果
我们项目里还有个棘手的问题——数据列表加载时,用户滚动到某一条内容,页面会自动”吸附”到最合适的位置。这个在移动端特别常见,比如抖音的上下滑动,或者一些卡片列表的 snap 效果。
纯 CSS 其实已经支持这个功能了:
.scroll-container {
scroll-snap-type: y mandatory;
scroll-behavior: smooth;
overflow-y: scroll;
height: 100vh;
}
.scroll-item {
scroll-snap-align: start;
height: 100vh;
}
但问题是,CSS scroll-snap 的控制粒度太粗,你不能在 JavaScript 里精确控制它停在哪个位置,也不能自定义缓动效果。如果我们想在滚动过程中动态计算最佳吸附位置,就需要结合 JavaScript:
class SnapScroller extends SmoothScroller {
constructor(options = {}) {
super(options);
this.snapElements = [];
this.container = null;
}
// 绑定到容器并注册吸附元素
attach(container, snapSelectors) {
this.container = container;
this.snapElements = Array.from(container.querySelectorAll(snapSelectors));
this.startY = container.scrollTop;
// 监听用户手动滚动,准备吸附
container.addEventListener('scroll', this.handleScroll.bind(this), { passive: true });
container.addEventListener('pointerup', this.handleRelease.bind(this));
}
handleScroll() {
this.currentScroll = this.container.scrollTop;
}
handleRelease() {
// 释放手指后,找到最近的吸附位置并滚动过去
const targetIndex = this.findNearestSnap(this.currentScroll);
const targetY = this.snapElements[targetIndex]?.offsetTop || 0;
this.scrollTo(targetY, { duration: 400, easing: easeOutCubic });
// 清理监听,避免动画期间重复触发
this.container.removeEventListener('scroll', this.handleScroll.bind(this));
this.container.removeEventListener('pointerup', this.handleRelease.bind(this));
}
findNearestSnap(currentY) {
let nearestIndex = 0;
let minDistance = Infinity;
this.snapElements.forEach((el, index) => {
const distance = Math.abs(el.offsetTop - currentY);
if (distance < minDistance) {
minDistance = distance;
nearestIndex = index;
}
});
return nearestIndex;
}
destroy() {
this.cancel();
this.container?.removeEventListener('scroll', this.handleScroll);
this.container?.removeEventListener('pointerup', this.handleRelease);
}
}
这个 SnapScroller 继承自我们的 SmoothScroller,在用户释放手指后自动找到最近的吸附点并平滑滚过去。核心逻辑就是 findNearestSnap 方法——遍历所有吸附元素,找到距离当前位置最近的那个。
真实项目中的调优细节
写完代码只是第一步,真正让滚动”丝滑”的往往是一些细节调优。我们团队在这次修复过程中踩了不少坑,分享几个最有价值的经验。
第一个坑是动画期间的竞态问题。 想象一下这个场景:用户快速连续点击了三个导航链接,如果每个点击都启动一个新动画,最终结果会是混乱的——可能最后一个动画还没结束,第一个动画的回调却被触发了。我们的 cancel() 方法就是为了解决这个问题,每次启动新动画前先取消旧的。但在 React 里,还要注意 useEffect 的清理函数,确保组件卸载时不会留下”幽灵动画”。
第二个坑是移动端 touch 事件的延迟。 有些开发者会发现,在手机上用我们的方案滚动,手指离开屏幕后会有一个明显的延迟才开始吸附。这是因为移动端的 click 事件有 300ms 的延迟。解决方案很简单——用 pointerdown、pointerup 等 Pointer Events API 替代 touch 事件,或者直接用 CSS 的 touch-action 属性来优化:
.scroll-container {
/* 告诉浏览器这个容器负责滚动,js 不需要拦截 touch 事件 */
touch-action: pan-y;
}
第三个坑是长列表的性能。 如果页面有几百个吸附元素,每次滚动都遍历一遍找最近的,那性能肯定扛不住。我们的做法是加一个简单的缓存,只在用户停止滚动后才重新计算最近位置:
let calcTimeout = null;
handleScroll() {
clearTimeout(calcTimeout);
this.currentScroll = this.container.scrollTop;
// 用户停止滚动 150ms 后再计算吸附位置
calcTimeout = setTimeout(() => {
this.handleRelease();
}, 150);
}
这个 150ms 的延迟是经验值,既不会让用户感知到卡顿,又能避免频繁计算。你可以根据实际场景调整这个值。
效果对比:用户反馈真的变了
把这套方案上线后,我们盯着数据看板看了一周。滚动相关的用户反馈从最初的”卡顿”、”生硬”变成了”挺顺滑的”、”没有那种跳来跳去的感觉了”。当然,没有反馈不代表完美,但至少说明方向是对的。
我自己用下来的感受是,easeOutExpo 配合 600~800ms 的持续时间,在大多数场景下都有最好的”体感”。太快了显得仓促,太慢了显得拖沓。如果你的页面内容比较少、距离短,可以把时间压到 400ms;如果是要从页面底部滚回顶部这种长距离滚动,800ms 甚至 1000ms 会更舒服。
说到底,滚动效果的优化不是什么高深技术,就是一个耐心调参数的过程。但正是这些细节,决定了用户觉得你的产品是”好用”还是”能用”。希望这篇能帮到你,如果有什么疑问或者遇到了奇怪的问题,随时来交流。
