禁止背景滚动引起抖动问题

通常我们在做弹窗时,都需要禁止背景滚动。网上查到最常见的方案是将 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三方库

www.npmjs.com/package/bod...

搜了个禁止滚动的三方库,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。

首先要搞清楚两个概念:

  1. 滚动链
  2. 根滚动容器

滚动链

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 了。

  1. fixed 元素默认不参与滚动链 如果 fixed 元素内部有可滚动内容(如 overflow: auto),它的滚动行为仅限于自身,不会传播到父级或页面。
  2. 特殊情况:fixed 元素嵌套在可滚动容器内 如果 fixed 元素嵌套在 transformfilterwill-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 元素也不会跑到滚动条上,就是滚动条的滑块消失了,看起来有一点奇怪。

总结

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

相关推荐
半点寒12W1 小时前
微信小程序实现路由拦截的方法
前端
某公司摸鱼前端2 小时前
uniapp socket 封装 (可拿去直接用)
前端·javascript·websocket·uni-app
要加油哦~2 小时前
vue | 插件 | 移动文件的插件 —— move-file-cli 插件 的安装与使用
前端·javascript·vue.js
小林学习编程2 小时前
Springboot + vue + uni-app小程序web端全套家具商场
前端·vue.js·spring boot
柳鲲鹏2 小时前
WINDOWS最快布署WEB服务器:apache2
服务器·前端·windows
weixin-a153003083163 小时前
【playwright篇】教程(十七)[html元素知识]
java·前端·html
ai小鬼头4 小时前
AIStarter最新版怎么卸载AI项目?一键删除操作指南(附路径设置技巧)
前端·后端·github
一只叫煤球的猫4 小时前
普通程序员,从开发到管理岗,为什么我越升职越痛苦?
前端·后端·全栈
vvilkim5 小时前
Electron 自动更新机制详解:实现无缝应用升级
前端·javascript·electron
vvilkim5 小时前
Electron 应用中的内容安全策略 (CSP) 全面指南
前端·javascript·electron