Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

rc-drawer是如何避免滚动条对组件的影响的 #64

Open
jyzwf opened this issue Feb 16, 2019 · 1 comment
Open

rc-drawer是如何避免滚动条对组件的影响的 #64

jyzwf opened this issue Feb 16, 2019 · 1 comment

Comments

@jyzwf
Copy link
Owner

jyzwf commented Feb 16, 2019

抽屉组件在平时的业务中很常见,所以在不久前参考了antdDrawer组件实现了下,不过本文并不会讲述内部的具体实现,而是了解下其是如何解决滚动条对该组件的影响的。
我们知道,Drawer组件的两部分——遮罩抽屉一般都是基于 fixed定位的,当出现滚动条,且我们没有对其做兼容性处理的时候,会出现如下bug:
image

抽屉并不能覆盖掉滚动条,这时我们可以在抽屉弹出的时候让body不能滚动,这样就不会出现滚动条了,如下:

drawer_bug

相应的只是在 body 上增加 overflow:hidden,下面是打开抽屉时body的样式:
image
但相应的,这种方式也会出现一个问题,如上面的gif图显示的一样,当抽屉打开时,遮罩下面的内容块会向右移动,该移动的距离就是滚动条的宽度,这是因为此时 body 溢出时设置了hidden,所以不会出现滚动条了,自然下面的内容块向右移动了;当抽屉关闭时,由于body上的 overflow:hidden 被取消,滚动条重新出现了,导致了内容块又向左移动了一个滚动条宽度的距离,所以这就是我们今天要讨论的滚动条对抽屉的影响,以及如何消除该影响。

首先我们来了解下如何判断是否出现了滚动条以及如何获取滚动条的宽度。这里只讨论当抽屉的容器为body 的情况。

是否出现了滚动条

一般情况下,我们只需要判断页面实际内容高度是否超过窗口的可视区高度就能判断出是否存在滚动条:

document.body.scrollHeight >  (window.innerHeight || document.documentElement.clientHeight)

这个在body不存在 overflow:hidden 的情况下是有效的,但在body上添加overflow:hidden之后,如果实际内容超出了可视区,上面的条件判断仍然为true,但此时并没有滚动条,所以上面判断并不能符合,那要如何处理呢?下面是rc-drawer做的判断:

// [1]
document.body.scrollHeight > (window.innerHeight || document.documentElement.clientHeight) &&
window.innerWidth > document.body.offsetWidth

由于window.innerWidth是包含滚动条的宽度,而 document.body.offsetWidth 并不包含滚动条的宽度,所以可以做此处理,从而真正判断出是否出现了滚动条,那么是否可以直接通过 window.innerWidth > document.body.offsetWidth 来判断是否出现了滚动条呢??
答案也是不行的,由于 document.body.offsetWidth 并不包含margin的大小,所以当出现bodymargin的时候,它是始终小于window.innerWidth的,当此时没有滚动条,示例如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
    <style>
        body{
            overflow: hidden;
        }
    </style>
</head>
<body>
    <div>353453</div>
</body>
</html>

但是又会出现一个问题,将上面的div增加到1000个,同时将body设为overflow:hidden,此时用上面[1]的处理方法,条件返回的是true,但仍然没有滚动条:
image
所以上面的判断还是有问题的,但是antd中是将bodymargin/padding 全部置为0的,所以上述的判断在使用antd的过程中并没有毛病。我们来对上面的条件做一定的修改,来做到更好的判断。
上面问题出现在overflow上,所以我们来判断下是否存在overflowhidden

function hasScrollBar2Body() {
            var overflow = document.body.currentStyle ? document.body.currentStyle.overflow :
                            window.getComputedStyle(document.body).getPropertyValue("overflow");

            if(overflow === "hidden") return false;
            
            return (document.body.scrollHeight > (window.innerHeight || document.documentElement.clientHeight) &&
                    window.innerWidth > document.body.offsetWidth)
}

关于如何判断是否存在ScrollBar,可以参考Detect if a page has a vertical scrollbar?

注:下面均假设,[1]中的条件能够判断是否出现滚动条

如何获取滚动条的宽度

知道是是否出现滚动条之后,我们就要来获取其宽度,其实现方式均大同小异,这里直接给出antd中是如何判断的,

let cached;

export default function getScrollBarSize(fresh) {
  if (fresh || cached === undefined) {
    const inner = document.createElement('div');
    inner.style.width = '100%';
    inner.style.height = '200px';

    const outer = document.createElement('div');
    const outerStyle = outer.style;

    outerStyle.position = 'absolute';
    outerStyle.top = 0;
    outerStyle.left = 0;
    outerStyle.pointerEvents = 'none';
    outerStyle.visibility = 'hidden';
    outerStyle.width = '200px';
    outerStyle.height = '150px';
    outerStyle.overflow = 'hidden';

    outer.appendChild(inner);

    document.body.appendChild(outer);

    const widthContained = inner.offsetWidth;
    outer.style.overflow = 'scroll';
    let widthScroll = inner.offsetWidth;

    console.log(widthContained,widthScroll)
    console.log(outer.clientWidth)

    if (widthContained === widthScroll) {
      widthScroll = outer.clientWidth;
    }

    document.body.removeChild(outer);

    cached = widthContained - widthScroll;
  }
  return cached;
}

