
本文详解如何使用 requestAnimationFrame 和 performance.now() 实现可控速度的平滑滚动,并重点修复按钮点击跳转失效、滚动目标错位及方向判断错误等核心问题。
本文详解如何使用 requestanimationframe 和 performance.now() 实现可控速度的平滑滚动,并重点修复按钮点击跳转失效、滚动目标错位及方向判断错误等核心问题。
在现代 Web 开发中,原生 scrollIntoView({ behavior: 'smooth' }) 虽简洁,但无法精确控制滚动时长与缓动曲线。因此,许多开发者选择手动实现基于时间的动画滚动——通过 performance.now() 记录起始时间,结合 requestAnimationFrame 帧循环计算进度,最终调用 window.scrollTo() 实现实时插值。然而,如示例代码所示,这类实现极易在交互场景(尤其是按钮触发)中出现逻辑偏差。
? 核心问题诊断
原始代码存在三个关键缺陷:
目标元素引用错误:
const lis = document.querySelectorAll('.section')正确用于获取页面区块;但后续却误将lis[index].getBoundingClientRect().top用于按钮点击逻辑,而lis并非按钮集合。更严重的是,修复版代码中竟将lis错写为document.querySelectorAll('.button'),导致lis[index].getBoundingClientRect().top获取的是按钮自身位置(固定顶部),而非目标.section的偏移量 —— 这是按钮跳转完全失效的根本原因。滚动模式与参数语义混淆:
mode === 'manual'分支中直接使用manualPosition * progress是错误的线性映射:getBoundingClientRect().top返回的是相对于视口顶部的像素值(如200),而progress从0 → 1,结果仅为0 → 200,远小于实际目标滚动位置(例如第二屏需滚动约100vh ≈ 800px)。正确做法应是计算当前滚动位置startY到目标位置targetY的差值,再按progress插值。方向逻辑硬编码,缺乏目标驱动:
按钮点击统一传'down',导致点击“Go to 1”(index=0)时仍执行向下滚动,必然失败。应根据currentSection与目标index的大小关系动态决定up/down,或更优地——直接计算目标绝对位置,彻底解耦方向判断。
✅ 正确实现方案
以下为修复后的完整、健壮实现(已验证兼容 Chrome/Firefox/Safari):
(function () {
const sections = document.querySelectorAll('.section'); // ✅ 正确的目标区块集合
const buttons = document.querySelectorAll('.button');
const duration = 500; // 滚动总时长(ms)
let isScrolling = false;
let currentSectionIndex = 0;
function smoothScrollTo(targetIndex, durationMs = duration) {
if (isScrolling || targetIndex = sections.length) return;
isScrolling = true;
const startY = window.scrollY;
const targetRect = sections[targetIndex].getBoundingClientRect();
const targetY = window.scrollY + targetRect.top; // ✅ 绝对目标位置(含当前滚动偏移)
const start = performance.now();
function animate(currentTime) {
const elapsed = currentTime - start;
const progress = Math.min(elapsed / durationMs, 1); // 防止超调
const easeOutCubic = 1 - Math.pow(1 - progress, 3); // 更自然的缓动效果
window.scrollTo(0, startY + (targetY - startY) * easeOutCubic);
if (progress {
if (isScrolling) return;
const delta = e.deltaY > 0 ? 1 : -1;
const nextIndex = Math.max(0, Math.min(sections.length - 1, currentSectionIndex + delta));
if (nextIndex !== currentSectionIndex) {
smoothScrollTo(nextIndex, duration);
}
});
// 按钮点击:绝对跳转(精准到指定区块)
buttons.forEach((btn, index) => {
btn.addEventListener('click', () => smoothScrollTo(index));
});
})();
⚠️ 关键注意事项
-
避免
getBoundingClientRect().top直接运算:它返回的是视口内偏移,必须叠加window.scrollY才得到文档绝对位置。 -
使用缓动函数提升体验:原生
linear插值生硬,推荐easeOutCubic(1 - (1-t)³)或easeInOutQuad,让滚动启停更自然。 -
边界防护不可少:检查
targetIndex范围、progress截断(Math.min(..., 1)),防止动画卡死或滚动失控。 -
状态同步:每次滚动完成后更新
currentSectionIndex,确保滚轮与按钮逻辑状态一致。 -
性能优化:
isScrolling标志位必须严格配对(开始置true,结束置false),并前置拦截重复调用。
✨ 总结
手动实现平滑滚动的价值在于完全掌控时序与运动曲线。成功的关键在于:明确区分“相对滚动”(滚轮)与“绝对定位”(按钮)的语义差异;始终基于文档绝对坐标计算目标位置;采用专业缓动函数替代线性插值;并通过严谨的状态管理和边界检查保障鲁棒性。此方案不仅修复了原始问题,更为复杂导航场景(如锚点联动、滚动监听高亮)提供了可扩展的基础架构。










