ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

网页自动滚动完全指南:从 scrollTop 原理到 rAF 动画与滚动场景封装

网页自动滚动完全指南:从 scrollTop 原理到 rAF 动画与滚动场景封装 前两天有个刚转前端的同事问我页面进入之后怎么让它自己往下滚他说看到某个活动页一打开就会自动播放滚动效果研究半天没搞明白。这种“自动滚动”的需求其实非常常见——回到顶部、新手引导、大屏数据轮播、公告跑马灯、触底加载……但能把原理讲清楚、又可直接拿来用的文章反而不多。这篇我把自动滚动的实现方式从简单到进阶完整过一遍先解释浏览器滚动到底是怎么回事再讲 CSS 和原生 JavaScript 两条路线最后补上列表轮播、跑马灯、无限滚动这些高频场景的封装思路以及我实际踩过的一些坑。1. 自动滚动到底在滚什么先搞懂滚动机制再说实现1.1 滚动位置的三个关键指标很多人学自动滚动时第一个卡住的点就是概念混淆。浏览器里的所谓“滚动”本质上是可视视口viewport在文档上的移动。你盯着屏幕看到的内容变了但背后的文档本身没变变的只是视口位置。描述这个位置有三个值scrollTop / scrollY垂直方向滚动的距离也就是视口上边缘距离文档顶部的距离。整页滚动时等价于window.scrollY某个带滚动条的容器则是element.scrollTop。scrollHeight内容的总高度包含没有被显示出来的部分。clientHeight可视区域的高度。文档能滚动多远取决于scrollHeight - clientHeight差值为 0 说明没有可滚动空间。我用大白话打个比方文档是一卷很长的胶片视口是放映窗口scrollTop 就是你摇动了多少圈胶片。自动滚动本质就是按某种规律去改变 scrollTop 或 scrollY 的值。1.2 自动滚动的三类高频场景实际操作中自动滚动的需求逃不出这三类一次性定位型回到顶部、锚点跳转、表单校验后滚动到错误项。特点是只需要从 A 点到达 B 点重点在于“准确”和“平滑”。持续位移型列表自动轮播、公告跑马灯、网页内容自动阅读。特点是需要一直“动”重点在于“循环”和“不穿帮”。条件触发型滚动到底部加载更多、滚动到某个区域高亮导航。特点是被动响应重点在于“判断时机”的准确。这三类场景看起来差异很大但底层都是同一套滚动机制。搞清楚这一点之后无论你用什么方案实现思路都不会跑偏。2. 最简方案CSS 一条属性覆盖多数定位场景2.1 scroll-behavior 到底做了什么如果你的需求只是“平滑地滚到某个位置”CSS 的scroll-behavior: smooth就能解决大半不需要写任何 JavaScript。html { scroll-behavior: smooth; }开了这条属性之后页面内所有跟滚动位置相关的操作都会自动带上平滑过渡效果包括锚点跳转a href#footer跳到底部/a点击链接时浏览器不再瞬间切换位置而是沿着滚动动画滑过去。对于内部滚动容器也一样只需在容器上设置.scroll-wrapper { overflow: auto; scroll-behavior: smooth; }简单是简单但有三个限制你得心里有数无法控制滚动速度、距离、缓动曲线动画完全由浏览器内部决定。scroll-behavior只影响“离散的滚动操作”比如锚点跳转、scrollTo调用不影响你手动拖滚动条或者滚轮滚动。老版本浏览器支持不完整后面第五章会专门讲兼容性问题。2.2 锚点跳转的补充细节scroll-margin 和 scroll-padding用了锚点跳转之后最容易遇到的一个问题滚动目标被吸顶导航遮住。你以为跳到了#section-2结果标题部分被顶部导航盖掉了一块体验很糟糕。解决方法是给目标元素加scroll-margin-top.section { scroll-margin-top: 80px; }或者在滚动容器上加scroll-padding-top.scroll-wrapper { scroll-padding-top: 80px; }这两个属性专门为滚动定位预留偏移量比“跳转之后再手动调整位置”的方案干净得多。如果目标元素是动态渲染的也建议提前设置好这些样式避免内容插入导致位置偏移。3. 原生 JS 手写自动滚动从 API 到动画原理3.1 三个核心 APIscrollTo、scrollBy、scrollIntoViewCSS 方案搞不定的场景比如“滚动动画跑了 800ms”“滚到一半用户点按钮要停”“先加速后减速”都需要 JavaScript 介入。最基础的是window.scrollTo。它有两种用法// 老式写法直接指定坐标 window.scrollTo(0, 1000); // 现代写法传配置对象 window.scrollTo({ top: 1000, behavior: smooth });scrollTo绝对定位滚到指定的坐标。scrollBy相对定位在当前基础上滚动一段距离。window.scrollBy({ top: 200, behavior: smooth });每次调用在当前位置往下滚 200 像素。对内部滚动容器调用方式类似只是把window换成容器元素element.scrollTo({ top: 200, behavior: smooth });还有一个容易被忽略的 API 是scrollIntoView它直接作用于目标元素本身document.getElementById(section-3).scrollIntoView({ behavior: smooth, block: center });block: center会让目标元素出现在视口中间适合弹窗调起、当前播放节点定位这类场景。这三个 API 的共同点是一次调用只管“从当前点到目标点”不会帮你处理连续动画。要实现“持续自动滚动”得自己控制频率。3.2 为什么用 requestAnimationFrame 而不是 setInterval这是很多初学者最容易写错的地方。有人实现自动滚动会这样写setInterval(() { window.scrollBy({ top: 2, behavior: smooth }); }, 16);表面看没问题实际用起来可能卡顿、发飘。问题出在setInterval的触发频率不跟屏幕刷新率同步而且浏览器在标签页切到后台时会大幅降频甚至暂停动画节奏完全不可控。正确的做法是使用requestAnimationFrame简称 rAF。它有两个优势回调跟屏幕刷新频率同步通常是每 16.7ms 执行一次动画不丢帧。页面不可见时自动暂停不会白白消耗资源。rAF 的基本套路是记录动画开始时间在每一帧里计算当前进度然后更新滚动位置。下面这个函数就是直接把 rAF 和缓动函数结合到一起的自动滚动工具函数function autoScrollTo(targetY, duration 600) { const startY window.pageYOffset || document.documentElement.scrollTop; const diff targetY - startY; let startTime null; if (Math.abs(diff) 1) { return; } // 缓动函数先加速后减速手感最自然 function easeInOutCubic(t) { return t 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t 2, 3) / 2; } function step(timestamp) { if (!startTime) { startTime timestamp; } const elapsed timestamp - startTime; // 保证进度不会超过 1 const progress Math.min(elapsed / duration, 1); const currentY startY diff * easeInOutCubic(progress); window.scrollTo(0, currentY); if (progress 1) { requestAnimationFrame(step); } } requestAnimationFrame(step); } // 用法 autoScrollTo(0, 800); // 回到顶部动画时长 800ms这段代码里最关键的一行是window.scrollTo(0, currentY)。注意这里传的是坐标值而不是配置对象因为每一帧都需要精确定位到当前位置如果传behavior: smooth浏览器又会启动自己的平滑动画两套动画叠加之后页面会抖动。3.3 缓动函数就是滚动的“手感”什么叫好的滚动动画关键是缓动函数。线性滚动从天亮滚到天黑看起来呆板先慢后快再慢中间有个轻盈的加减速观感会好很多。缓动函数只做一件事把动画进度从 0 到 1 的时间比例映射成 0 到 1 的运动比例。常见的几个缓动名称函数表达式适合场景线性t跑马灯匀速滚动easeOutCubic1 - Math.pow(1 - t, 3)回到顶部起步快收尾慢easeInOutCubic前面代码里的实现页面引导、锚点定位easeOutElastic带弹性回弹弹窗里的提示定位不宜常用在autoScrollTo里替换缓动函数只需要改一行。实际项目中我最常用easeInOutCubic观感均衡也不会给人过度花哨的感觉。4. 进阶玩法列表轮播、跑马灯和无限滚动加载4.1 列表自动轮播复制一份内容实现“无缝感”做数据大屏或后台消息面板时常见需求是让一条列表每 3 秒自动“翻”一项。最简单的实现是定时器加 scrollByconst list document.getElementById(list); const itemHeight 80; // 每一条的固定高度 let index 0; const timer setInterval(() { index 1; list.scrollTo({ top: index * itemHeight, behavior: smooth }); }, 3000);但有一个问题滚到最底部之后如果想丝滑地回到第一项直接scrollTop 0会显得很突兀。常见做法是把列表内容复制一份接到末尾滚完一遍之后瞬间归零const list document.getElementById(list); const itemHeight 80; const originalCount list.children.length; // 复制一份内容追加到列表尾部 for (let i 0; i originalCount; i) { const clone list.children[i].cloneNode(true); list.appendChild(clone); } let index 0; setInterval(() { index 1; // 滚到复制的第一项时瞬间归零继续下一轮 if (index originalCount) { list.scrollTop 0; index 0; } else { list.scrollTo({ top: index * itemHeight, behavior: smooth }); } }, 3000);关键点在于用户看到的视觉位置是连续的因为复制内容和原始内容长得一模一样归零那一刻发生在视觉切换的瞬间骗过了眼睛。如果列表项高度不固定itemHeight可以改成读取每一条的offsetTop逻辑不变。4.2 无缝跑马灯能用 transform 就别动布局跑马灯是另一种自动滚动。相比轮播跑马灯要求内容连续不断地横向移动常见于公告、股票行情和标签墙。最容易踩坑的是用margin-left或scrollLeft去改位置。这些方式每次位置变化都会触发布局重排内容多的时候肉眼可见掉帧。正确做法是使用 CSStransform配合 animationdiv classmarquee div classmarquee-track span这是一条滚动公告某某系统将于今晚升级维护/span span这是一条滚动公告某某系统将于今晚升级维护/span /div /div.marquee { overflow: hidden; white-space: nowrap; } .marquee-track { display: inline-flex; animation: marqueeMove 12s linear infinite; } keyframes marqueeMove { from { transform: translateX(0); } to { transform: translateX(-50%); } }这里把内容复制了两份动画从 0 移动到 -50%正好走完“第一份内容的宽度”结束后无缝衔接第二份视觉上就是循环播放。transform只触发合成器不触发布局和绘制跑起来很顺滑。如果跑马灯里是动态数据比如多条公告需要拼接可以在 JS 里拼好 HTML复制一份塞进 track 再启动动画const track document.querySelector(.marquee-track); const content span${text}/span; track.innerHTML content content;需要强调一下动态改变内容之后宽度计算要等浏览器完成布局再做。如果你发现动画衔接处有跳变多半是两份内容的宽度不一致导致的。可以先执行一次 track 文本复制然后再通过getBoundingClientRect()确认宽度相等。4.3 触底加载判断“快滚到底了”的标准公式无限滚动加载列表也就是触底加载属于“条件触发型”自动滚动。它不主动滚而是让用户滚滚到临界点自动请求下一页。判断条件有一个固定公式const container document.documentElement; function handleScroll() { const { scrollTop, scrollHeight, clientHeight } container; // 滚动到离底部 80px 以内时触发 if (scrollTop clientHeight scrollHeight - 80) { loadMore(); } } window.addEventListener(scroll, handleScroll);注意两个细节节流处理。scroll 事件触发频率很高不做节流的话每次滚动都有大量计算和网络请求。用 throttle 给触发条件加个 300ms 的冷却时间。let lock false; function handleScroll() { if (lock) { return; } if (scrollTop clientHeight scrollHeight - 80) { lock true; loadMore().finally(() { lock false; }); } }判断的容器。整页滚动用document.documentElement局部容器滚动用具体的element。如果页面里同时存在 body 和相关容器的滚动容易把clientHeight判错建议在接到需求时先确认滚的是窗口还是某个 div。5. 实测中必须避开的坑兼容性、性能和交互冲突5.1 behavior: smooth 的兼容性降级scrollTo({ behavior: smooth })不是所有浏览器都支持。iPhone 上的 Safari 到 15.4 才完整支持更早版本只支持老式双参数调用。如果你在代码里放了behavior: smooth而不做判断老设备上的效果就是“直接跳过去”看起来像没实现。兼容性检测其实很简单function isSmoothSupported() { return scrollBehavior in document.documentElement.style; }优先走平滑动画不支持时退回window.scrollTo(0, targetY)直接跳转或者手动 rAF 动画。我个人习惯是直接用手写 rAF 方案统一体验也避免两条路径在代码里打架。5.2 CSS smooth 和 JS 手动动画互相打架我调试过最诡异的一个 bug 是给根元素开了scroll-behavior: smooth然后手写了一段 rAF 动画结果页面上下抖动、忽快忽慢。排查了半天才意识到问题出在scrollTo每次传坐标值时CSS 的 smooth 属性又给滚动施加了一次平滑过渡等于两套动画同时在跑。解决方式很简单手写动画循环时不要在两个地方同时启用平滑。要么去掉全局的scroll-behavior: smooth要么在动画区间内临时把它置为autodocument.documentElement.style.scrollBehavior auto; // 执行你的 rAF 动画... // 动画结束再恢复原样 document.documentElement.style.scrollBehavior smooth;这属于平时不会踩、一踩就头疼的坑提前写预防方案比事后排查舒服得多。5.3 后台标签页定时器降频与切换回来错位第三个坑跟生命周期有关。浏览器为了省电会让后台标签页的定时器大幅降频。setInterval写的是 3 秒一次切到后台 10 分钟回来可能这期间只执行了一两次。表现就是自动轮播列表回来之后位置停在很奇怪的中间态。处理方法是监听visibilitychange页面切换回来时重新对齐滚动位置document.addEventListener(visibilitychange, () { if (!document.hidden) { list.scrollTop currentIndex * itemHeight; } });另外纯 rAF 动画在后台标签页会自动暂停这个反而是优点不会出现定时器积压的问题。如果你的项目里有长时间后台运行的需求优先选择 rAF 状态机设计而不是只靠定时器循环。5.4 超长页面图片加载导致的滚动错位还有一种常见情况页面里图片很多自动滚动开始时scrollHeight还没完全撑开滚到一半图片加载完成文档高度一下子变大目标位置就不对了。应对方式是把滚动动作放到window.onload之后或者监听图片的load事件。如果是轮播类组件还可以在每次滚动之前重新读一次目标元素的offsetTop不要缓存固定值function getNextPosition() { const node document.querySelector(.next-section); return node.offsetTop; // 每次都重新读取避免高度变化后位置错乱 }6. 组合到一起一个通用自动滚动工具的封装思路如果只是临时需求散落的函数完全够用。但项目里出现三四个滚动场景之后我建议做一个统一封装。这里提供一种很轻量的思路——用一个AutoScroller类管理状态支持容器、时长、缓动、取消和完成回调const EASINGS { linear: (t) t, easeInOutCubic: (t) t 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t 2, 3) / 2 }; class AutoScroller { constructor(options {}) { this.easing options.easing || EASINGS.easeInOutCubic; this.duration options.duration || 600; this.running false; this.rafId null; } scrollTo(element, targetY, duration this.duration) { this.cancel(); this.running true; const startY element window ? window.pageYOffset : element.scrollTop; const diff targetY - startY; let startTime null; return new Promise((resolve) { const step (timestamp) { if (!startTime) { startTime timestamp; } const progress Math.min((timestamp - startTime) / duration, 1); const value startY diff * this.easing(progress); if (element window) { window.scrollTo(0, value); } else { element.scrollTop value; } if (progress 1 this.running) { this.rafId requestAnimationFrame(step); } else { this.running false; resolve(progress 1); } }; this.rafId requestAnimationFrame(step); }); } scrollBy(element, distance, duration this.duration) { const startY element window ? window.pageYOffset : element.scrollTop; return this.scrollTo(element, startY distance, duration); } cancel() { this.running false; if (this.rafId) { cancelAnimationFrame(this.rafId); this.rafId null; } } }在 React 或 Vue 里使用时只需要在组件销毁时调用cancel()// Vue 示例 const scroller new AutoScroller(); function onClickBackTop() { scroller.scrollTo(window, 0, 800); } onUnmounted(() { scroller.cancel(); });如果你接手的项目里有多个滚动容器还可以在类里加一个map用容器的 Key 保存各自的动画状态这样同一个页面里两个列表各滚各的互不影响。实际做下来这套封装能覆盖我遇到的大部分自动滚动需求。写到最后说说我自己的体会。自动滚动看起来是一个很小的功能点但恰恰是这些细节决定了页面体验的上限。实现之前先花两分钟想清楚场景分类——是一次性定位、持续循环还是触底响应——比直接抄代码重要得多。尤其是缓动函数和障碍防护这两个点一旦处理好即使项目后期加了再复杂的需求你也只需要在这个框架上填新内容而已。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进