通常我们在做弹窗时,都需要禁止背景滚动。网上查到最常见的方案是将 body 设置为overflow: hidden
。简单方便没毛病。
产品:打开弹窗时,界面为什么闪一下?不行,重做
我:😭

那只能找其他方法了。
缺陷方案
1. 将 body 设置为 fixed
将 body 设为 fixed 后,滚动条消失,调整下宽度后,不会闪了,这次应该没问题了。
javascript
let bodyScrollTop;
// 禁止滚动
bodyScrollTop = document.body.scrollTop || document.documentElement.scrollTop;
// 很多教程都不算滚动条宽度的,还是会动一下
const scrollWidth = window.innerWidth - document.documentElement.clientWidth;
document.body.style.position = 'fixed';
document.body.style.left = 0;
document.body.style.top = -bodyScrollTop + 'px';
document.body.style.width = `calc(100vw - ${scrollWidth}px)`;
// 恢复滚动
document.body.removeAttribute('style');
document.body.scrollTop = document.documentElement.scrollTop = bodyScrollTop;

马上又发现了新的问题,fixed 元素还是移动了。
虽然 body 宽度调整了,但是 fixed 元素的位置是根据视窗确定的。右下角的 fixed(right: 0) 的返回顶部的元素跟随视窗变化,还是会移动。总不能把所有组件都加上滚动条宽度,工作量太大了,这方案不行。
2. 禁止滚动事件
既然不能修改滚动条,我们就试着从 js 入手,直接禁用滚动事件。
先在网上找下有没有现成的代码。
在 stack overflow 上看到一个比较简单的方法,先试一下。
javascript
let winX = null;
let winY = null;
const handleScroll = (evt) => {
if (winX !== null && winY !== null) {
evt.preventDefault();
window.scrollTo(winX, winY);
}
}
// 禁用滚动
window.addEventListener('scroll', handleScroll);
winX = window.scrollX;
winY = window.scrollY;
// 恢复滚动
window.removeEventListener('scroll', handleScroll);
winX = null;
winY = null;
这个方法对于弹窗内能滚动的元素比较适用,但是对于不能滚动的元素,第一下滚动鼠标滚轮时,比较容易出现抖动。
因为 scroll 事件是 UI 事件,cancelable 为 false,不能阻止默认行为(由 AI 提供,未找到具体规范文档)。MDN 中也提到 scroll 事件是滚动结束后才触发的(Element: scroll event - Web APIs | MDN),无法阻止滚动。所以上面的代码其实是滚动后,再回到原处,会有一定的抖动。
The
scroll
event fires when an element has been scrolled.

既然 scroll 事件不能用,那么我们再试下 wheel 事件。
先判断当前元素或祖先元素是否能滚动,在未滚动到边界前,滚动不会穿透到 body,不禁止滚动。(以下代码由 AI 生成)
javascript
function handleWheel(e) {
let el = e.target;
let scrollableElement = findScrollableParent(el);
if (scrollableElement) {
// 可滚动元素
const delta = e.deltaY;
const isAtTop = scrollableElement.scrollTop <= 0;
const isAtBottom = scrollableElement.scrollTop + scrollableElement.clientHeight >= scrollableElement.scrollHeight;
// 在边界时阻止滚动
if ((delta < 0 && isAtTop) || (delta > 0 && isAtBottom)) {
e.preventDefault();
}
} else {
// 不可滚动元素
e.preventDefault();
}
}
// 辅助函数:查找最近的可以滚动的父元素
function findScrollableParent(el) {
while (el && el !== document.body) {
const hasScrollbar = el.scrollHeight > el.clientHeight;
const overflowY = window.getComputedStyle(el).overflowY;
if (hasScrollbar && (overflowY === 'auto' || overflowY === 'scroll')) {
return el;
}
el = el.parentElement;
}
return null;
}
// 禁用滚动
document.addEventListener('wheel', handleWheel, { passive: false, capture: true });
// 恢复滚动
document.removeEventListener('wheel', handleWheel, true);
还是有点小问题,滚动条没有被盖住,还是可以用鼠标拖动或在其上滚动。

