Skip to content

彻底解决移动端页面的滚动穿透问题

问题复现

H5 页面中的弹窗出现概率很高,而要想实现弹窗,一个越不过的问题就是滚动穿透问题,表现为手指在弹窗上进行滑动时,下方的页面滚动起来了,示意效果如下:

图片待补充

根本原因分析

事件传播、滚动链、以及一些玄学

解决方案一:body overflow: hidden;

参考:https://juejin.cn/post/7269085173643706404

当弹窗弹出时,通过设置 body 的样式,让 body 不可滚动,这样即使弹窗的滚动穿透到了页面上,也不会导致页面滚动。

这样做的缺点是,页面的滚动位置会被重置,导致页面的滚动位置丢失,有严重的体验问题。

解决方案是,在弹窗打开时,记录滚动位置,使用 transform 来让页面平移滚动的距离,这样从视觉上看,背景页面的位置没有变化。在弹窗关闭时,恢复 body 的样式和滚动位置。

这种解决方案也有一些缺点:

  1. 首先就是对页面的整体结构有要求。因为要用 transform 来让页面整体平移,所以需要把页面上的元素都放在一个长的容器中,transform 的平移应用在此容器上。这一点在实际的应用中还好。
  2. 有兼容性问题。正常情况下,在打开和关闭弹窗时,虽然 body 的样式有变化,但是理论上来说视觉上是没有变化的。但是在实际应用中有两个兼容问题 1. 某些设备上,弹窗打开和关闭时,页面会闪;2. 一些低端设备上,能明显的看到页面先滚回初始位置,再平移滚动距离这一过程,严重影响体验。

虽然有上面两个问题,但是对于大多数的场景来说,这样就已经够用了。

解决方案二:#app 作为滚动容器,弹窗与 #app 同级

这种方式的理论基础是,弹窗的滚动之所以会穿透,是因为弹窗内的滚动到达了滚动的边界,所以滚动会被层层向上传递,一直传递到可滚动的父容器上,父容器进行滚动。

所以自然而然的想法就是让弹窗的所有父节点都不可滚动,这样就不会有滚动穿透的问题了。

但是理论与现实往往难以一致。在实际测试中,发现 Safari 是符合预期的,然而某些 Android 设备上,弹窗滚动穿透问题仍然存在。

也就是说滚动链的传递在这些设备上并不是按 DOM 层级来的。

解决方案三:检测 overflow 属性

这是接近完美的解决方案。

javascript
globalThis.supportsPassive = false;

try {
  const opts = Object.defineProperty({}, 'passive', {
    get() {
      globalThis.supportsPassive = true;
    },
  });
  globalThis.addEventListener('test', null, opts);
} catch (e) {}

function globalTouchHandler(e) {
  preventEvent(e);
}

export default class ScrollLocker {
  constructor(el) {
    this.el = el;
    this._onTouchStart = this._onTouchStart.bind(this);
    this._onTouchMove = this._onTouchMove.bind(this);
    this.el.addEventListener('touchstart', this._onTouchStart, true);
    this.el.addEventListener('touchmove', this._onTouchMove, true);
  }

  calcRect() {
    this.scrollHeight = this.el.scrollHeight;
    this.offsetHeight = this.el.offsetHeight;

    this.locked = this.scrollHeight === this.offsetHeight;
  }

  _onTouchStart(e) {
    this.calcRect();
    this.lastY = e.touches[0].pageY;
  }

  _onTouchMove(e) {
    e.stopPropagation();
    if (this.locked) {
      preventEvent(e);
      return false;
    }
    const { scrollTop } = this.el;
    const touch = e.touches[0];
    const currY = touch.pageY;
    const direction = currY - this.lastY;

    if (scrollTop === 0 && direction > 0) {
      preventEvent(e);
    } else if (this.scrollHeight <= this.offsetHeight + scrollTop && direction < 0) {
      preventEvent(e);
    }
    this.lastY = currY;
  }

  unlock() {
    this.el.removeEventListener('touchstart', this._onTouchStart, true);
    this.el.removeEventListener('touchmove', this._onTouchMove, true);
  }

  static lock(el) {
    let lockerId = el.dataset && el.dataset.lockerId ? el.dataset.lockerId : '';
    if (lockerId) {
      this.unlock(el);
    } else {
      // eslint-disable-next-line no-multi-assign
      lockerId = el.dataset.lockerId = this.genLockerId();
    }
    if (!size(this.lockers)) {
      document.addEventListener('touchmove', globalTouchHandler, window.supportsPassive ? {
        passive: false,
      } : false);
    }
    // eslint-disable-next-line no-multi-assign
    const lockers = this.lockers[lockerId] = [];

    [...el.querySelectorAll('[data-skip-locker]')].forEach((v) => {
      lockers.push(new ScrollLocker(v));
    });

    return lockerId;
  }

  static unlock(el) {
    const lockerId = el.dataset && el.dataset.lockerId ? el.dataset.lockerId : '';

    if (!lockerId) {
      return;
    }

    const lockers = this.lockers[lockerId];
    delete this.lockers[lockerId];
    if (Array.isArray(lockers)) {
      lockers.forEach(v => v.unlock());
    }

    if (!size(this.lockers)) {
      document.removeEventListener('touchmove', globalTouchHandler, window.supportsPassive ? {
        passive: false,
      } : false);
    }
  }

  static genLockerId() {
    const id = this.SEQ + 1;
    this.SEQ = id;
    return `locker_${id}`;
  }
}

function size(obj) {
  for (const key in obj) {
    // eslint-disable-next-line no-prototype-builtins
    if (obj.hasOwnProperty(key)) {
      return true;
    }
  }
  return false;
}

function preventEvent(e) {
  if (e.cancelable) e.preventDefault();
}

ScrollLocker.lockers = {};
ScrollLocker.SEQ = 0;

但是在某些极端情况下,仍然会出现滚动穿透的问题,iOS Safari 和某些 Android 设备下都会出现,问题的原因在于有时 touchmove 事件 cancelable 是 false, 无法阻止事件的默认行为。

终极解决方案:overscroll-behavior: contain

overscroll-behavior 这个 css 用来配置滚动链,有三个值 auto、contain、none。

auto 自不必说,当滚动到达边界时,会向上传递。

contain 和 none 有些类似,都是把滚动链在此元素处截断,滚动无法再向上传递。contain 和 none 的区别是,contain 在 safari 上当滚动到达边界时,保留了弹性加弹效果,在 none 则没有弹性效果。

边缘场景处理

overscroll-behavior: contain 是完美的吗?

很遗憾并不是,因为这个 overscroll-behavior 只适用于滚动容器,也即适用于 overflow: scrolloverflow: auto 并且内容超出容器的元素。

对于弹窗内的本身就不可滚动的元素,本属性并不生效,仍然会有滚动穿透的问题。

对于这种边缘场景,有两个解决方案,一个就是让弹窗成为一个滚动容器,可以尝试添加 overflow: scroll 来解决。另外一种方案是在弹窗上监听 touchmove 事件,判断目标元素是否可滚动,如果不可滚动,就调用 preventDefault 阻止事件的默认行为,如果可滚动,就无须调用 preventDefault 保留默认的滚动行为,而 overscroll-behavior: contain 则可以确保在滚动时不会越过边界导致滚动穿透,完美闭环。