弹窗遮罩不滚动背景?前端开发者必学的实战技巧(附完整方案)
- 弹窗遮罩不滚动背景?前端开发者必学的实战技巧(附完整方案)
- 当弹窗遇上滚动,页面“抖”得你心慌
- 弹出遮罩与背景滚动冲突的底层原理
- 主流方案 3 连击:CSS、JS、CSS+JS
- 纯 CSS 的“一分钟”方案:看上去很美
- JS 动态冻结:把滚动条装进冰箱
- 1. 最简实现(面向过程版)
- 2. React 自定义 Hook(函数组件版)
- 3. Vue3 自定义指令(组合式 API)
- 移动端“坑王”:iOS 橡皮筋 & 安卓键盘
- 1. iOS 弹性滚动(bounce)
- 2. 安卓键盘弹起导致 `window.innerHeight` 变化
- 可访问性:别让键盘用户“迷路”
- 混合终极方案:CSS 占位 + JS 焦点 + 内部滚动补丁
- 调试锦囊:快速定位“翻车”现场
- 开发小妙招:让遮罩层更“聪明”
- 结语:别让 bug 躲在滚动条后面偷笑
弹窗遮罩不滚动背景?前端开发者必学的实战技巧(附完整方案)
“哥,弹窗一开,后面的页面还能上下滑,产品经理刚才又拍桌子了。”
“别急,今天咱们就把滚动条这只调皮鬼按在地上摩擦。”
当弹窗遇上滚动,页面“抖”得你心慌
先讲个真事。
去年双十一,公司活动页上线前 2 小时,测试同学在群里甩了个录屏:iPhone 上点击“领取优惠券”弹窗,背景列表疯狂弹性滚动,像蹦迪。更尴尬的是,弹窗里也有滚动区,结果手指一滑,两层一起动,用户直接懵圈。老板当场发话——“这体验要是上线,年终奖你们自己掏”。
那一刻我深刻体会到:
“锁不住滚动条,就锁不住产品经理的怒火。”
其实问题本质一句话:
浏览器默认不会帮你把 body 滚动冻住,它只认“谁能滚”而非“谁不能滚”。
所以,我们的工作就是“欺骗”浏览器——让 viewport 以为“这里没东西可滚”,同时又不让用户发现我们在作弊。
弹出遮罩与背景滚动冲突的底层原理
先给滚动冲突做个“解剖”。
页面结构
html → 视口(viewport) └── body → 文档流,高度可能超出视口当 body 高度 > 100vh,浏览器就会在 viewport 右侧生成系统滚动条。
此时你拉起一个position:fixed的遮罩,浏览器说:“遮罩你随意,body 我照滚。”滚动事件冒泡
移动端 touch 事件不会自动阻止冒泡到 body;如果你的弹窗内部也滚动,到底后继续滑,就会“溢出”到 body,出现「链式滚动」——俗称“橡皮筋”。滚动条宽度消失导致的“跳动”
很多方案直接给 body 加overflow:hidden,滚动条瞬间被拔掉,页面宽度瞬间增加 15px(Chrome 默认),整个布局会“抖”一下;在暗黑模式 + 宽屏下尤其明显。iOS 的“区别对待”
iOS Safari 对body和documentElement的滚动耦合做了特殊优化:body的overflow:hidden可能无效,必须加给documentElement;- 键盘弹起时,
visualViewport会变化,如果此时你把top写死,页面会被键盘顶飞。
一句话总结:
“冲突”不是浏览器 bug,而是我们把“谁该滚”这件事想得太简单。
主流方案 3 连击:CSS、JS、CSS+JS
先把所有能想到的办法摆出来,再逐个拆坑。
| 方案 | 关键词 | 兼容性 | 跳动 | 内部滚动 | 推荐指数 |
|---|---|---|---|---|---|
A. 纯 CSSoverflow:hidden | 一行代码 | iOS 需双节点 | 有 | 需要额外处理 | ⭐⭐ |
B. JS 记录scrollTop+position:fixed | 手动还原 | 全 | 无 | 灵活 | ⭐⭐⭐⭐ |
C.scrollbar-gutter:stable | 未来战士 | 仅 Chromium | 无 | 不影响 | ⭐⭐ |
| D. 混合打法:CSS 占位 + JS 焦点管理 | 生产首选 | 全 | 无 | 完美 | ⭐⭐⭐⭐⭐ |
下面把每种打法揉碎,顺便送上“能直接粘贴到项目”的源码包。
纯 CSS 的“一分钟”方案:看上去很美
核心代码
body.modal-open{overflow:hidden;}JS 侧
functiontoggleModal(show){document.body.classList.toggle('modal-open',show);}优点
- 真·一把梭,写完早下班。
缺点
- 滚动条消失导致布局抖动。
- iOS 上经常失灵——必须连
html一起锁:html.modal-open, body.modal-open{overflow:hidden;height:100%;} - 如果弹窗内部还有滚动区,手指滑到顶/底后继续拖,body 依然会被带跑(链式滚动)。
- 键盘弹出时,iOS 会把
fixed元素顶到宇宙去,详见后文。
“抖动”修复补丁
body.modal-open{overflow:hidden;/* 1. 提前占好滚动条宽度,防止回流 */padding-right:var(--scrollbar-width,0);}// 2. 动态计算滚动条宽度constscrollbarWidth=window.innerWidth-document.documentElement.clientWidth;document.body.style.setProperty('--scrollbar-width',`${scrollbarWidth}px`);链式滚动补丁
/* 弹窗内部滚动区 */.dialog-scroll{overscroll-behavior:contain;}overscroll-behavior是 Chrome 63+ / Firefox 59+ 的支持属性,可以阻断“边缘溢出滚动”。
但 iOS Safari 要 16 才支持,老机型只能干瞪眼。
小结
纯 CSS 方案适合“内部无滚动 + 无兼容要求”的后台系统;面向 C 端或者移动端,请直接看下一节。
JS 动态冻结:把滚动条装进冰箱
思路一句话:
“把 body 变成 fixed,滚多少我帮你记着,关闭时再原样塞回去。”
1. 最简实现(面向过程版)
letscrollTop=0;functionlock(){scrollTop=window.pageYOffset||document.documentElement.scrollTop;document.body.style.position='fixed';document.body.style.top=`-${scrollTop}px`;document.body.style.left=0;document.body.style.right=0;}functionunlock(){document.body.style.position='';document.body.style.top='';window.scrollTo(0,scrollTop);}优点
- 绝对无抖动,因为 body 宽度没变,滚动条还在,只是被我“固定”了。
- 内部滚动区随便滑,body 纹丝不动。
缺点
position:fixed后,整个 body 会跑到视口顶部,用户如果中途旋转屏幕、键盘弹起,都会看到“内容闪一下”。- 关闭弹窗时
scrollTo会触发一次scroll事件,第三方埋点/广告 SDK 可能误报 PV。
2. React 自定义 Hook(函数组件版)
import { useLayoutEffect, useRef } from 'react'; export default function useLockBodyScroll(locked) { const scrollTop = useRef(0); useLayoutEffect(() => { if (!locked) return; const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; scrollTop.current = window.pageYOffset; document.body.style.position = 'fixed'; document.body.style.top = `-${scrollTop.current}px`; document.body.style.left = 0; document.body.style.right = 0; document.body.style.paddingRight = `${scrollbarWidth}px`; // 防止抖动 document.body.style.overflow = 'hidden'; // 保险起见 return () => { document.body.style.position = ''; document.body.style.top = ''; document.body.style.paddingRight = ''; document.body.style.overflow = ''; window.scrollTo(0, scrollTop.current); }; }, [locked]); }使用姿势
function Modal({ open, children }) { useLockBodyScroll(open); return open ? <div className="modal">{children}</div> : null; }3. Vue3 自定义指令(组合式 API)
// v-lock-scrollconstlockScroll={mounted(el,binding){const{value,modifiers}=binding;if(!value)return;constscrollbarWidth=window.innerWidth-document.documentElement.clientWidth;constscrollTop=window.pageYOffset;el.$scrollTop=scrollTop;// 暂存el.$originalStyles={position:document.body.style.position,top:document.body.style.top,paddingRight:document.body.style.paddingRight,overflow:document.body.style.overflow,};document.body.style.position='fixed';document.body.style.top=`-${scrollTop}px`;document.body.style.paddingRight=`${scrollbarWidth}px`;document.body.style.overflow='hidden';},updated(el,binding){// 开关切换if(!binding.value&&binding.oldValue){document.body.style.position=el.$originalStyles.position;document.body.style.top=el.$originalStyles.top;document.body.style.paddingRight=el.$originalStyles.paddingRight;document.body.style.overflow=el.$originalStyles.overflow;window.scrollTo(0,el.$scrollTop);}},};exportdefault{install(app){app.directive('lock-scroll',lockScroll);},};模板里一句话
<div v-lock-scroll="modalVisible" class="modal">...</div>移动端“坑王”:iOS 橡皮筋 & 安卓键盘
1. iOS 弹性滚动(bounce)
iOS 的body即使position:fixed仍能拖动,因为 Safari 把滚动“挂载”到visualViewport。
终极补丁:
html, body{-webkit-overflow-scrolling:touch;/* 先开启惯性 */height:100%;/* 关键:让 viewport 以为没溢出 */}.modal-open{position:fixed;width:100%;}另外,给遮罩本身再加一层“兜底”:
mask.addEventListener('touchmove',e=>e.preventDefault(),{passive:false});2. 安卓键盘弹起导致window.innerHeight变化
现象:
- 键盘升起 → 视口高度变小 →
fixed的弹窗被“顶上去”。 - 键盘收起 → 高度恢复 → 弹窗回不来,直接挡住输入框。
检测方案
letoriginalHeight=window.innerHeight;window.addEventListener('resize',()=>{constdiff=originalHeight-window.innerHeight;if(Math.abs(diff)>100){// 键盘弹出document.body.style.height=`${window.innerHeight}px`;}else{// 键盘收起document.body.style.height='';}});更现代做法
if('visualViewport'inwindow){window.visualViewport.addEventListener('resize',e=>{document.body.style.height=`${e.target.height}px`;});}可访问性:别让键盘用户“迷路”
锁滚动只是第一步,焦点管理才是专业前端的分水岭。
弹窗打开时,把焦点移到第一个可聚焦元素:
constfirstFocusable=modal.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');firstFocusable?.focus();限制 Tab 只在弹窗内循环:
functiontrapFocus(e){constfocusable=modal.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');constfirst=focusable[0];constlast=focusable[focusable.length-1];if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus();}elseif(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus();}}modal.addEventListener('keydown',trapFocus);弹窗关闭后,把焦点还给触发器:
triggerBtn.focus();屏幕阅读器
给遮罩加aria-hidden="true"、role="dialog"、aria-modal="true",并在打开时把其余主内容aria-hidden也置为true,防止“读屏穿透”。
混合终极方案:CSS 占位 + JS 焦点 + 内部滚动补丁
把上面所有补丁缝合成一套“生产环境可直接复制”的通用组件。
// React 为例,Vue 思路完全一致 export default function SmartModal({ show, onClose, children }) { const ref = useRef(null); const trigger = useRef(document.activeElement); useLockBodyScroll(show); // 上一节的 Hook useTrapFocus(ref, show); // 焦点循环,代码略 useHideOthers(ref, show); // aria-hidden 主内容 useEffect(() => { const handler = e => e.key === 'Escape' && onClose(); if (show) window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, [show, onClose]); useEffect(() => { if (!show && trigger.current) { (trigger.current as HTMLElement).focus(); } }, [show]); if (!show) return null; return ( <div className="modal-mask" onClick={onClose}> <div className="modal-main" ref={ref} role="dialog" aria-modal="true" onClick={e => e.stopPropagation()} > {children} </div> </div> ); }配套 CSS
.modal-mask{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;-webkit-tap-highlight-color:transparent;}.modal-main{max-height:80vh;overflow-y:auto;overscroll-behavior:contain;/* 关键:防止链式滚动 */-webkit-overflow-scrolling:touch;}优点
- 不挑框架,React / Vue / 原生都能套。
- 滚动条宽度动态占位,无抖动。
- 内部滚动区自带橡皮筋修复。
- 焦点、ESC 关闭、aria 全套可访问性标配。
调试锦囊:快速定位“翻车”现场
| 现象 | 排查思路 | 一行代码 |
|---|---|---|
| 弹窗关闭后页面回不到原位 | 检查unlock时scrollTop是否被重置、是否被键盘resize干扰 | console.log(scrollTop) |
| 锁滚动后内部区域滚不动 | 内部节点被加了overflow:hidden | 检查 CSS 选择器优先级 |
| iOS 上还能拖动背景 | -webkit-overflow-scrolling没加或html没固定 | 检查html.modal-open |
| 键盘弹起后按钮被挡住 | 视口高度计算错误 | 用visualViewport重新校准 |
| 焦点跑出弹窗 | trapFocus没绑定或选择器写错 | document.activeElement打印 |
开发小妙招:让遮罩层更“聪明”
自动识别是否需要锁定
如果弹窗内容自身高度 < 视口高度,其实用户滚不动背景,可以跳过锁定逻辑,减少一次重排。constneedLock=modal.scrollHeight<=window.innerHeight;支持嵌套弹窗
维护一个lockStack计数器,只有最外层关闭时才解锁:letlockCount=0;functionlock(){lockCount++;if(lockCount===1)reallyLock();}functionunlock(){lockCount--;if(lockCount===0)reallyUnlock();}适配动态内容高度
内容可能是异步渲染的骨架屏,高度会变化。监听ResizeObserver动态调整max-height,防止“内容撑爆”:constro=newResizeObserver(()=>{modal.style.maxHeight=`${window.innerHeight*0.8}px`;});ro.observe(modal);
结语:别让 bug 躲在滚动条后面偷笑
锁滚动这件事,说小也小,说大也能让一整天的排期泡汤。
今天我们把“为什么抖、怎么锁、如何还原、移动端特殊癖好、可访问性细节”全部拆了一遍,并给出三套可直接粘贴的源码:
- 纯 CSS 快速版(后台系统随便用)
- JS 冻结版(C 端标配)
- 混合终极版(生产稳如老狗)
记住两句话:
- 用户体验藏在细节里,滚动条也是细节。
- 产品经理不一定懂技术,但一定能看出“抖没抖”。
下回再遇到弹窗背景滑来滑去,就把这篇文章甩给他:
“别慌,方案全在这儿,复制粘贴,年终奖稳了。”
欢迎来到我的博客,很高兴能够在这里和您见面!希望您在这里可以感受到一份轻松愉快的氛围,不仅可以获得有趣的内容和知识,也可以畅所欲言、分享您的想法和见解。
推荐:DTcode7的博客首页。
一个做过前端开发的产品经理,经历过睿智产品的折磨导致脱发之后,励志要翻身农奴把歌唱,一边打入敌人内部一边持续提升自己,为我们广大开发同胞谋福祉,坚决抵制睿智产品折磨我们码农兄弟!
| 专栏系列(点击解锁) | 学习路线(点击解锁) | 知识定位 |
|---|---|---|
| 《微信小程序相关博客》 | 持续更新中~ | 结合微信官方原生框架、uniapp等小程序框架,记录请求、封装、tabbar、UI组件的学习记录和使用技巧等 |
| 《AIGC相关博客》 | 持续更新中~ | AIGC、AI生产力工具的介绍,例如stable diffusion这种的AI绘画工具安装、使用、技巧等总结 |
| 《HTML网站开发相关》 | 《前端基础入门三大核心之html相关博客》 | 前端基础入门三大核心之html板块的内容,入坑前端或者辅助学习的必看知识 |
| 《前端基础入门三大核心之JS相关博客》 | 前端JS是JavaScript语言在网页开发中的应用,负责实现交互效果和动态内容。它与HTML和CSS并称前端三剑客,共同构建用户界面。 通过操作DOM元素、响应事件、发起网络请求等,JS使页面能够响应用户行为,实现数据动态展示和页面流畅跳转,是现代Web开发的核心 | |
| 《前端基础入门三大核心之CSS相关博客》 | 介绍前端开发中遇到的CSS疑问和各种奇妙的CSS语法,同时收集精美的CSS效果代码,用来丰富你的web网页 | |
| 《canvas绘图相关博客》 | Canvas是HTML5中用于绘制图形的元素,通过JavaScript及其提供的绘图API,开发者可以在网页上绘制出各种复杂的图形、动画和图像效果。Canvas提供了高度的灵活性和控制力,使得前端绘图技术更加丰富和多样化 | |
| 《Vue实战相关博客》 | 持续更新中~ | 详细总结了常用UI库elementUI的使用技巧以及Vue的学习之旅 |
| 《python相关博客》 | 持续更新中~ | Python,简洁易学的编程语言,强大到足以应对各种应用场景,是编程新手的理想选择,也是专业人士的得力工具 |
| 《sql数据库相关博客》 | 持续更新中~ | SQL数据库:高效管理数据的利器,学会SQL,轻松驾驭结构化数据,解锁数据分析与挖掘的无限可能 |
| 《算法系列相关博客》 | 持续更新中~ | 算法与数据结构学习总结,通过JS来编写处理复杂有趣的算法问题,提升你的技术思维 |
| 《IT信息技术相关博客》 | 持续更新中~ | 作为信息化人员所需要掌握的底层技术,涉及软件开发、网络建设、系统维护等领域的知识 |
| 《信息化人员基础技能知识相关博客》 | 无论你是开发、产品、实施、经理,只要是从事信息化相关行业的人员,都应该掌握这些信息化的基础知识,可以不精通但是一定要了解,避免日常工作中贻笑大方 | |
| 《信息化技能面试宝典相关博客》 | 涉及信息化相关工作基础知识和面试技巧,提升自我能力与面试通过率,扩展知识面 | |
| 《前端开发习惯与小技巧相关博客》 | 持续更新中~ | 罗列常用的开发工具使用技巧,如 Vscode快捷键操作、Git、CMD、游览器控制台等 |
| 《photoshop相关博客》 | 持续更新中~ | 基础的PS学习记录,含括PPI与DPI、物理像素dp、逻辑像素dip、矢量图和位图以及帧动画等的学习总结 |
| 日常开发&办公&生产【实用工具】分享相关博客》 | 持续更新中~ | 分享介绍各种开发中、工作中、个人生产以及学习上的工具,丰富阅历,给大家提供处理事情的更多角度,学习了解更多的便利工具,如Fiddler抓包、办公快捷键、虚拟机VMware等工具 |
吾辈才疏学浅,摹写之作,恐有瑕疵。望诸君海涵赐教。望轻喷,嘤嘤嘤
非常期待和您一起在这个小小的网络世界里共同探索、学习和成长。愿斯文对汝有所裨益,纵其简陋未及渊博,亦足以略尽绵薄之力。倘若尚存阙漏,敬请不吝斧正,俾便精进!