下面是两个div的截图:
外层div:
image
内层div:
image

从代码中我们可以了解到:判断方法主要是通过设置两个div,内层div高度大于外层的,同时设置外层divoverflow分别为hiddenscroll时,导致内层的div宽度的变化。因为当设置了scroll之后,内层divoffsetWidth不包含滚动条的宽度的,相反,当设置了hidden之后,由于内层div的宽度为100%,所以是与外层div等宽,这样,就可以获取到了滚动条的大小了。但同时我们注意到代码中有如下判断:

if (widthContained === widthScroll) {
      widthScroll = outer.clientWidth;
    }

那么什么时候会出现hidden与scroll时,内层divoffsetWidth相等呢?换句话说,类似于滚动条不存在?这里我只想到一种情况(欢迎补充):在mac上,点击系统偏好设置 -> 通用 -> 显示滚动条 -> 点选 根据鼠标或者触控板自动显示之后就会出现相等的情况:
image

前面讲解了关于如何判断是否出现滚动条以及如何获取其宽度,现在回到组件本身,来了解如何消除滚动条对组件的影响。
打开组件源码,先看getChildToRender这个函数:

  1. 获取抽屉打开的方向,从而设置抽屉translate属性
  2. 获取抽屉的大小
  3. 执行setLevelDomTransform

setLevelDomTransform

关于滚动条的设置主要是发生在该函数中

  1. 由于rc-drawer支持了抽屉打开时,可以附带一些元素向相同的方向移动一些距离,详见rc-drawer关于level的设定,其原理就是在抽屉打开时,所有level内的元素,均设置style.transformtranslate值,有兴趣的可以自行了解下它是如何收集这些level元素的。
  2. 当抽屉的容器是body的时候,处理body的滚动,这里不考虑移动端的情况,下面代码阉割了移动端的处理:
if (getContainer === 'body') {
        // ......
        const right =  // 判断是否存在滚动条,如果是旧获取其宽度
          document.body.scrollHeight >
            (window.innerHeight || document.documentElement.clientHeight) &&
          window.innerWidth > document.body.offsetWidth
            ? getScrollBarSize(1)  // 详见上面
            : 0;
        let widthTransition = `width ${duration} ${ease}`;
        const trannsformTransition = `transform ${duration} ${ease}`;
        if (open && document.body.style.overflow !== 'hidden') {  
        // 当抽屉打开时,但body没有overflow 为 'hidden'时
          document.body.style.overflow = 'hidden'; // 这样就不会出现滚动条了
          if (right) { // 如果有滚动条
            document.body.style.position = 'relative';
            document.body.style.width = `calc(100% - ${right}px)`;  // 重点:直接将body的宽度设置为减去滚动条的宽度,这样在overflow:hidden的时候,就不会出现我们上面提到的,抽屉底部内容块向右移动的情况了
            this.dom.style.transition = 'none';
            switch (placement) {
              case 'right':
                 // 这里为何要先将整个抽屉往左移动一个滚动条的宽度呢?下面是我的理解,欢迎交流。请看下图
                this.dom.style.transform = `translateX(-${right}px)`;
                this.dom.style.msTransform = `translateX(-${right}px)`;
                break;
              case 'top':
              case 'bottom':
               // 这里这么设置是为了更平滑的效果??????????
                this.dom.style.width = `calc(100% - ${right}px)`;
                 // 这里设置 transform 的原因我想的是:position等于fixed的时候,当元素祖先的 transform  属性非 none 时,容器由视口改为该祖先。
                // 所以`so-drawer`元素上添加上该style后,其子元素`so-drawer__content-wrapper`是fixed定位的,且其宽度是100%,所以它是相对于`so-drawer`这个祖先元素,而不是视口大小了
               // 同时它还能开始gpu加速,提升性能
                this.dom.style.transform = 'translateZ(0)'; 
                break;
              default:
                break;
            }
            clearTimeout(this.timeout);
            this.timeout = setTimeout(() => {    // 清除影响
              this.dom.style.transition = `${trannsformTransition},${widthTransition}`;
              this.dom.style.width = '';
              this.dom.style.transform = '';
              this.dom.style.msTransform = '';
            });
          }
          // 处理手机端禁止滚动
          // ......
          
        } else if (this.getCurrentDrawerSome()) {  // 当没有Drawer实例的时候,多个抽屉情况
          document.body.style.overflow = '';  // 清除body上的overflow
          if ((this.isOpenChange || openTransition) && right) {
            document.body.style.position = '';
            document.body.style.width = '';
            if (transitionStr) {   // 这里我觉得不应该加,不然关闭抽屉之后原来应该滚动的页面变得不能滚动了?????????
              document.body.style.overflowX = 'hidden';
            }
            this.dom.style.transition = 'none';
            let heightTransition;
            switch (placement) {
              case 'right': {
                // 这里由于此时页面出现了滚动条,如果不加上这个,就会出现抽屉闪动的情况
                this.dom.style.transform = `translateX(${right}px)`;
                this.dom.style.msTransform = `translateX(${right}px)`;
                this.dom.style.width = '100%';
                widthTransition = `width 0s ${ease} ${duration}`;
                if (this.maskDom) {
                  // 由于整个组件向右移动了一个滚动条的距离,导致在消失的时候遮罩并不能覆盖整个页面,左边会有一个空白,见下图,所以需要将遮罩向左移动一个滚动条的距离,同时由于向左移动了,导致右边会不能覆盖,所以宽度要加上一个滚动条的大小
                  this.maskDom.style.left = `-${right}px`;
                  this.maskDom.style.width = `calc(100% + ${right}px)`;
                }
                break;
              }
              case 'top':
              case 'bottom': {
                // 这里同理防止滚动条的突然出现,导致抽屉突然向左移动,显得生硬,加上之后,显得更加平滑,欢迎指正
                this.dom.style.width = `calc(100% + ${right}px)`;
                this.dom.style.height = '100%';
                this.dom.style.transform = 'translateZ(0)';
                heightTransition = `height 0s ${ease} ${duration}`;
                break;
              }
              default:
                break;
            }
            clearTimeout(this.timeout);
            this.timeout = setTimeout(() => { // 同理,消除额外影响
              this.dom.style.transition = `${trannsformTransition},${
                heightTransition ? `${heightTransition},` : ''
              }${widthTransition}`;
              this.dom.style.transform = '';
              this.dom.style.msTransform = '';
              this.dom.style.width = '';
              this.dom.style.height = '';
            });
          }
          // 处理手机端禁止滚动
          // ......
        }
      }
    }

