前端平滑返回顶部:requestAnimationFrame与缓动函数实战 1. 页面平滑返回顶部的核心设计思路1.1 为什么原生 scrollTo 不够用很多刚接触前端的朋友实现返回顶部第一反应就是window.scrollTo(0, 0)。这行代码确实能用点一下滚动条瞬间就跳到顶部了。但问题也恰恰出在瞬间这两个字上——用户视觉上会经历一次突兀的跳变尤其是页面内容很长、滚动位置很深的时候这种跳变会让人产生短暂的迷失感不知道刚才看到哪儿了。我在实际项目里做过对比测试同一个长列表页面用瞬间跳转和用平滑滚动用户对返回顶部这个功能的感知满意度差距非常明显。平滑滚动给了用户一个视觉上的过渡带让眼睛有时间跟随滚动过程心理上更容易接受位置的变化。这也是为什么现在主流浏览器都开始原生支持scroll-behavior: smooth这个 CSS 属性。但原生方案有个绕不开的局限可控性太差。你没法自定义缓动曲线没法在滚动过程中插入回调没法中途打断也没法精确控制时长。对于追求交互质感的项目来说这些恰恰是刚需。所以自己用 JavaScript 手写一套平滑滚动逻辑依然是前端工程师绕不过去的基本功。1.2 三种主流实现方案的取舍在动手之前我先把常见的几种方案摆出来对比一下方便你根据项目情况做选择。方案实现方式优点缺点适用场景CSS 方案scroll-behavior: smooth一行代码搞定无法控制时长和曲线兼容性有坑简单页面、对动效无要求定时器方案setInterval逐步修改 scrollTop逻辑直观容易理解帧率不稳定容易卡顿学习练手rAF 方案requestAnimationFrame 缓动函数帧率跟随屏幕刷新丝滑代码稍复杂生产环境首选scroll-behavior: smooth这个 CSS 属性看起来最省事但它的问题在于一是部分老版本浏览器支持不完整二是它只对锚点跳转和scrollTo这类原生滚动生效一旦你想做点自定义的东西就抓瞎了。而且它的滚动时长由浏览器自己决定你完全插不上手。setInterval方案是很多教程里的经典写法每隔十几毫秒把scrollTop减一点。这个思路没错但setInterval的定时精度受主线程影响很大页面一忙它就掉帧滚动起来一顿一顿的。更麻烦的是如果用户手动滚动或者切换了标签页定时器还在后台傻跑容易出各种诡异问题。所以真正靠谱的方案是requestAnimationFrame配合缓动函数。requestAnimationFrame会把回调安排在浏览器下一次重绘之前执行天然和屏幕刷新率对齐60Hz 屏幕就是每秒 60 次120Hz 就是每秒 120 次滚动过程自然就顺滑。缓动函数则负责控制速度曲线让滚动有加速和减速的过程而不是匀速直线运动观感上更接近真实物理世界。1.3 缓动函数到底在缓什么缓动这个词听起来有点玄其实说白了就是控制速度随时间变化的规律。想象你开车从 A 点到 B 点。如果全程匀速起步瞬间就是最高速到终点瞬间刹停乘客会很难受。真实的驾驶是起步慢慢加速中间保持高速快到终点时慢慢减速停下。缓动函数干的就是这件事——它把进度从 0 到 1 的变化映射成一条非线性的曲线。最常用的缓动函数是easeInOutQuad二次缓入缓出公式长这样// t 是当前进度范围 0 到 1 function easeInOutQuad(t) { return t 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t 2, 2) / 2; }这个函数的特点是前半段加速后半段减速中间过渡自然。用在返回顶部上就是刚开始慢慢启动中间快速滚动接近顶部时缓缓停下视觉体验非常舒服。除了二次的还有三次Cubic、四次Quart等更高阶的缓动曲线更陡加速减速更明显。也有正弦、指数、弹性Elastic、回弹Back等各种花式曲线。选哪个取决于你想要的气质——返回顶部这种功能我一般推荐用easeInOutQuad或easeInOutCubic稳重不浮夸。提示缓动函数的输入必须是 0 到 1 之间的归一化进度值输出也是 0 到 1。这个约定很重要后面计算实际滚动位置时会用到。2. 核心细节解析与实操要点2.1 requestAnimationFrame 的正确打开方式requestAnimationFrame的用法本身很简单传入一个回调函数浏览器会在下次重绘前调用它并且把当前时间戳作为参数传进来。但这个时间戳怎么用很多人一开始会搞错。let startTime null; function step(timestamp) { if (startTime null) { startTime timestamp; } const elapsed timestamp - startTime; // 已经过去的时间 // ... 根据 elapsed 计算进度 if (elapsed duration) { requestAnimationFrame(step); } }这里的关键是不要用Date.now()去算时间。requestAnimationFrame传进来的timestamp是高精度时间戳和屏幕刷新节奏严格对齐用它算出来的进度才准确。如果你混用Date.now()在某些设备上会出现时间基准不一致的问题导致滚动忽快忽慢。另一个要点是起始时间的初始化。第一次调用step时startTime还是 null需要把它设成当前时间戳。这样第一次计算出来的elapsed是 0进度从 0 开始符合预期。如果忘了这一步直接用timestamp - 某个固定值进度就会从一个莫名其妙的位置开始。2.2 滚动位置的插值计算有了进度t0 到 1接下来要把它转换成实际的滚动位置。这里用的是线性插值const startScroll document.documentElement.scrollTop || document.body.scrollTop; const targetScroll 0; // 目标是顶部 const currentScroll startScroll (targetScroll - startScroll) * easedProgress;startScroll是起始位置targetScroll是目标位置返回顶部就是 0easedProgress是经过缓动函数处理后的进度。这个公式的意思是从起点出发按照缓动后的比例向终点移动。为什么不能直接用startScroll * (1 - easedProgress)其实数学上等价但写成start (target - start) * p这种形式更通用——如果以后你想改成滚动到页面某个位置而不是顶部只需要改targetScroll就行公式不用动。这种写法在动画库里是标准做法。2.3 滚动容器的兼容性处理document.documentElement.scrollTop和document.body.scrollTop这两个属性是前端历史上一个经典的兼容性坑。在标准模式下滚动位置挂在documentElement上在怪异模式quirks mode下挂在body上。虽然现在怪异模式基本绝迹了但为了保险取的时候还是两个都试一下const scrollTop document.documentElement.scrollTop || document.body.scrollTop;设置的时候也一样两个都设一遍最稳妥document.documentElement.scrollTop currentScroll; document.body.scrollTop currentScroll;不过这里有个细节如果你用的是window.scrollTo(x, y)它内部会自动处理这个兼容问题代码更干净。但scrollTo在某些场景下比如滚动容器不是 window 而是某个 div就不适用了。所以我的建议是滚动 window 用window.scrollTo滚动自定义容器用element.scrollTop。2.4 中断与并发控制平滑滚动有个容易被忽略的问题如果用户在滚动过程中又点了一次返回顶部按钮或者手动滚动了页面正在进行的动画该怎么处理如果不做处理会出现两个动画同时跑、互相打架的情况滚动位置会来回抽搐。解决办法是维护一个动画进行中的标志或者保存requestAnimationFrame返回的 ID新动画开始前先取消旧的let rafId null; function smoothScrollToTop() { if (rafId ! null) { cancelAnimationFrame(rafId); rafId null; } // ... 启动新动画 }cancelAnimationFrame接收requestAnimationFrame返回的 ID能精确取消对应的回调。这个 ID 一定要存下来否则没法取消。至于用户手动滚动打断动画这个稍微复杂一点。可以在滚动过程中监听wheel、touchmove等事件一旦触发就取消动画。但要注意动画本身修改scrollTop也会触发scroll事件所以不能简单用scroll事件来判断用户操作得用wheel和touchmove这类明确的用户输入事件。注意cancelAnimationFrame只能取消还没执行的回调。如果回调已经在执行了取消是无效的。所以取消操作要放在启动新动画之前而不是在回调内部。3. 完整实操过程与核心代码实现3.1 基础版本从零手写一个平滑返回顶部先把最核心的逻辑写出来不依赖任何库纯原生实现。function smoothScrollToTop(duration 500) { // 取消正在进行的动画 if (window.__scrollRafId) { cancelAnimationFrame(window.__scrollRafId); window.__scrollRafId null; } const startScroll window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop; // 已经在顶部就不用滚了 if (startScroll 0) return; const startTime performance.now(); // easeInOutQuad 缓动函数 function easeInOutQuad(t) { return t 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t 2, 2) / 2; } function step(currentTime) { const elapsed currentTime - startTime; let progress Math.min(elapsed / duration, 1); const eased easeInOutQuad(progress); const currentScroll startScroll * (1 - eased); window.scrollTo(0, currentScroll); if (progress 1) { window.__scrollRafId requestAnimationFrame(step); } else { window.__scrollRafId null; } } window.__scrollRafId requestAnimationFrame(step); }这段代码有几个设计点值得说明。第一duration默认 500 毫秒。这个时长是我反复测试后觉得比较舒服的值。太短比如 200ms会显得急促平滑感不明显太长比如 1000ms会让用户觉得怎么还没到产生等待焦虑。500ms 左右是一个平衡点既能看到滚动过程又不会拖沓。第二progress用Math.min(elapsed / duration, 1)做了上限保护。因为requestAnimationFrame的时间戳精度很高elapsed有可能略微超过duration如果不限制eased会超过 1滚动位置就会越过顶部变成负数页面会抖一下。第三用window.pageYOffset作为首选获取方式。这个属性是scrollY的别名兼容性最好直接返回当前滚动位置不用管documentElement还是body。第四把rafId挂在window上是为了让多次调用之间能共享状态。如果你把整个逻辑封装成一个模块或类用闭包变量会更干净。3.2 进阶版本支持自定义缓动和回调基础版本够用但不够灵活。实际项目里你可能想换缓动曲线想在滚动结束后执行某些操作比如隐藏返回顶部按钮。下面这个版本把这些都考虑进去了。const Easing { linear: t t, easeInQuad: t t * t, easeOutQuad: t t * (2 - t), easeInOutQuad: t t 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t 2, 2) / 2, easeInCubic: t t * t * t, easeOutCubic: t (--t) * t * t 1, easeInOutCubic: t t 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t 2, 3) / 2, easeOutQuart: t 1 - Math.pow(1 - t, 4), easeInOutQuart: t t 0.5 ? 8 * t * t * t * t : 1 - Math.pow(-2 * t 2, 4) / 2 }; function scrollToTop(options {}) { const { duration 500, easing easeInOutQuad, onComplete null } options; if (window.__scrollRafId) { cancelAnimationFrame(window.__scrollRafId); window.__scrollRafId null; } const startScroll window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop; if (startScroll 0) { if (typeof onComplete function) onComplete(); return; } const easingFn typeof easing function ? easing : (Easing[easing] || Easing.easeInOutQuad); const startTime performance.now(); function step(currentTime) { const elapsed currentTime - startTime; const progress Math.min(elapsed / duration, 1); const eased easingFn(progress); const currentScroll startScroll * (1 - eased); window.scrollTo(0, currentScroll); if (progress 1) { window.__scrollRafId requestAnimationFrame(step); } else { window.__scrollRafId null; if (typeof onComplete function) onComplete(); } } window.__scrollRafId requestAnimationFrame(step); }这个版本把缓动函数抽成了一个Easing对象用字符串名字就能切换曲线。onComplete回调在滚动结束后触发可以用来做收尾工作。我实测下来easeInOutCubic比easeInOutQuad的观感更高级一点因为它的加速减速段更明显中间匀速段更短整体节奏更有张力。如果你想要更柔和的效果easeInOutQuad更合适。这个没有绝对标准看项目调性。3.3 按钮的显示与隐藏逻辑返回顶部按钮通常不是一直显示的而是滚动到一定距离后才出现。这个逻辑用scroll事件监听就能实现但要注意节流否则滚动时频繁触发回调会影响性能。function initBackToTopButton(button, threshold 300) { let ticking false; function updateButton() { const scrollTop window.pageYOffset || document.documentElement.scrollTop; if (scrollTop threshold) { button.classList.add(visible); } else { button.classList.remove(visible); } ticking false; } window.addEventListener(scroll, function() { if (!ticking) { requestAnimationFrame(updateButton); ticking true; } }, { passive: true }); button.addEventListener(click, function() { scrollToTop({ duration: 500, easing: easeInOutCubic }); }); }这里用了一个经典的requestAnimationFrame节流技巧ticking标志位保证在一帧之内只执行一次updateButton。这比setTimeout节流更精准因为它直接和渲染帧对齐。{ passive: true }这个选项也很重要。它告诉浏览器这个监听器不会调用preventDefault()浏览器就可以放心地优化滚动性能不会因为等待监听器执行而阻塞滚动。在移动端这个优化带来的流畅度提升非常明显。按钮的显示隐藏用 CSS 的opacity和transform做过渡比直接切换display更平滑.back-to-top { position: fixed; right: 24px; bottom: 24px; width: 48px; height: 48px; border-radius: 50%; background: #333; color: #fff; border: none; cursor: pointer; opacity: 0; transform: translateY(16px); transition: opacity 0.3s ease, transform 0.3s ease; pointer-events: none; } .back-to-top.visible { opacity: 1; transform: translateY(0); pointer-events: auto; }pointer-events: none保证按钮隐藏时不会误触visible类加上后才恢复点击。这个细节很多人会漏掉导致按钮明明看不见却能点到。3.4 滚动容器的适配前面讲的都是滚动整个 window 的情况。但有些页面布局是固定头部 内部滚动区域滚动发生在某个div上而不是 window。这时候上面的代码就不适用了需要改成操作容器的scrollTop。function scrollContainerToTop(container, options {}) { const { duration 500, easing easeInOutQuad } options; const startScroll container.scrollTop; if (startScroll 0) return; const easingFn Easing[easing] || Easing.easeInOutQuad; const startTime performance.now(); let rafId null; function step(currentTime) { const elapsed currentTime - startTime; const progress Math.min(elapsed / duration, 1); const eased easingFn(progress); container.scrollTop startScroll * (1 - eased); if (progress 1) { rafId requestAnimationFrame(step); } } rafId requestAnimationFrame(step); }区别就是把window.scrollTo换成了container.scrollTop ...。逻辑完全一样只是作用对象变了。如果你要写一个通用的工具函数可以判断传入的是 window 还是普通元素走不同的分支。提示判断一个元素是不是滚动容器可以看它的scrollHeight是否大于clientHeight以及overflow样式是否为auto或scroll。不过更简单的做法是让调用方明确指定避免自动判断带来的意外。4. 常见问题与排查技巧实录4.1 滚动卡顿、不丝滑怎么办这是最常见的问题。排查思路按优先级来第一检查是不是用了setInterval或setTimeout。如果是换成requestAnimationFrame。这是卡顿的头号原因。第二检查scroll事件监听有没有节流。如果滚动过程中有大量计算或 DOM 操作主线程被占满requestAnimationFrame也会掉帧。用requestAnimationFrame节流或者用passive: true。第三检查有没有触发强制同步布局layout thrashing。比如在滚动回调里读取offsetHeight、getBoundingClientRect()等属性会强制浏览器重新计算布局。解决办法是把读取和写入分开先批量读再批量写。第四检查页面本身有没有性能问题。如果页面有大量复杂动画、阴影、滤镜滚动时重绘压力大平滑滚动也会受影响。这种情况得先优化页面整体性能。我踩过的一个坑是在滚动回调里同时修改了scrollTop和某个元素的style.height结果每次滚动都触发重排帧率直接掉到 20 多。后来把高度修改挪到滚动结束后立马就顺了。4.2 滚动到顶部后位置不对有时候滚动结束后页面没有精确停在顶部而是差了几个像素。这通常是浮点数精度问题。scrollTop在浏览器内部是整数但计算过程中用的是浮点数最后赋值时会被取整可能差 1 像素。解决办法是在动画结束时强制设一次 0if (progress 1) { window.scrollTo(0, 0); // ... }另外如果页面顶部有固定定位的头部视觉上顶部可能不是scrollTop 0而是scrollTop headerHeight。这种情况需要把targetScroll设成头部高度而不是 0。4.3 移动端滚动异常移动端的滚动行为和桌面端差别很大。iOS 上有橡皮筋效果滚动到顶部还能继续往下拉Android 上滚动惯性更强。这些都会影响平滑滚动的表现。一个常见问题是在 iOS 上window.scrollTo在滚动动画进行中可能被系统的惯性滚动打断导致动画中断。解决办法是监听touchstart事件用户一碰屏幕就取消动画window.addEventListener(touchstart, function() { if (window.__scrollRafId) { cancelAnimationFrame(window.__scrollRafId); window.__scrollRafId null; } }, { passive: true });另一个问题是移动端scroll事件的触发频率和桌面端不同节流策略可能需要调整。不过用requestAnimationFrame节流的话基本能自适应各种设备。4.4 常见问题速查表问题现象可能原因排查方向解决方案滚动一顿一顿用了 setInterval检查定时器换成 requestAnimationFrame滚动过程中页面卡死强制同步布局检查回调里的 DOM 读写读写分离批量处理滚动结束位置差几像素浮点数取整检查最终赋值结束时强制设 0移动端动画被打断系统惯性滚动检查 touch 事件监听 touchstart 取消动画按钮点击无反应事件未绑定或元素被遮挡检查 z-index 和 pointer-events调整层级恢复 pointer-events多次点击滚动抽搐动画未取消检查 rafId 管理启动新动画前 cancelAnimationFrame滚动时长感觉不对duration 设置不合理检查参数调整到 400-600ms 区间4.5 几个我踩过的坑坑一在scroll事件里调用scrollToTop。这会造成无限递归——滚动触发事件事件里又启动滚动滚动又触发事件。如果你确实需要在某个滚动位置自动返回顶部一定要加条件判断确保只触发一次。坑二忘了处理prefers-reduced-motion。有些用户因为身体原因在系统设置里开启了减少动态效果。这时候平滑滚动反而会让他们不适。尊重用户选择的做法是const prefersReducedMotion window.matchMedia((prefers-reduced-motion: reduce)).matches; if (prefersReducedMotion) { window.scrollTo(0, 0); } else { scrollToTop(); }这个细节体现了对用户的尊重也是无障碍设计的基本要求。坑三duration 写死。页面短的时候 500ms 刚好页面特别长的时候 500ms 会显得太快滚动过程一闪而过。更好的做法是根据滚动距离动态计算时长const distance startScroll; const duration Math.min(1000, Math.max(300, distance * 0.5));距离越远时长越长但限制在 300 到 1000 毫秒之间。这样短距离不会拖沓长距离也不会一闪而过。坑四在 Vue/React 里直接操作 DOM。框架有自己的渲染机制直接改scrollTop一般没问题但如果在组件卸载后动画还在跑就会报错。记得在beforeUnmount或useEffect的清理函数里取消动画。5. 性能优化与体验打磨5.1 用 transform 替代 scrollTop 的取舍有一种观点认为修改scrollTop会触发重排性能不如用transform: translateY()来假装滚动。这个说法有一定道理但用在返回顶部这个场景上我并不推荐。原因在于transform只是视觉上移动了内容实际的滚动位置没变。这意味着滚动条不会跟着动用户如果此时手动滚动会出现视觉和实际位置不一致的诡异现象。而且页面上的固定定位元素、position: sticky元素的行为都会乱掉。scrollTop虽然会触发重排但现代浏览器对滚动做了大量优化实际性能完全够用。除非你的页面有极端复杂的布局否则没必要为了这点性能牺牲正确性。5.2 滚动过程中的视觉反馈纯滚动有时候会显得单调。可以在滚动过程中给按钮加一点状态变化比如旋转、变色让用户感知到正在执行。.back-to-top.scrolling { transform: rotate(360deg); transition: transform 0.5s ease; }滚动开始时加上scrolling类结束后移除。这样按钮会转一圈视觉上有个明确的完成信号。不过要注意transform和之前的translateY会冲突需要用translateY(0) rotate(360deg)这种组合写法。另一个思路是滚动过程中按钮逐渐淡出因为用户马上就到顶部了按钮也没必要一直显示。这个用onComplete回调配合 CSS 过渡就能实现。5.3 无障碍与键盘支持返回顶部按钮本质上是个交互控件应该支持键盘操作。用button元素而不是div天然就支持 Tab 聚焦和 Enter 触发。如果非要用div记得加tabindex0和rolebutton并监听键盘事件。button classback-to-top aria-label返回顶部 svg!-- 箭头图标 --/svg /buttonaria-label给屏幕阅读器提供文字说明因为按钮里只有图标没有文字。这个细节对无障碍很重要但经常被忽略。5.4 不同缓动曲线的实际观感对比我在同一个页面上测试了几种缓动曲线记录一下主观感受供你参考。linear匀速机械感强像电梯适合工具类页面但缺乏质感。easeInOutQuad加速减速都比较温和整体节奏平稳是最安全的默认选择几乎不会出错。easeInOutCubic加速减速更明显中间段速度更快整体更有冲劲适合内容丰富的页面滚动过程更有存在感。easeOutQuart一开始就很快然后慢慢减速适合快速返回的场景但起步太猛长距离滚动时视觉冲击有点大。easeInOutQuart比 Cubic 更极端加速减速段更长中间匀速段几乎为零观感上有点弹射的感觉适合短距离滚动。我的建议是默认用easeInOutQuad想要更有质感用easeInOutCubic其他曲线除非有特殊需求否则不用折腾。5.5 封装成可复用的工具函数最后把整套逻辑封装成一个独立的工具方便在项目里复用。核心思路是把状态rafId、startTime放在闭包里对外只暴露一个简洁的 API。const SmoothScroll (function() { let rafId null; const Easing { linear: t t, easeInOutQuad: t t 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t 2, 2) / 2, easeInOutCubic: t t 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t 2, 3) / 2 }; function cancel() { if (rafId ! null) { cancelAnimationFrame(rafId); rafId null; } } function toTop(options {}) { const { duration 500, easing easeInOutQuad, onComplete null } options; cancel(); const startScroll window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop; if (startScroll 0) { if (typeof onComplete function) onComplete(); return; } const easingFn typeof easing function ? easing : (Easing[easing] || Easing.easeInOutQuad); const startTime performance.now(); function step(currentTime) { const elapsed currentTime - startTime; const progress Math.min(elapsed / duration, 1); const eased easingFn(progress); window.scrollTo(0, startScroll * (1 - eased)); if (progress 1) { rafId requestAnimationFrame(step); } else { rafId null; if (typeof onComplete function) onComplete(); } } rafId requestAnimationFrame(step); } return { toTop, cancel }; })();用的时候直接SmoothScroll.toTop({ duration: 600, easing: easeInOutCubic })就行。cancel方法暴露出来方便在页面切换或组件卸载时清理。这个封装的好处是状态完全私有不会污染全局也不会因为多个实例互相干扰。如果你用 TypeScript还可以给 options 加上类型定义用起来更放心。提示如果你的项目已经用了 GSAP、anime.js 这类动画库其实可以直接用它们的滚动插件功能更强大。但理解底层原理依然有价值因为库出问题的时候你得知道怎么排查而且小项目引入一个动画库有点杀鸡用牛刀。最后分享一个我在实际项目里的小习惯把duration和easing做成可配置项但给一套经过验证的默认值。这样既保证了开箱即用的体验又给需要微调的场景留了口子。默认值不要拍脑袋定多找几个人在真实页面上试收集主观反馈比看任何理论都管用。