这一次,彻底解决滚动穿透

2019 年 1 月 4 日 IMWeb前端社区

本文由 IMWeb 团队成员 erasermeng 首发于腾讯内部KM论坛。点击阅读原文查看 IMWeb 社区更多精彩文章。

什么是滚动穿透

如图所示,有一层遮罩蒙层覆盖在body上时,当我们滚动遮罩层,它下面的内容也会跟着一起滚动,看起来好像是上面的滚动事件穿透到下面的DOM元素上一样,我们称之为滚动穿透。

阻止冒泡?

刚开始遇到这个问题的同学可能会联想到是不是由于事件冒泡到body上引起的,于是监听 scroll/touchmove事件,阻止事件冒泡。

事实上,这并没有什么卵用。

首先,一般而言滚动不是我们自己监听事件去改变元素的位置而实现的,当我们设置 overflow:scroll/auto时,实际上是浏览器原生实现的滚动效果。

其次, scroll事件对于普通Element元素是不冒泡的,可参见MDN:

Bubbles Not on elements, but bubbles to the default view when fired on the document

所有的滚动都是在 Document上形成了一个 pending队列,然后按照一定的规则触发,详见W3C规范:

When asked to run the scroll steps for a Document doc, run these steps: For each item target in doc’s pending scroll event targets, in the order they were added to the list, run these substeps: If target is a Document, fire an event named scroll that bubbles at target. Otherwise, fire an event named scroll at target. Empty doc’s pending scroll event targets.

当我们滚动鼠标滚轮,或者滑动手机屏幕时,触发对象可分为两种类型(详见W3C规范):

  1. viewport被触发滚动, eventtarget为关联的 Document

  2. element元素被触发滚动,通常也就是我们添加 overflow滚动属性的element元素, eventtarget为相应的 node element

注意到这里,只有两种类型,当我们触发滚轮或滑动时,如果当前元素没有设置 overflow这样的属性,同时也没有 preventDefault掉原生的滚动/滑动事件,那么此时触发的是 viewport的滚动, position:fixed的元素并没有什么例外。

由此可见,滚动穿透问题其实并不是一个浏览器的bug(虽然在ios下fixed定位确实会导致很多bug),它是完全符合规范的,滚动的原则应该是 scrollforwhat can scroll,不应该因为某个元素的 CSS定位导致滚轮失效或者滑动失效。

然而对于我们的业务而言, ModalLayer元素触发整个 viewport滚动往往是一个bug,它会给用户带来非常不好的体验,因此我们需要去解决它。

加overflow:hidden?

既然它触发了整个 viewport的滚动,那么我们给 body上加个 overflow:hidden,让整个body变成不可滚动的元素:

  
  
    
  1. html, body {

  2.    overflow: hidden;

  3. }

这个想法很美好,在不侵入JS的情况下禁止滚动,然而:

只加 overflow:hidden对移动端是无效的!

当body的高度被内容撑开而滚动时,如果不对body的高度加以限制,只加入 overflow:hidden,此时在移动端依然可以滚动。

我们可以在加入 overflow:hidden的同时选择性做:

  1. 将 html,body的高度设置为 100%

  2. 将 html,body设置为绝对定位

这两个操作都可以完美地禁止整个body的滚动,但带来的最大问题是:

该方案会让浏览器的滚动条默认重置于初始位置

要解决这个问题,首先想到的方案是在添加 overflow之前,先记录当前浏览器的 scrollTop值,然后在添加之后重置 scrollTop,效果如下:

(请注意蒙层出现时,底部列表发生的变化)

在这个交互过程中,浮层弹出时,底部列表首先滚动条被置为初始态,关闭浮层后重置为之前的记录位置。实际上浮层的弹出背景是有一次跳变。

这种方案实现简单,若认为重置滚动条的跳变无伤大雅的情况下可以优先采用此方案。

阻止body的默认滚动?

直接阻止 documenttouchmove事件:

  
  
    
  1. document.ontouchmove = e => {

  2.    e.preventDefault();

  3. };

看起来好像非常严格,将整个页面的滚动全部禁止,但实践后发现:

该方案好像在Android中不生效?

这似乎颠覆了我们平时的认知,连 document的touchmove都禁不掉默认滚动?

在仔细进一步的定位下,最终确定罪魁祸首原来是:

  
  
    
  1. passive event

我们知道,chrome 51引入了 passiveeventlisteners以提高滚动性能,同时它也合入了标准,具体可查看chrome passive-event-listeners

简单介绍一下原理,就是我们监听 touchmove事件时,在之前是有一个小延迟触发的,因为浏览器不知道我们是否要 preventDefault,所以等到大概200ms左右才能真正收到监听回调。

chrome在56版本将 addEventListner默认的 passive置为true,具体请参见这里,这样浏览器就能知道这个 addEventListner是不用 preventDefault的,立即可触发滚动事件。

在Android的手q和微信中使用的是X5内核,它是基于blink内核的,因此同样有关于 passiveevent的优化。所以我们需要加入 addEventListner的第三个参数:

  
  
    
  1. document.addEventListener(

  2.    'touchmove',

  3.    e => {

  4.        e.preventDefault();

  5.    },

  6.    { passive: false },

  7. );

现在Android的手机也可以禁止掉浏览器的滚动了。当然 addEventListner的第三个参数是最新标准才更改为对象的,因此存在一些兼容性问题,我们需要做一个检测:

  
  
    
  1. var supportsPassive = false;

  2. try {

  3.  var opts = Object.defineProperty({}, 'passive', {

  4.    get: function() {

  5.      supportsPassive = true;

  6.    }

  7.  });

  8.  window.addEventListener("test", null, opts);

  9. } catch (e) {}

采用这种方案带来的最大的问题是:

所有的滚动事件全部被禁止了!

假如我们的浮层上真的需要滚动事件,就不能阻止这些元素的默认行为。

浮层上面的滚动元素?

既然浮层上面有需要滚动的元素,最简单的方案就是有选择性地阻止默认事件:

  
  
    
  1. document.addEventListener(

  2.  'touchmove',

  3.  e => {

  4.    const excludeEl = document.querySelectorAll('.can-scroll');

  5.    const isExclude = [].some.call(excludeEl, (el: HTMLElement) =>

  6.      el.contains(e.target),

  7.    );

  8.    if (isExclude) {

  9.      return true;

  10.    }

  11.    e.preventDefault();

  12.  },

  13.  { passive: false },

  14. );

我们简单地规定带有 can-scroll类名的元素是可滚动的,这些元素以及他们的子元素全部采用不阻止默认事件策略。

这样一来只需要在可滚动的容器上加入 can-scroll类名即可滚动,但是这种滚动又随之带来一个问题:

当滚动到元素顶部和底部再继续滚动时,又会触发滚动穿透!

正如一开始介绍穿透问题那样,当滑动超出边界时,一样会触发默认的滚动穿透。对此,我们必须要在边界条件时阻止滚动:

  
  
    
  1. // 监听所有可滚动元素的滚动事件

  2. [].forEach.call(scrollEl, (el: HTMLElement) => {

  3.  let initialY = 0;

  4.  el.addEventListener('touchstart', e => {

  5.    if (e.targetTouches.length === 1) {

  6.      // 单点滑动

  7.      initialY = e.targetTouches[0].clientY;

  8.    }

  9.  });


  10.  el.addEventListener('touchmove', e => {

  11.    if (e.targetTouches.length === 1) {

  12.      // 单点滑动

  13.      const clientY = e.targetTouches[0].clientY - initialY;


  14.      if (

  15.        el.scrollTop + el.clientHeight >= el.scrollHeight &&

  16.        clientY < 0

  17.      ) {

  18.        // 向下滑至底部

  19.        return e.preventDefault();

  20.      }

  21.      if (el.scrollTop <= 0 && clientY > 0) {

  22.        // 向上滑至顶部

  23.        return e.preventDefault();

  24.      }

  25.    }

  26.  });

  27. });

经过这样的调整之后,看起来滚动穿透问题得到了完美的解决,但是:

当多个浮层同时存在时,滚动穿透将再次触发

支持多浮层

之所以会出现多浮层问题,是因为我们往 document上绑事件只绑一次,这个是对的,但是每个浮层关闭的时候都会触发 unbind,就会导致绑定的事件直接解绑,但其实这时还有其他浮层需要阻止滚动穿透。

解决办法也很简单,每一个浮层作为一个实例,我们定义一个Set来存储当前锁定的浮层:

  
  
    
  1. const lockedList = new Set();

  2. lock() {

  3.  lockedList.add(this);

  4.  // 省略其他逻辑

  5. }


  6. unlock() {

  7.  lockedList.delete(this);

  8.  if (lockedList.size <= 0) {

  9.    this.destroy();

  10.  }

  11. }

只有当这个set没有值的时候,也就是所有的弹框均调用 unlock之后,再去解绑事件。

这样,整个方案就比较完美了:

更好的组件调用

经过一系列折腾,我们终于解决了问题,也给出了一个相对较为完美的解决方案。可是从使用性质来考虑,还不是很便捷,尤其是现在如 ReactVue这类框架中,还需要考虑浮层什么时候实例化,什么时候应当调用 lockunlock显得有些麻烦,因此编写了一个React版本的组件:

  
  
    
  1. componentDidMount() {

  2.    const opts = this.props.selector

  3.      ? { selector: this.props.selector }

  4.      : undefined;

  5.    this.lockScroll = new LockScroll(opts);

  6.    this.updateScrollFix();

  7. }


  8. updateScrollFix() {

  9.    const { lock } = this.props;

  10.    if (lock) {

  11.      this.lockScroll.lock();

  12.    } else {

  13.      this.lockScroll.unlock();

  14.    }

  15. }


  16. componentDidUpdate(prevProps: ScrollFixProps) {

  17.    if (prevProps.lock !== this.props.lock) {

  18.      this.updateScrollFix();

  19.    }

  20. }


  21. componentWillUnmount() {

  22.    console.log('scrollfix component will unmount!');

  23.    this.lockScroll.unlock();

  24. }

思路也非常简单,组件传入一个 lock参数,当组件挂载时创建一个实例(保证了每个浮层一个实例),在lock变化时调用 lockunlock来解决滚动穿透,使用起来就非常简单了:

  
  
    
  1. <ScrollFix lock={show}>

  2.  <!-- 浮层内容 -->

  3. </ScrollFix>

只需要将浮层包裹在组件内,并且传入 lock属性,即可不用再关注滚动穿透的问题。


腾讯 IMWeb 团队招聘啦~

戳二维码查看详情

👇👇👇 


登录查看更多
35

相关内容

AI创新者:破解项目绩效的密码
专知会员服务
33+阅读 · 2020年6月21日
【论文扩展】欧洲语言网格:概述
专知会员服务
6+阅读 · 2020年3月31日
广东疾控中心《新型冠状病毒感染防护》,65页pdf
专知会员服务
18+阅读 · 2020年1月26日
安全和健壮的医疗机器学习综述,附22页pdf
专知会员服务
46+阅读 · 2020年1月25日
三本书,帮你解决阅读难题
罗辑思维
8+阅读 · 2019年6月13日
这么多年,终于知道为啥右指针不能往回走了
九章算法
5+阅读 · 2019年4月15日
从webview到flutter:详解iOS中的Web开发
前端之巅
5+阅读 · 2019年3月24日
Python3.7中一种懒加载的方式
Python程序员
3+阅读 · 2018年4月27日
JavaScript 背包问题详解
前端大全
7+阅读 · 2018年1月17日
阿里工程师详解典型SLAM应用场景及解决方案
机械鸡
6+阅读 · 2017年8月21日
漆桂林 | 开放:知识图谱发展的必由之路
开放知识图谱
7+阅读 · 2017年6月28日
Arxiv
15+阅读 · 2019年9月11日
VIP会员
相关资讯
三本书,帮你解决阅读难题
罗辑思维
8+阅读 · 2019年6月13日
这么多年,终于知道为啥右指针不能往回走了
九章算法
5+阅读 · 2019年4月15日
从webview到flutter:详解iOS中的Web开发
前端之巅
5+阅读 · 2019年3月24日
Python3.7中一种懒加载的方式
Python程序员
3+阅读 · 2018年4月27日
JavaScript 背包问题详解
前端大全
7+阅读 · 2018年1月17日
阿里工程师详解典型SLAM应用场景及解决方案
机械鸡
6+阅读 · 2017年8月21日
漆桂林 | 开放:知识图谱发展的必由之路
开放知识图谱
7+阅读 · 2017年6月28日
Top
微信扫码咨询专知VIP会员