上个月帮一个朋友排查他那个后台管理系统的毛病:订单列表滚到第四十几条,右下角那个向上的小箭头戳了半天,页面纹丝不动,控制台干净得像刚洗过。我看了一眼他的代码,一行window.scrollTo(0, 0)写得挺标准,问题出在这个页面的滚动压根不在window上——整页被包在一个overflow: auto的容器里,真正在滚的是那个 div。这就是"点击按钮回到顶部"这件事最典型的样子:看起来是一行代码的事,实际上一半的坑都在你没注意到的前提条件上。
这篇东西就围绕"回到顶部按钮"这个前端里最不起眼的小组件展开,把常见的五种实现写法一个个拆开讲清楚:锚点跳转、window.scrollTo、容器scrollTop赋值、scrollIntoView、以及requestAnimationFrame手写缓动。除了代码本身,我更想聊的是每种写法背后那些只有真踩过才知道的细节——URL 被搞脏、路由跳错页、固定头部把标题挡住、动画到一半被用户滚轮打断、iOS 上按钮被键盘顶飞。适合刚入门前端、正在补组件库功课的同学,也适合做了几年但一直把这按钮当"随手一写"的老手对照着看看有没有漏掉什么。
1. 先定位滚动主体:页面级滚动和容器级滚动不是一回事
1.1 一个"点了没反应"的按钮暴露出的第一个问题
回到开头那个 bug。朋友的页面结构大致是这样:顶部固定导航、中间一个内容区<div class="content">,CSS 里给这个 div 设了height: calc(100vh - 60px); overflow-y: auto;。浏览器地址栏旁边的滚动条其实一直没动过,用户看到"页面在滚",滚的是那个 div 的内部偏移量。
这个区分必须先讲清楚,因为后面五种写法全都建立在它之上。页面级滚动的主体是document.documentElement(标准模式下),你能用window.scrollY、window.scrollTo()直接操作;容器级滚动的主体是某个具体的 DOM 元素,得用element.scrollTop读写,或者对它调用element.scrollTo()。
判断当前页面属于哪一种,最快的办法是在控制台敲两句:
window.scrollY // 页面级滚动距离 document.querySelector('.content').scrollTop // 容器级滚动距离如果滚动的时候前者一直是 0、后者在变,那就是容器级滚动,window.scrollTo写一百遍也没用。我见过不少人第一反应是加个!important或者怀疑事件没绑上,绕一大圈才发现方向错了。
还有一种更隐蔽的情况:页面本身能滚,同时里面某个区域也能滚。这种"双层滚动"在仪表盘类界面里特别常见。这时候按钮到底该滚谁,取决于用户的视觉焦点在哪——如果用户是盯着中间那个表格在滚,你把页面滚回顶部,他的表格还停在第二百行,体验一样糟糕。
提示:在写任何回到顶部的逻辑之前,先在控制台手动执行一次目标滚动语句,确认视觉上真的动了。这一步能省掉后面 80% 的无效调试。
1.2 五种写法的能力对照
先把五种写法摆在一起看,后面再逐条展开。这张表是我自己整理常用速查用的,放在这里方便你直接对照选型:
| 写法 | 核心 API | 平滑滚动 | 可控性 | 典型适用场景 |
|---|---|---|---|---|
| 锚点跳转 | <a href="#top"> | 依赖 CSSscroll-behavior | 低 | 静态页、文档类长页面 |
| 窗口滚动 | window.scrollTo({top:0, behavior:'smooth'}) | 原生支持 | 中 | 常规页面级滚动 |
| 容器滚动 | el.scrollTop = 0/el.scrollTo() | 原生支持(较新内核) | 中 | 局部滚动区域、弹窗内列表 |
| 元素定位 | el.scrollIntoView({block:'start'}) | 原生支持 | 中 | 需要滚到某个具体位置而非绝对顶部 |
| 手写缓动 | requestAnimationFrame逐帧计算 | 完全自己控制 | 高 | 需要自定义时长/曲线/中断处理 |
"可控性"这一列是我自己最看重的维度。原生behavior: 'smooth'的动画时长在不同浏览器内核里并不一致,也没有参数能调,对于强调品牌动效的产品来说这就是个黑盒。而手写requestAnimationFrame版本,每一帧滚动到哪个像素都是你说了算——代价是要自己处理中断、性能和大数字精度。
另外提前说一句:不要以为用了框架就绕开了这些问题。React、Vue 里点按钮回到顶部,最终执行的还是这几个 DOM API,框架只是帮你把事件和方法连起来,滚动主体判断、URL 处理、动画中断这些坑一个都不会少。
2. 锚点跳转:一句 href 撑了二十年,但它有三个副作用
2.1#与#top的真实差异
最老的写法就是超链接锚点。在页面顶部放一个<div id="top"></div>,按钮写成<a href="#top">回到顶部</a>,点击之后浏览器会自动把目标元素滚进视口。这是 HTML 原生能力,不需要一行 JS,兼容性从二十年前的古董浏览器到今天的最新版本都没问题。
但很多人图省事会写成href="#",这两者其实有区别。href="#"指向的是文档顶部这个隐含的默认锚点,行为上确实也能回到顶部,但它同时会把地址栏的 hash 变成单独一个#。这个变化会带来一串连锁反应:如果页面用了 hash 路由(很多前端路由库的历史模式之外还有一种 hash 模式),路由可能会把它识别成一个空路径跳转;如果页面里还有其他依赖hashchange事件的逻辑,会被无端触发一次;刷新之后地址栏挂着一个孤零零的#,说不上错但很难看。
我自己的习惯是显式写id:
<div id="page-top"></div> ... <a href="#page-top" class="back-to-top">回到顶部</a>这样 URL 变成#page-top,语义明确,也不会和路由的空 hash 撞车。如果页面是用框架渲染的,记得把锚点容器放在最外层布局里,不要放在某个会被卸载的路由组件内部,否则组件切走之后href指向的目标就不存在了,点击毫无反应。
至于href="javascript:void(0)"这种写法,我的建议是直接放弃。它除了让链接失去原生语义、影响键盘导航和右键"在新标签页打开"之外,在一些内容安全策略较严格的环境里还可能被拦。
2.2 URL 变脏、路由冲突与焦点丢失
锚点方案的第一个副作用是URL 污染。每点一次回到顶部,地址栏就多一段#page-top,用户可以收藏、可以分享,分享出去之后别人打开页面会直接定位到那个锚点位置——这在大多数场景下无害,但如果你的页面顶部有个动效,锚点定位会让它一进页面就已经处于"滚过一段"的状态,视觉上很怪。
第二个副作用是和历史记录的关系。锚点跳转默认会往浏览器的历史栈里压一条记录,用户点三次回到顶部,就要按三次后退键才能退回上一页。这个体验非常糟糕。解决办法是给点击事件加preventDefault,然后用history.replaceState手动改 hash:
document.querySelector('.back-to-top').addEventListener('click', (e) => { e.preventDefault(); document.querySelector('#page-top').scrollIntoView({ behavior: 'smooth' }); history.replaceState(null, '', location.pathname + location.search); });第三个副作用经常被忽略:焦点转移。浏览器处理锚点跳转时,会把焦点移动到目标元素上。如果目标元素是个不可聚焦的div,焦点会落到body上,此时如果用户接着按 Tab 键,焦点会从页面开头重新走一遍——对键盘用户来说这其实是对的(因为视觉上也回到了顶部),但如果你的按钮是个自定义组件、跳转后又立刻用 JS 把焦点抢回按钮,键盘用户就会陷入"按 Tab 跳回按钮、按 Enter 又回顶部"的死循环。
所以锚点方案我的结论是:静态文档、博客正文页、纯 HTML 项目里用它非常合适,省事且稳;交互复杂、有路由、有动效的单页应用里,它带来的边界情况比省下的代码多。
3. scrollTo 与 scrollTop:最直白的两条路,坑藏在细节里
3.1 window.scrollTo 的两种调用形式
window.scrollTo有两种签名,很多人只知道第一种:
// 形式一:两个数字参数,瞬时跳转,没有过渡 window.scrollTo(0, 0); // 形式二:对象参数,可以指定平滑行为 window.scrollTo({ top: 0, left: 0, behavior: 'smooth' });第一种是同步的、瞬间完成的,用户点下去页面"唰"地一下就到底了。在小页面上无所谓,但在内容很长的页面里,这种瞬移会让用户瞬间失去空间感——他不知道自己刚才看的东西跑到哪去了,想回去找还得重新滚。这也是为什么大部分产品最终都会选平滑滚动。
第二种形式里的behavior是关键字。它支持三个值:auto(跟随 CSS 的scroll-behavior)、smooth(平滑)、instant(瞬时)。注意auto不等于instant,它是"由 CSS 决定",这是个容易搞混的点。
对象形式还有一个隐性的好处:参数是命名的。scrollTo(0, 0)谁都知道是回到左上角,但如果是滚到某个特定位置,scrollTo(0, 860)和scrollTo(860, 0)一眼看不出区别,而{ top: 860, left: 0 }就不会搞错。
实际用的时候我会把它包一层,处理掉浏览器不支持对象参数的情况:
function scrollWindowToTop() { if ('scrollBehavior' in document.documentElement.style) { window.scrollTo({ top: 0, behavior: 'smooth' }); } else { window.scrollTo(0, 0); } }'scrollBehavior' in style这个特性检测比查各种版本号可靠得多,也不依赖任何外部库。
3.2 documentElement 和 body 到底该动谁
这是前端面试里出现过、但实际写代码时依然会绊人的老问题。历史上不同内核把"页面滚动容器"映射到了不同元素上:标准模式下是document.documentElement(也就是<html>),怪异模式(没有<!DOCTYPE>的页面)下是document.body。
于是就有了那段流传很久的兼容代码:
document.documentElement.scrollTop = 0; document.body.scrollTop = 0;现在还需要这么写吗?我的判断是:如果你的页面有正确的<!DOCTYPE html>声明(现代项目基本都有),只写documentElement就够了,往body上赋值不会报错但也不会有任何效果,属于纯冗余。而在一些老的移动端内核里,document.body.scrollTop反而是有效的那个。
更稳的做法是干脆别碰这两个属性,统一用window.scrollTo——它内部会帮你处理掉这层映射关系。只有当你要读写某个具体容器的滚动位置时,才需要element.scrollTop。
说到容器,这里有个很实用的技巧:一个页面里可能有多个可滚动容器,写一个通用的回到顶部方法比给每个容器写一遍舒服得多:
function scrollContainerToTop(el, smooth = true) { if (!el) return; // 传 window 的情况统一交给 window.scrollTo if (el === window || el === document || el === document.documentElement) { window.scrollTo({ top: 0, behavior: smooth ? 'smooth' : 'auto' }); return; } el.scrollTo({ top: 0, behavior: smooth ? 'smooth' : 'auto' }); }Element.prototype.scrollTo和window.scrollTo的参数签名是一致的,所以这段代码可以覆盖绝大部分场景。不过要注意,老一些的内核里元素的scrollTo不支持behavior参数,传了会被静默忽略,表现为"直接瞬移"。如果你的产品对平滑有硬性要求,就得走下一节的手写缓动路线。
3.3 scroll-behavior 这个 CSS 开关会牵连全局
html { scroll-behavior: smooth; }这一行 CSS 能让页面里所有滚动行为都变平滑——包括锚点跳转、scrollTo(0,0)、甚至浏览器的"查找"定位。看起来很香,但它的影响面是全局的,我踩过两次坑。
第一次是路由。单页应用里,从一个页面跳到另一个页面时,路由库通常会在挂载后执行一次window.scrollTo(0, 0)来复位滚动位置。加上scroll-behavior: smooth之后,这个复位变成了动画,用户看到的是新页面从底部"缓缓升上来",配合入场动画简直是灾难。第二次是"跳转到评论区"这类功能:产品经理要的是"点一下立刻定位到评论",结果因为全局平滑,定位过程拖了半秒,用户以为没点上又点了一次。
所以我现在只在这两种情况下全局开平滑:
- 纯内容站、文档站,页面里没有复杂的路由切换;
- 明确知道项目里所有滚动调用点,并且逐个确认过没有冲突。
否则我更倾向于只在需要平滑的那次调用上写behavior: 'smooth',把控制权留在 JS 里,而不是交给全局 CSS。
还有一个必须提的配套项:@media (prefers-reduced-motion: reduce)。有一部分用户因为前庭功能原因,对大幅度位移动画会产生不适,系统层面开了"减少动态效果"。你的平滑滚动应该尊重这个设置:
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }如果是 JS 手写动画,就改用window.matchMedia('(prefers-reduced-motion: reduce)').matches判断,命中时直接把时长设为 0。这一行代码的成本几乎为零,但对特定用户群体是实打实的体验改善。
4. scrollIntoView 和手写缓动动画:把节奏握在自己手里
4.1 scrollIntoView 的 block 参数与固定头部遮挡
scrollIntoView的思路和锚点一样——不是"滚到坐标 0",而是"把某个元素滚到可见区域"。区别在于它可以被 JS 调用,还能带参数:
document.querySelector('#page-top').scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' });block控制垂直方向的对齐方式,start表示让元素顶端对齐容器顶端,end是底端对齐,center居中,nearest是"滚到最近的那个边"(如果元素已经在视口里就不动)。inline同理,管水平方向。
它的一个好处是不关心滚动主体是谁。浏览器会自己向上遍历找到最近的可滚动祖先,逐个把它滚到合适位置。这解决了开头那个 bug 的一半问题——不用判断滚的是 window 还是某个 div。
但它有个非常常见的坑:固定头部遮挡。页面顶部有个position: fixed; height: 64px的导航栏,你scrollIntoView({block:'start'})之后,目标元素的顶部正好藏在导航栏底下,用户看到的是内容被切掉一截。这个问题的标准解法是用 CSSscroll-margin-top:
#page-top, .anchor-target { scroll-margin-top: 72px; /* 头部高度 64 + 留白 8 */ }scroll-margin系列属性专门用来给滚动定位留出边距,scrollIntoView、锚点跳转、scroll-padding都会尊重它。相比以前用"多加一个空白占位 div"或者"滚到坐标减去 64"的土办法,这个属性干净太多了。
还有一个更细的点:如果页面同时存在"整个文档能滚"和"内部容器也能滚"的嵌套结构,scrollIntoView有时会把所有可滚动的祖先都滚一遍,导致外层页面也跳了一下。这种情况我一般会在用完之后检查一下外层容器位置,或者干脆退回手动计算坐标的方案。
4.2 requestAnimationFrame 版本从零写一遍
当原生behavior: 'smooth'满足不了需求——比如产品要求"必须是 600 毫秒、必须是先快后慢再收尾"——就得自己写。核心思路是:记录起始滚动位置和时间戳,每一帧根据进度算出一个插值位置,然后赋给滚动容器。
function smoothScrollToTop(duration = 500) { const scroller = document.scrollingElement || document.documentElement; const start = scroller.scrollTop; if (start <= 0) return; const startTime = performance.now(); // 先慢后快再慢的缓动曲线,观感最接近原生 const easeInOutCubic = (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; function step(now) { const elapsed = now - startTime; const progress = Math.min(elapsed / duration, 1); scroller.scrollTop = start * (1 - easeInOutCubic(progress)); if (progress < 1) { requestAnimationFrame(step); } } requestAnimationFrame(step); }几个关键点值得解释一下。
为什么用performance.now()而不是Date.now():performance.now()是单调递增的高精度时间戳,不受系统时间调整影响,精度到微秒级。用Date.now()的话,如果动画进行中系统时间被同步了一次,进度可能突然跳到 1 或者变成负数,动画就闪了。
为什么滚动的距离用start * (1 - eased):因为我们是从start滚到 0,任意时刻的位置等于"起点减去已经走过的部分"。而已经走过的部分等于start * eased。这样写的好处是全程只依赖起点值,不累加误差。
document.scrollingElement这个属性值得单独说一句:它会返回当前文档真正在滚动的那个元素,标准模式下是<html>,怪异模式下是<body>,帮你自动做了那个兼容判断。除了极老的浏览器,现代环境都可以直接用。
easeInOutCubic这条曲线我用了很多年。它的特征是开头和结尾都慢、中间快,视觉上接近人推开一扇沉重的门再停住的感觉。相比之下纯线性的t会让动画显得机械,easeOutQuad又收得太急,像被谁拽停了一样。
4.3 动画中途被用户打断该怎么收场
手写动画绕不开一个问题:用户滚到一半,自己滚了一下滚轮或者开始滑动屏幕。这时候如果你还在每一帧强行赋值scrollTop,就会和用户的输入打架,表现是页面在抖、滚不动、或者动画结束后突然跳到顶部。
正确的做法是监听用户输入并立即终止动画:
let rafId = null; let aborted = false; function cancelScroll() { aborted = true; if (rafId) cancelAnimationFrame(rafId); rafId = null; } window.addEventListener('wheel', cancelScroll, { passive: true }); window.addEventListener('touchstart', cancelScroll, { passive: true }); window.addEventListener('keydown', (e) => { // 空格、PageUp/PageDown、方向键都算用户接管 if ([' ', 'PageUp', 'PageDown', 'ArrowUp', 'ArrowDown'].includes(e.key)) { cancelScroll(); } });这里有两个细节容易被忽视。
第一是{ passive: true }。wheel和touchstart这类高频事件如果没标 passive,浏览器会担心你在回调里调preventDefault从而阻塞滚动,导致滚动变得卡顿。加上 passive 等于告诉浏览器"我保证不阻止默认行为",滚动流畅度会明显改善。
第二是标记位要复位。上面这段代码里aborted一旦为 true 就再也不会变回来,下一次点击按钮动画会直接失效。正确写法是在每次启动动画前重置,并且在step函数里检查这个标记:
function step(now) { if (aborted) return; // ...计算并赋值 if (progress < 1) rafId = requestAnimationFrame(step); }另外还有一个隐蔽的中断源:滚动位置被别的东西改动。比如页面里有个"展开全文"按钮,点击后内容变高,浏览器会自动调整滚动位置。如果你只监听输入事件,这种情况下动画会继续跑并把位置强行拉回去。稳妥一点的做法是在每一帧里比对一下当前实际位置和上一帧你设的位置,差得太多就认为是外部干预,主动放弃。
5. 按钮本体的工程细节:显示阈值、节流与移动端适配
5.1 用 IntersectionObserver 替掉滚动监听
回到顶部按钮不是一直该显示的。页面还在顶部的时候,右下角挂个箭头既没用又碍眼。传统的做法是监听scroll事件,比较scrollY和某个阈值:
let ticking = false; window.addEventListener('scroll', () => { if (ticking) return; ticking = true; requestAnimationFrame(() => { btn.hidden = window.scrollY < 400; ticking = false; }); }, { passive: true });这段代码本身没问题,requestAnimationFrame加锁的写法也确实是标准做法——如果没有这层锁,滚动过程中每帧可能触发多次回调,白白消耗主线程时间。
但我想推荐另一个方案:IntersectionObserver。思路是在页面顶部往下 400px 的位置放一个"哨兵"元素,观察它是否还在视口内,不在视口内就说明用户滚远了,该显示按钮了:
const sentinel = document.querySelector('#top-sentinel'); const io = new IntersectionObserver(([entry]) => { btn.hidden = entry.isIntersecting; }, { threshold: 0 }); io.observe(sentinel);这个方案的优势在于回调是异步的、由浏览器在合适的时机批量触发,完全不需要你自己做节流,也不会和滚动帧竞争主线程。在低端机上体感差异很明显。
代价是需要多一个 DOM 节点。我一般会把它做成一个零高度的占位:
<div id="top-sentinel" aria-hidden="true" style="position:absolute;top:400px;height:1px;width:1px;"></div>position: absolute保证它不占布局空间,aria-hidden让读屏软件忽略它。
判断阈值选多少,我的经验是跟着内容走而不是跟固定数字走。400px 在手机上大概是两屏,在桌面端连一屏都不到。更合理的做法是取视口高度的 0.8 到 1.2 倍,让"用户已经看完一屏"成为显示条件。当然,最简单可靠的方式还是用哨兵元素——你把哨兵放在哪,阈值就是哪。
5.2 固定按钮在移动端的三个具体麻烦
按钮的交互逻辑写完之后,样式和定位才是真正磨人的部分。移动端上我遇到过这三个问题,每一个都耗过不止半小时。
第一个是底部工具栏和小黑条(Home Indicator)重叠。按钮如果用bottom: 20px固定,在某些全面屏机型上会和大圆角、手势条挤在一起,点起来很别扭。解法是加安全区边距:
.back-to-top { position: fixed; right: 16px; bottom: calc(16px + env(safe-area-inset-bottom, 0px)); z-index: 100; }env()里的safe-area-inset-bottom会返回设备底部需要避让的高度,非全面屏机型返回 0,calc加上去不会出错。
第二个是键盘弹出时按钮乱飞。移动端软键盘弹起会让视口高度变化,如果按钮用了bottom: 0加百分比定位,可能会被顶到屏幕中间。这时候需要配合visualViewport判断,或者干脆在输入框聚焦时把按钮隐藏掉——后者实现成本低很多。
第三个是长列表滚动时的性能。按钮本身是个固定定位元素,只要它不变形,浏览器会把它提升到独立的合成层,滚动时几乎不参与重绘。但如果给它加了box-shadow、backdrop-filter这类属性,某些内核下反而会拖慢滚动。我在一个低端安卓机上实测过,backdrop-filter: blur(8px)的按钮会让滚动帧率从 55 掉到 30 出头。换成半透明纯色背景,问题消失。
还有一点是按钮尺寸。移动端可点击区域建议不小于 44×44 逻辑像素,很多人做的箭头图标只有 24px 见方,实际点击体验很差。用 padding 把热区撑开就行,视觉尺寸不用变。
5.3 可访问性:button 还是 a,以及键盘用户怎么用
这个细节几乎没有产品文档会写,但它是"做完了"和"做好了"之间的差距。
如果按钮纯粹触发一个 JS 行为,用<button type="button">是更正确的选择。它天然可聚焦、支持 Enter 和 Space 触发、读屏软件会读成"按钮"。用<a>或<div>的话,必须手动补tabindex="0"、role="button"、以及 Enter/Space 的键盘事件处理,少一样键盘用户就废了。
如果用的是纯图标按钮,一定要有可读的文本:
<button type="button" class="back-to-top" aria-label="回到页面顶部"> <svg aria-hidden="true" focusable="false">...</svg> </button>aria-label是给读屏软件的,aria-hidden="true"加在 svg 上是防止它被当成图片念出来,focusable="false"是照顾某些老版本浏览器把 svg 也放进 Tab 序列的问题。
最后是一个容易被忘掉的收尾动作:点击之后焦点该去哪儿。如果按钮跟着消失了(因为回到了顶部,阈值判断后隐藏),焦点会落到<body>上,键盘用户按 Tab 得从头开始找。更友好的做法是把焦点移到一个语义合理的容器上:
target.setAttribute('tabindex', '-1'); target.focus({ preventScroll: true });preventScroll: true是关键——不写的话浏览器聚焦时会再滚一次,把我们刚做好的平滑动画覆盖成瞬间跳转,等于白干。
6. 选型与踩坑:我会怎么在真实项目里挑
6.1 按场景选型的对照表
讲了五种写法,落到实际项目上其实就三个问题:滚的是谁、要不要平滑、需不需要自定义动效。把这三个问题的答案排列组合一下,选型路径就很清楚了:
| 场景特征 | 推荐写法 | 理由 |
|---|---|---|
| 纯静态文档、内容站 | 锚点#id+ CSSscroll-behavior | 零 JS,最省事,天然支持分享定位 |
| 常规活动页、落地页 | window.scrollTo对象形式 | 一行搞定,平滑交给原生 |
| 后台系统、有内层滚动容器 | element.scrollTo或通用封装 | 必须先判断滚动主体 |
| 需要滚到某个区块而非顶部 | scrollIntoView+scroll-margin-top | 不用算坐标,自动找滚动祖先 |
| 有品牌动效要求、时长要精确 | requestAnimationFrame手写 | 唯一能控制曲线和中断的方案 |
| 兼容老旧内核的政企项目 | 锚点或scrollTop赋值 | 不依赖较新的 API |
我个人在绝大多数项目里的默认选择是:用window.scrollTo的对象形式,配上特性检测降级。够用、可读、出问题好排查。只有当产品明确要求"动画 600ms、先快后慢"或者"用户一滚就停"时,才上requestAnimationFrame。
框架项目里我会把它做成一个可复用的小函数放在utils里,组件里只负责传滚动容器和按钮的显隐状态。这样做的好处是,将来无论是换框架还是重构,滚动逻辑这部分是稳定的,不用重写。
6.2 几个真的会让人加班到深夜的坑
最后整理几条我自己踩过的、与代码本身无关但同样致命的问题。
坑一:多个回到顶部按钮打架。页面里既有全局的回到顶部,又有某个抽屉里的回到顶部。两个按钮共用同一个类名和一个全局事件监听,结果点抽屉里的按钮把整个页面滚回去了。解决方式是给每个可滚动容器绑定自己的按钮,用>