滚动穿透问题的成因分析与前端解决方案实战

皇甫思晨 优化 阅读 823
赞 8 收藏
二维码
手机扫码查看
反馈

又踩坑了,弹窗一出来背景还能滚

上周做移动端项目,搞了个全屏弹窗,结果测试一提:「你这弹窗打开的时候,底下页面还能滑动,手指一划就滚走了,体验贼差」。我一开始还不信,自己试了下——好家伙,真能滚!明明弹窗盖住了整个屏幕,怎么背景还在响应滚动?这不就是传说中的「滚动穿透」嘛。

滚动穿透问题的成因分析与前端解决方案实战

其实这问题以前见过,但每次处理都糊里糊涂的,这次决定彻底搞明白。

折腾半天,先试试最简单的方案

第一反应:给 body 加个 overflow: hidden 不就完事了?

body.locked {
  overflow: hidden;
}

然后 JS 控制弹窗打开时加 class,关闭时移除。本地 Chrome 模拟器跑得挺顺,结果一上真机(iPhone),发现根本没用!页面照样能滚,而且有时候还会把整个页面往上「吸」一下,特别诡异。

后来查了才知道,iOS Safari 对 overflow: hidden 在 body 上基本无视,尤其是在有弹性滚动(rubber-banding)的情况下。这招在桌面端还行,移动端直接废掉。

那用 preventDefault 阻止 touchmove?

行,那就监听 touchmove 事件,直接 preventDefault()。思路很简单:弹窗打开时,禁止页面的 touchmove。

function preventScroll(e) {
  e.preventDefault();
}

// 弹窗打开时
document.body.addEventListener('touchmove', preventScroll, { passive: false });

// 弹窗关闭时
document.body.removeEventListener('touchmove', preventScroll);

注意这里 { passive: false } 很关键,不然现代浏览器默认 passive 为 true,preventDefault 会被忽略。这点我一开始忘了,卡了半小时才想起来。

改完一测,iOS 上确实不滚了!但新问题来了:如果弹窗内容本身是可滚动的(比如一个长表单),那用户也不能滚弹窗内容了!因为整个 body 的 touchmove 被一刀切了。

这时候就得加判断:只有当滚动目标不是弹窗内部时,才阻止。于是改成:

function preventScroll(e) {
  const target = e.target;
  // 假设弹窗容器有 .modal-content 类
  const modalContent = document.querySelector('.modal-content');
  
  if (!modalContent.contains(target)) {
    e.preventDefault();
  }
}

但这样又有个坑:如果用户手指从弹窗外开始滑,但滑到弹窗区域,逻辑会混乱;或者反过来。而且 contains 判断在动态 DOM 下也不一定可靠。

更麻烦的是,有些安卓机对 preventDefault 反应迟钝,还是会漏滚一两像素。

核心代码就这几行:记录滚动位置 + fixed 定位

折腾一圈后,我翻了下社区方案,发现最稳的其实是「锁 body + 补偿滚动偏移」组合拳。

原理是这样的:

  • 弹窗打开前,记录当前页面的 scrollTop
  • 给 body 加 position: fixed; top: -{scrollTop}px; width: 100%
  • 同时设置 overflow: hidden 防止出现滚动条闪动
  • 关闭时恢复原样,并把 scrollTop 设回去

这样 body 被固定住了,自然不会滚,而且因为 top 是负的,视觉上页面位置不变。最关键的是,这个方案不影响弹窗内部滚动。

完整代码如下:

let scrollY = 0;

function lockBody() {
  scrollY = window.scrollY;
  document.body.style.position = 'fixed';
  document.body.style.top = -${scrollY}px;
  document.body.style.width = '100%';
  document.body.style.overflow = 'hidden'; // 防止 iOS 弹出滚动条
}

function unlockBody() {
  document.body.style.position = '';
  document.body.style.top = '';
  document.body.style.width = '';
  document.body.style.overflow = '';
  window.scrollTo(0, scrollY);
}

调用时机:

// 打开弹窗
lockBody();

// 关闭弹窗(比如点击遮罩或关闭按钮)
unlockBody();

亲测在 iOS 15+、安卓 Chrome、微信内置浏览器都表现正常。唯一的小瑕疵是:如果页面用了 vh 单位,fixed 后可能会导致布局轻微变化(因为 vh 在 fixed 下计算方式不同)。不过我们项目没用 vh,所以无伤大雅。

另外,记得在 unlockBody 里用 window.scrollTo(0, scrollY) 恢复位置,否则关闭弹窗后页面会跳回顶部。

踩坑提醒:这三点一定注意

写这个方案时,我踩了三个坑,分享出来省得你再掉:

  1. 别忘 width: 100%:body fixed 后如果不设 width,某些安卓机会导致页面横向收缩,出现空白。
  2. iOS 地址栏问题:在 iOS 上,如果页面高度刚好等于视口,有时 fixed 会导致地址栏自动收起,视觉上「跳」一下。不过实际使用中影响不大,用户注意力在弹窗上。
  3. 多个弹窗嵌套:如果你有「弹窗里再开弹窗」的需求,得用栈来管理 scrollY,否则关闭外层弹窗时会把内层的位置搞乱。我们项目没这需求,就没处理,但你要有就得考虑。

有没有更优雅的方案?

其实现在有些库比如 body-scroll-lock 就是干这个的,封装好了各种 edge case。但项目为了轻量没引入,自己写也就十几行。

另外,CSS 新特性 overscroll-behavior: contain 理论上也能解决,但兼容性太差(iOS 全线不支持),目前只能作为辅助手段:

.modal {
  overscroll-behavior: contain;
}

但单独用它根本挡不住背景滚动,只能配合 JS 方案一起用,聊胜于无。

所以综合来看,还是「fixed + 记录 scrollTop」最靠谱,虽然看起来有点 hacky,但实测稳定。

结尾碎碎念

滚动穿透这问题看似简单,真要兼容所有移动端设备,还真得花点时间试错。我这个方案不是理论最优,但胜在简单、有效、改动小。

以上是我踩坑后的总结,如果你有更好的方案,或者遇到我没提到的边界情况,欢迎评论区交流。毕竟前端这行,谁还没被滚动条折磨过呢?

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论
Top丶鑫平
读完这篇文章,我对自己的学习方法更有信心了,原来我走的路是对的。
点赞
2026-03-02 11:26