Appearance
问题复现
H5 页面中的弹窗出现概率很高,而要想实现弹窗,一个越不过的问题就是滚动穿透问题,表现为手指在弹窗上进行滑动时,下方的页面滚动起来了,示意效果如下:
图片待补充
根本原因分析
事件传播、滚动链、以及一些玄学
解决方案一:body overflow: hidden;
参考:https://juejin.cn/post/7269085173643706404
当弹窗弹出时,通过设置 body 的样式,让 body 不可滚动,这样即使弹窗的滚动穿透到了页面上,也不会导致页面滚动。
这样做的缺点是,页面的滚动位置会被重置,导致页面的滚动位置丢失,有严重的体验问题。
解决方案是,在弹窗打开时,记录滚动位置,使用 transform 来让页面平移滚动的距离,这样从视觉上看,背景页面的位置没有变化。在弹窗关闭时,恢复 body 的样式和滚动位置。
这种解决方案也有一些缺点:
- 首先就是对页面的整体结构有要求。因为要用 transform 来让页面整体平移,所以需要把页面上的元素都放在一个长的容器中,transform 的平移应用在此容器上。这一点在实际的应用中还好。
- 有兼容性问题。正常情况下,在打开和关闭弹窗时,虽然 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: scroll 或 overflow: auto 并且内容超出容器的元素。
对于弹窗内的本身就不可滚动的元素,本属性并不生效,仍然会有滚动穿透的问题。
对于这种边缘场景,有两个解决方案,一个就是让弹窗成为一个滚动容器,可以尝试添加 overflow: scroll 来解决。另外一种方案是在弹窗上监听 touchmove 事件,判断目标元素是否可滚动,如果不可滚动,就调用 preventDefault 阻止事件的默认行为,如果可滚动,就无须调用 preventDefault 保留默认的滚动行为,而 overscroll-behavior: contain 则可以确保在滚动时不会越过边界导致滚动穿透,完美闭环。