到这里我还想挣扎一下,如果判断 wheel 事件触发的位置在滚动条上就禁用行不行?
javascript
function isScrollbarInteraction(e) {
// 检测是否点击在滚动条上
return e.offsetX > e.target.clientWidth ||
e.offsetY > e.target.clientHeight;
}
if (isScrollbarInteraction(e)) {
e.preventDefault();
return;
}
在非 body 元素滚动条上,wheel 事件确实被禁用了,但是在 body 滚动条上,wheel 事件还是不能禁用。

3. body-scroll-lock三方库
搜了个禁止滚动的三方库,star 挺多的,但是使用的还是overflow: hidden
,还是会造成闪动。
一通搞下来,还是没找到完美方案。难道要自己实现滚动条?好像有点过于麻烦了。
综合考量下(与产品、测试一番争论),使用了上面提到的禁用 scroll 事件的方法。
组件库方案
1. 转换滚动容器
本着能跑就行的心态,后面没再关注这件事了。
直到最近,在看 element plus 文档时,看到这样一段话。
当对话框被显示及隐藏时,页面元素会来回移动(抖动)
典型议题:#10481
PS:建议将滚动区域放置在一个挂载的 vue 节点,如
<div id="app" />
下,并对 body 使用overflow: hidden
样式。
具体来说就是用一个容器把主要的元素装载,避免直接挂在在 body 上。然后将 body 高度设置为 100%,并且overflow: hidden
,body 自然就不能滚动了。
css
html,
body {
width: 100% !important;
height: 100%;
overflow: hidden;
margin: 0;
}
#app {
width: 100%;
height: 100%;
overflow-y: auto;
max-width: none !important;
}
副作用是 fixed 元素可能会覆盖在滚动条上,但是不会闪动,可以接受。

那么为什么滚动条在 body 上会滚动穿透,在 #app 上不会?
是因为弹窗挂载在 body 上,#app 不是父节点?
并非如此,把弹窗挂载在 #app 上,滚动也不会穿透到 #app。

首先要搞清楚两个概念:
- 滚动链
- 根滚动容器
滚动链
developer.mozilla.org/zh-CN/docs/...
滚动链 是指用户滚动超过滚动元素的滚动边界时,导致祖先元素上的滚动行为。
我们构造下面的 html 结构。
html
<body>
<div id="app">
<div class="scroll-container-grandparent">
<div class="scroll-container-parent">
<div class="scroll-container">
<HelloWorld msg="Vite + Vue" />
<HelloWorld msg="Vite + Vue" />
<HelloWorld msg="Vite + Vue" />
</div>
</div>
<HelloWorld msg="Vite + Vue" />
<HelloWorld msg="Vite + Vue" />
<HelloWorld msg="Vite + Vue" />
</div>
</div>
</body>
<style>
.scroll-container-grandparent {
overflow: auto;
max-height: 200px;
}
.scroll-container-parent {
overflow: hidden;
}
.scroll-container {
overflow: auto;
max-height: 100px;
}
</style>
可以看到当滚动到边界时,滚动会依次穿透到最近的能滚动的祖先元素上。

那么能不能中断滚动链的传递?可以,overscroll-behavior
就是用来做这个的。
css
.scroll-container {
overflow: auto;
max-height: 100px;
overscroll-behavior: none;
}
可以看到内层的滚动已经不能穿透到外层了。

再看一下对于不可滚动元素的效果。
css
.scroll-container {
/* overflow: auto; */
max-height: 100px;
overscroll-behavior: none;
border: 1px solid #000;
}
可以看到,对于不可滚动元素,overscroll-behavior
不能中断滚动链。

回到弹窗上来。由于弹窗内不可滚动的元素很多,通过overscroll-behavior
中断滚动链是不可能的。
html
<body>
<div id="app">
<div class="dialog">
<div class="dialog-mask"></div>
<div class="dialog-wrapper">
<div class="dialog-header"></div>
<div class="dialog-body"></div>
<div class="dialog-footer"></div>
</div>
</div>
</div>
</body>
<style>
.dialog {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
.dialog-body {
overflow: auto;
overscroll-behavior: none;
}
</style>
再把滚动条放回 #app 内。
css
html,
body {
width: 100% !important;
height: 100%;
overflow: hidden;
margin: 0;
}
#app {
width: 100%;
height: 100%;
overflow-y: auto;
max-width: none !important;
}
虽然 #app 是弹窗的祖先元素,但是滚动不会穿透到 #app 上。难道和 fixed 有关系?
这方面内容没找到比较官方的文档,只能问 AI 了。
fixed
元素默认不参与滚动链 如果fixed
元素内部有可滚动内容(如overflow: auto
),它的滚动行为仅限于自身,不会传播到父级或页面。- 特殊情况:
fixed
元素嵌套在可滚动容器内 如果fixed
元素嵌套在transform
、filter
或will-change
等属性影响的容器内 ,它的定位可能会受到影响,导致它不再相对于视口 ,而是相对于该容器(类似position: absolute
)。此时,它可能参与滚动链。
先验证下第二点
css
#app {
width: 100%;
height: 100%;
overflow-y: auto;
max-width: none !important;
transform: translate(0);
}
滚动确实穿透了,但 fixed 也失效了,一般不这么用。