当抽屉从右边弹出,设置this.dom.style.transform = translateX(-${right}px);原因,cong从下图我们可以看见,当抽屉从右边弹出时,由于此时body的宽度减去了一个滚动条的宽度,但此时抽屉组件是固定定位,并且width为100%,所以此时抽屉的宽度是视口的宽度,比body多了一个滚动条的宽度,所以打开抽屉时会下面这样的空白区,所以此时将抽屉整体向左移动一个滚动条的宽度就能掩盖住之前的空白区,当然最后还是要将抽屉整体向右回移回去,不然右边就会出现下面第二个图的情况,同时显得更加平滑:
image

未回移的情况:
image

关闭抽屉时,如果不处理遮罩位置时,出现的bug:
image

总结

总的来说,rc-drawer是通过改变body的宽度,然后修改响应的抽屉的打开或者关闭过程中的位置关系(保持body与组件同在一个“位置”上,即假设没有滚动条的情况下),最后在下一个事件循环时,消除之前过程中所带来的副作用来达到消除滚动条的影响。同时也了解了如何判断是否存在滚动条以及滚动条的宽度如何获取。有兴趣的可以clone下该组件,并细读其源码,可以学习到不少东西,如该组件没有设置 open的默认值,因为存在永久抽屉;再者当组件第一次进入时没有动画效果是如何处理的,这里是直接重新渲染组件,等等...
以上全为个人见解,如有不当,欢迎指出以及交流^_^

@Ray-56
Copy link

Ray-56 commented Aug 18, 2021

从代码中我们可以了解到:判断方法主要是通过设置两个div,内层div的高度大于外层的,同时设置外层div在overflow分别为hidden和scroll时,导致内层的div宽度的变化。因为当设置了scroll之后,内层div的offsetWidth是不包含滚动条的宽度的,相反,当设置了hidden之后,由于内层div的宽度为100%,所以是与外层div等宽,这样,就可以获取到了滚动条的大小了。但同时我们注意到代码中有如下判断:

if (widthContained === widthScroll) {
      widthScroll = outer.clientWidth;
    }

那么什么时候会出现hidden与scroll时,内层div的offsetWidth相等呢?换句话说,类似于滚动条不存在?这里我只想到一种情况(欢迎补充):在mac上,点击系统偏好设置 -> 通用 -> 显示滚动条 -> 点选 根据鼠标或者触控板自动显示之后就会出现相等的情况


请教一下,我自己也 mac 按照该方法进行设置测试,未复现呀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants