滚动穿透在移动端开发中是一个很常见的问题,产生诡异的交互行为,影响用户体验,同时也让我们的产品看起来不那么“专业”。虽然不少产品选择容忍了这样的行为,但是作为追求极致的工程师,应该去了解为什么会产生以及如何去解决。

什么是滚动穿透

移动端开发中避免不了会在页面上进行弹窗、加浮层等这种操作。一个最常见的场景就是整个页面上有一个遮罩层,上面画着各种各样的东西,具体是什么就不讨论。实现这样一个遮罩层可难不住即使是一个刚开始写前端的小白。但是这里有一个问题就是如果不对遮罩层做任何处理,当用户在上面滑动时会发现遮罩层下方的页面居然也在滚动,这就很 interesting 了。就如下面的例子,一个名为mask长宽都是屏幕大小的遮罩层,我们在上面滑动时,下面的内容也在跟随滚动,即滚动“穿透”到了下方,这就是滚动穿透(scroll-chaining)。

scroll-chaining

上方 demo 的遮罩层底部是一个逐渐变蓝的内容容器,但是滑动上面遮罩层时,底部也跟随滚动了,这只是一个最简单的场景,后面我们会讨论更复杂的情况。

为什么会出现

目前 Google 上搜滚动穿透会出现一大堆教你如何解决的文章,但是它们都是在告诉你怎么解决怎么 hack 掉这种交互异常。并没有告诉读者为什么会产生这种行为,甚至认为这是浏览器的一个 bug。对于我来说这个是难以理解的,因为就算解决了问题,其实也并不知道问题的根本是怎样的。

认知误区

有一个误区就是我们设置了一个和屏幕一样大小的遮罩层,盖住了下面的内容,按理说我们应该能屏蔽掉下方的所有事件也就是说不可能触发下面内容的滚动。那么我们就去看一下规范,什么时候会触发滚动。

// https://www.w3.org/TR/2016/WD-cssom-view-1-20160317/#scrolling-events
When asked to run the scroll steps for a Document doc, run these steps:

  1. 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.
  1. Empty doc’s pending scroll event targets.

通过规范我们可以明白的 2 点是,首先滚动的 target 可以是 document 和里面的 element。其次,在 element 上的 scroll 事件是不冒泡的,document 上的 scroll 事件冒泡。

所以如果我们想通过在 scroll 的节点上去阻止它的滚动事件冒泡来解决问题是不可行的!因为它根本就不冒泡,无法触及 dom tree 的父节点何谈触发它们的滚动。

那么问题是怎么产生的呢,其实规范只说明了浏览器应该在什么时候滚动,而没有说不应该在什么时候滚动。浏览器正确实现了规范,滚动穿透也并不是浏览器的 bug。我们在页面上加了一个遮罩层并不会影响 document 滚动事件的产生。根据规范,如果目标节点是不能滚动的那么将会尝试 document 上的滚动,也就是说遮罩层虽然不可滚动,但是这个时候浏览器会去触发 document 的滚动从而导致了下方文档的滚动。也就是说如果 document 也不可滚动了,也就不会有这个问题了。这就引出了解决问题的第一种方案:把 document 设置为 overflow hidden。

怎么解决

overflow hidden

既然滚动是由于文档超出了一屏产生的,那么就让它超出部分 hidden 掉就好了,所以在遮罩层被弹出的时候可以给 html 和 body 标签设置一个 class:

.modal--open {
  height: 100%;
  overflow: hidden;
}

这样文档高度和屏幕一样,自然不会存在滚动了。但是这样又会引来一个新的问题,如果文档之前存在一定的滚动高度那么这样设置后会导致之前的滚动距离失效,文档滚回了最顶部,这样一来岂不是得不偿失?但是我们可以在加 class 之前记录好之前的滚动具体然后在关闭遮罩层的时候把滚动距离设置回来。这样问题是可以得到解决的实现成本也很低,但是如果遮罩层是透明的,弹出后用户仍然会看到丢失距离后的下方页面,显然这样并不是完美的方案。

prevent touch event

还有一种办法就是我们直接阻止掉遮罩层和弹窗的 touch event 这样就不会在移动端触发 scroll 事件了。但是在 PC 上没有 touch 事件, scroll 事件仍然可以被触发,原因上面我们也说过,scroll 事件是滚动它能滚动的元素。这里我们解决的是移动端的问题,例子如下:

scroll-chaining

<div id="app">
  <div class="mask">mask</div>
  <div class="dialog">dialog</div>
</div>
const $mask = document.querySelector('.mask');
const $dialog = document.querySelector('.dialog');
const preventTouchMove = ($el) => {
  $el.addEventListener(
    'touchmove',
    (e) => {
      e.preventDefault();
    },
    { passive: false }
  );
};
preventTouchMove($mask);
preventTouchMove($dialog);

上面我们通过 prevent touchmove 来阻止页面的触摸事件从而禁止进一步的页面滚动,在 addEventListener 最后一个参数我们将 passive 显示的设置为 false,这里是有用意的。关于 passive event listener 这里又是一个话题我们就不展开说了,就是浏览器为了优化滚动性能做的一些改进,具体可以看 网站使用被动事件侦听器以提升滚动性能,由于在 Chrome 56 开始将会默认开启 passive event listener 所以不能直接在 touch 事件中使用 preventDefault,需要先将 passive 选项设置为 false 才行。

这里我们解决了在页面上普通弹窗的问题,但是如果 dialog 的内容是可以滚动的,这样将其阻止了 touch 事件将会导致其内容也不能正常滚动,所以还有要进一步优化才行。

进一步优化

现在的场景是我们的弹窗是可以滚动的,所以不能再直接将其 touch 事件阻止,去掉后我们发现会产生新的问题。遮罩层被阻止了 touch 事件不能使下方滚动,但是弹出层 modal 这里内容是可滚动的,在 touch modal 时能正常滚动里面的内容。但是 modal 滚动到最上方或者最下方时仍然能触发 document 的滚动,效果如下:

scroll-chaining

我们看到当 modal 滚动在顶部时仍然能拖动下方 document。这样我们只能监听用户手势,如果 modal 已经滑动到了底部或者顶部且还要往上或者下滑动则也要 prevent modal 的 touch 事件。简单实现一个 fuckScrollChaining 函数:

function fuckScrollChaining($mask, $modal) {
  const listenerOpts = { passive: false };
  $mask.addEventListener(
    'touchmove',
    (e) => {
      e.preventDefault();
    },
    listenerOpts
  );
  const modalHeight = $modal.clientHeight;
  const modalScrollHeight = $modal.scrollHeight;
  let startY = 0;

  $modal.addEventListener('touchstart', (e) => {
    startY = e.touches[0].pageY;
  });
  $modal.addEventListener(
    'touchmove',
    (e) => {
      let endY = e.touches[0].pageY;
      let delta = endY - startY;

      if (
        ($modal.scrollTop === 0 && delta > 0) ||
        ($modal.scrollTop + modalHeight === modalScrollHeight && delta < 0)
      ) {
        e.preventDefault();
      }
    },
    listenerOpts
  );
}

完整实现在 这里,至此无论弹出层内容是否可滚动都不会导致下方 document 跟随滚动。