再来看第一点。我们前面已经验证了滚动条在 #app 上时,滚动不会穿透到 #app,可以说明 AI 说的不会传播到父级的说法是对的。但是滚动条在 body 上时,滚动还是会穿透,这跟 AI 说的好像又不一样。
接着,我又查到了另一个概念------根滚动容器。
根滚动容器
在标准模式下(
<!DOCTYPE html>
),body
是浏览器默认的根滚动容器 ,即使没有显式设置overflow: auto
,它也会在内容溢出时显示滚动条。
和 AI 争论几番后,还是没能找到 fixed 和根滚动容器之间的特殊关系。
姑且认为脱离文档流的元素的滚动链会传递到根滚动容器(即 body)。
2. fixed + 强制滚动条
既然 element plus 有解决方案,那么其他组件库有没有不同的解决方案呢?
翻了几个 star 比较多的组件库,还真有!vuetify 和 quasar 有另一套方案。
原理就是在上面的 fixed 方案上,加上强制的滚动条。
稍微简化了一下代码,完整代码可参考github.com/quasarframe...
javascript
// .q - body--force-scrollbar - x {
// overflow - x: scroll;
// }
// .q - body--force - scrollbar - y {
// overflow - y: scroll;
// }
// .q - body--prevent - scroll {
// position: fixed!important;
// }
const getVerticalScrollPosition = (scrollTarget) => {
return scrollTarget === window
? window.pageYOffset || window.scrollY || document.body.scrollTop || 0
: scrollTarget.scrollTop;
}
const getHorizontalScrollPosition = (scrollTarget) => {
return scrollTarget === window
? window.pageXOffset || window.scrollX || document.body.scrollLeft || 0
: scrollTarget.scrollLeft;
}
let
scrollPositionX,
scrollPositionY,
bodyLeft,
bodyTop,
bodyWidth;
const preventScroll = (state) => {
const body = document.body;
if (state) {
const { overflowY, overflowX } = window.getComputedStyle(body);
scrollPositionX = getHorizontalScrollPosition(window);
scrollPositionY = getVerticalScrollPosition(window);
bodyLeft = body.style.left;
bodyTop = body.style.top;
bodyWidth = body.style.width;
body.style.left = `-${scrollPositionX}px`;
body.style.top = `-${scrollPositionY}px`;
const scrollWidth = window.innerWidth - document.documentElement.clientWidth;
body.style.width = `calc(100vw - ${scrollWidth}px)`;
if (overflowX !== 'hidden' && (overflowX === 'scroll' || body.scrollWidth > window.innerWidth)) {
body.classList.add('q-body--force-scrollbar-x');
}
if (overflowY !== 'hidden' && (overflowY === 'scroll' || body.scrollHeight > window.innerHeight)) {
body.classList.add('q-body--force-scrollbar-y');
}
body.classList.add('q-body--prevent-scroll');
document.qScrollPrevented = true;
} else {
body.classList.remove('q-body--prevent-scroll');
body.classList.remove('q-body--force-scrollbar-x');
body.classList.remove('q-body--force-scrollbar-y');
document.qScrollPrevented = false;
body.style.left = bodyLeft;
body.style.top = bodyTop;
body.style.width = bodyWidth;
window.scrollTo(scrollPositionX, scrollPositionY)
}
}
可以看到滚动不会穿透了,页面不会闪动了,fixed 元素也不会跑到滚动条上,就是滚动条的滑块消失了,看起来有一点奇怪。

总结
组件库的两种方案都是可用的,个人更推荐转换滚动容器的方案。