深入前端之滚动“穿透”的前因后果探究与解决

259 阅读17分钟

前言

看到这主题可能你会想又是一个查漏补缺,炒冷饭的话题。我写这种烂大街的话题的文章,基本离不开两个理由,就是现存的网络资料

  • 未能解决我的疑惑
  • 想到更完善的解决方案

那么本次文章能给大家带来什么?

  • 理清滚动穿透的前因后果,理解为什么会出现这种情况,从本质上认识这个问题
  • 相比网络上的其他资料
    • 通用性: 绝大多数的的资料提出的方案都是解决滚动穿透到bodyhtml上,所以他们提出的方案具有“针对性”,若不是在bodyhtml上,其实很多方案是遗漏了一些要考虑的点的,是具有缺点的,无法直接使用;而我提出的,适用于所有场景,不管是滚动穿透到body上还是其他html容器上,都可以
    • 便利性: 因为通用性,所以可以直接封装成一个方法后直接使用,适合任何场景。只需要知道自己想要哪个元素滚动,不需要关心其他元素的情况,目标很明确,不会受到因为布局的场景的局限性。网上的方案往往需要知道牵涉到哪些父容器情况。
    • 修复性: 网上资料没有考虑到多层嵌套滚动容器时,存在多层的滚动穿透行为的情况;还有就是滚动方向手势造成的影响点。以上遗漏点本方案都直接可以解决
  • 在介绍过程中,会夹带一些鲜为人知的知识点干货哦

问题

简单描述下问题的情况,实际上,就是很多移动端开发者,在编写页面会有弹窗之类的,而在弹窗上出发滚动事件,会把弹窗遮盖下的页面内容也发生滚动(可滚动的话)

如下图gif所示,橙色区域为页面内容,一般就是body下的内容,例子中是可滚动的,即内容很长超过屏幕一屏。而粉红色区域就是大家常做的弹窗,该弹窗自身内容也是很长,也是可以滚动的。

Mar-22-2023 15-15-44.gif

可以看到,当我们滚动粉红色区域(弹窗)时,当滚动到底部或顶部时,继续发生作滑动手势时,“底部”的页面区域(橙色区域)也会发生滚动。

很多人认为,在弹窗出现后,在它上面进行滑动,应该只滚动弹窗的内容,不应该触发弹窗“底下”的页面内容滚动才对。

原因

其实这是个具有迷惑性的“问题”,是一个视觉认知上产生的错觉让你误以为这是个问题。

一般大家提出这个问题,基本都是说类似弹窗之类的情况下,弹窗的独特样式让大家从视觉上看着像是弹窗位于页面的上层,页面是底层的东西,而明明手指滑动位于上层的弹窗内容,并没有触碰到底层的页面,不应该触发底层页面滚动。大家都是觉得这个是理所当然的。

上面这种“理所当然”是一种错觉,这种看似上下层的结构,只是视觉上看着是这样的,但是实际上,从DOM结构上,并没有这种所谓的层级关系,而浏览器认识的正是从DOM结构上认识。当我们触发一个事件,例如click事件,也是根据DOM结构经过捕获阶段和冒泡阶段,触发滑动的事件也不例外。而不是根据我们看着像是有上下层关系的页面内容来触发事件,大家以为只碰到了上层的弹窗,并没有碰到下层的页面,这是一种错误的认识。

假设你把弹窗的蒙层样式去掉,没人告诉你这是个弹窗,光从界面上看,谁知道这是个看着有上下层关系的弹窗还是只是页面中间有一个div容器呢

image.png

按照上图来滑动看,表现跟第一张带有蒙层的gif一样,但是你会觉得一切都很正常,没啥不妥。因为你不知道这个弹窗,假设它的确不是一个弹窗,就是页面中间有一个div容器带有可滚动内容,这种滑动表现你并不会提出疑问。

是不是,瞬间觉得自己双标了[狗头]

所以,其实这个所谓的问题,并不是一个bug,而是一个浏览器正常的表现行为。具体什么行为,这里卖个关子,下面具体说明。

本质

阅读过网上许多文章,大家分析和解决这个问题,基本都是从htmlbody,以及弹窗容器本身的关系来解决这个问题。这也难怪,毕竟大多数移动端的页面结构很简单,而大家遇到这个问题的场景大多数也就是这三者之间的关系,所以提出解决方案针对这他们来处理也是很正常的。

但是我们试下拓展下视野,看得更宽点,试图去了解下更加广泛的场景,认识下本质。

实际上,这跟是否是bodyhtml,以及弹窗容器没多大关系,就算页面内容不是处于body上,也还是会发生这样的问题,就算不是滑动弹窗容器而是其他子容器,也还是会发生这样的问题。

那么本质行为是怎样的?

当你的页面上存在多个可滚动容器,你的手指在哪个容器上做滑动手势,优先滚动所在的容器。当该容器滚动到顶部或底部时,继续滚动(非连续,即你滚动到顶或底后,手指有抬起过然后继续滑动),接下来的行为就有差异了:

  • 当在Android上时,会直接滚动body/html上的内容(body/html没设置高度或body/html设置了高度了overflow:auto/scroll
  • 当在IOS上时,会滚动其父层可滚动容器,父层滚动元素同样滚动到顶部或底部后,继续发生滑动的话,就继续到父层元素的父层可滚动容器发生滚动,以此类推,直到到达body容器。这里说的父子关系,当然是从DOM结构角度来说的,而不是从视觉上的角度。

当在电脑端浏览器上进行实验,会发现,在电脑上(windowmac),表现跟在Android上的一致。

这里有个demo代码,大家可以自己分别在AndroidIOS上尝试体验下

<!DOCTYPE html>
<html>
<head>
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"/>
    <title>test</title>
    <style>
        html, body {
            padding: 0;
            margin: 0;
        }
        body {
            padding-top: 40px;
            background-color: black;
            /* height: 100vh; */
            overflow: auto;
        }
        .app-container {
            height: 500px;
            padding: 16px;
            background-color: aqua;
            overflow: auto;
        }
        #box {
            position: fixed;
            top: 90px;
            width: 80%;

            height: 200px;
            background-color: brown;
            overflow: auto;
        }
        .blowup {
            height: 900px;
        }
        
    </style>
</head>
<body>
    <div class="app-container">
        <div id="box">
            <section class="blowup">blowup-box</section>
            blowup-box-end
        </div>
        <section class="blowup">blowup-app</section>
        blowup-app-end
    </div>
    <section class="blowup">blowup-body</section>
    blowup-body-end
</body>
</html>

解决方案

原因是知道了,本质表现也知道了,虽然知道这个是浏览器的正常行为,但是,对于我们来讲,它这种表现并不是我们想要的效果,总不能说这个是浏览器正常表现而不去管他,非开发人员管你是不是浏览器行为呢,对使用者来讲,弹窗场景下它的表现就是怪异的,所以还是得去解决它。

网上是有很多博客介绍解决这个问题,我大部份都看了下,基本大家都是围绕htmlbody是最外层的滚动容器,然后它上面有个弹窗,滚动弹窗的内容就会出发htmbody的滚动。所以他们的解决方案大多数也是围绕着这个场景来解决。这个我也十分理解,毕竟移动端上的布局不会特别复杂。但是到底这只是一个场景之一,他们介绍的方案大多数也只适用这个场景,未能覆盖更广泛的场景。

阅读过我的文章的人大概了解我的风格,我喜欢研究一些更为通用的兼容性更好的方案,因为我不想每次都要去思考这个是什么场景,我要去选择什么对应场景的方案,写对应的代码。我只想知道这个是什么问题,我只需要用什么方法来解决就好,不需要过多关心它内部的使用问题,不需要关心问题会有什么场景问题,我在什么场景下会不会方案不生效等。

于是我自己总结了一套通用方案。

网上盛行的方案基本分为两类

  • 一种是更多地通过css来解决。利用overflow: hidden; 让弹窗下的页面滚动失效,并结合position: fixedtop的属性让视觉效果不显的突兀。
  • 一种是纯粹用javascript来解决。通过preventDefault阻止滚动事件的往外泄漏

我尝试使用css的方式来解决,发现这种方式,不能成为通用方案,它更适合于处理一个父级滚动容器的场景,而具有更深的定制化写法,难以封装出一个适用于更多父级滚动容器的场景和更多布局手段的通用代码。特别是若一个元素设置了fixed定位后,需要重新指定它的widthheight,这个是不可精准可控的,并且脱离了文档流,会影响到了原本的布局了,破坏性很大!

为什么网上那么多资料会推荐这个方式,是因为他们应用的对象是bodyhtml,不会存在脱离文档流带来的影响。

所以接下来我的通用方案便是纯粹javascript的方案:

先看下怎么使用,再具体说明方法内部实现了什么逻辑。

为了方便大家使用,我封装在uiueutils方法库中。

// 先安装下依赖

npm i uiueutils

安装完后进行使用:

/**
 * @param {Node} - 指定具体的容器可以滚动,其余都不行
 * @returns {Object} 返回一个设置了滚动规则的实例
 */
import { scrollOnly } from 'uiueutils'

// 指定'#example-box'可以滚动,其余页面元素不能发生滚动
scrollOnly(document.querySelector('#example-box'))

// 可指定多个元素
scrollOnly([document.querySelector('#example-box1'), document.querySelector('#example-box2')])

// 建议使用方法:
// 调用方法表明对传入的DOM对象进行滚动穿透限制。
// 开启后会返回一个实例对象
const contianerOnly = scrollOnly(document.querySelector('#example-box'))

// 在合适的时机下,不再需要禁止其他元素滚动了,就取消开启
contianerOnly.off()

就是这么简单,在指定的dom节点上进行滑动就不会发生滚动穿透了。

典型例子,例如打开了弹窗,防止弹窗滚动引起“穿透”到“外层”:

var contianerOnly = null

function openModal () {
    // ... 打开了弹窗
    // ...
    // 打开后设置只允许弹窗的容器可滚动
    contianerOnly = scrollOnly(document.querySelector('#dialog-modal'))
}

function closeModal () {
    // ... 关闭了弹窗
    // ...
    // 关闭后恢复原样
    contianerOnly.off()
}

那现在把目光放在scrollOnly上,看其内部做了什么。

上面解释过滚动穿透的原因,以及诱发穿透的时机是你在某个DOM容器上作滚动手势,当该容器的内容已经滚动到底部或顶部,无法再发生滚动时,就会诱发父/祖先容器的滚动了。而你也要清楚,若一个容器无法滚动(可能内容不够多或者本身设置了overflow:hidden;),你在其上面作滚动手势,同样视为到达了顶部和底部,所以这种情况下还是会发生滚动穿透现象。

既然知道滚动穿透发生的原因和时机,我们就可以对症下药,在我们作滚动手势的时候判断是否到达了顶部和底部,是的话,就利用preventDefault()来阻止默认行为,这样就能阻止滚动穿透了。这是方案的解决核心,看似很简单,但是细节方面还是要打磨的,关注点有:

  1. 需要支持任意指定容器,会存在多个的情况
  2. 怎么判断到达顶部和底部才禁止默认行为,不能单纯的判断当当前处于顶部或底部时就禁用,例如在顶部时明明可以向下滚动查看更多,却禁用了导致查看不了更多。所以这里我通过判断滑动手势是向上滑动还是向下滑动
  3. 特殊情况:当容器不可滚动,但是滚动手势仍然会触发滚动穿透
  4. 只关心自己关注的容器可滚动,其余容器都不能滚动。但是其余容器我不想管有什么容器。
  5. 极限场景:会存在用户在触摸屏幕后进行滑动往两个方向滑动(期间不抬起手指),这种交互仍然会引发滚动穿透,需要额外对此场景写判断条件进行阻止。

关于第5点,这里要好好展开说说。

【额外知识点】当你触摸到屏幕后滑动一段距离后又向反方向滑动,这期间手指未抬起过,实际上这里是有两个方向的滑动。根据情况的不同,滚动交互实际反馈的效果会有所不同。

场景1

当你对一个在某个方向上不可继续滚动(或本身不支持滚动)的容器(记作A)进行滑动手势欲要滚动那个方向上的更多内容,则会把滚动效果实际对象作用在其父层,父层也同样情况就继续反馈到上一层,以此类推。尽管这个父层的toumove里使用了preventDefault阻止了默认行为,但通过容器A上这种行为却能触发父层的滚动。这意味着,当你从触摸到屏幕后滑动一段距离后又向反方向滑动(这期间手指未抬起过),尽管容器A在某个方向上可以滚动,但是因为第一次滑动的方向是不可滚动的,所以导致浏览器要滚动的对象已经转移到其父层,进而容器A就算可以滚动也会变成滚动不了。

举例说明

当前容器A滚动条在顶部了,即不能继续往上查看内容了,但是它可以滚动下方内容查看更多。当你触摸向下滑动,即想要查看上方内容,因为不能继续往上滚动了,导致浏览器要把滚动交互的作用对象转移其父层,因此尽管你向下滑动后又转为向上滑动(这期间手指未抬起过),你以为能滚动容器A的下方内容,但是实际上并不会这么表现。

场景2

当你对一个的容器(记作B)进行滑动手势欲要滚动查看更多内容,若此时的容器滚动条滚动位置既未到达顶部也未到达地步,那么这次开始触摸时因为可以上下滚动,因此浏览器滚动交互的对象就是作用在容器B上,从触摸到屏幕后滑动一段距离后又向反方向滑动(这期间手指未抬起过),从始至终都是能让那个容器B发生滚动


具体逻辑解析

好了,该考虑的点都罗列清楚了,该用代码实现上述思想了。我精简了上述方法实现的内部逻辑,目的为了更好地给大家讲解,让大家了解实现核心方法。

有一些知识点需要大家提前知晓

  • 当对父容器(祖先容器)的touchmove事件中使用e.preventDefault(),同样会阻止到子孙容器的滚动,不论你是设置在捕获阶段还是冒泡阶段。
  • 而想实现子孙容器允许滚动,父(祖先)容器不能滚动,你单纯地在子孙容器的touchmove事件中使用e.stopPropagation来阻止冒泡到父级,这种做法也是行不通的。它仅仅是阻止了touchmove事件的向上冒泡而已,但是并没有阻止滚动手势引起的浏览器默认交互行为(得用e.preventDefault()阻止)
  • 达到「子孙容器允许滚动,父(祖先)容器不能滚动」的效果。即你对父(祖先)容器的touchmove事件中执行e.preventDefault(),子容器的touchmove事件中使用e.stopPropagation。但是,子容器滚动到尽头时,在往尽头的那个方向继续滚动,就会转移滚动对象到祖先容器,此时就算祖先容器设置了e.preventDefault() 也还是会触发滚动,而滚动规则就是上面章节中说的本质行为情况一样,注意安卓和ios表现不一样。所以子容器滚动到尽头时你就需要对子容器的touchmove设置preventDefault进行阻止.

结合上述知识点,所以方案的核心思路就是:

  1. 先对页面顶层元素设置不可滚动。touchmove事件中使用e.preventDefault(),这就页面所有元素都不能滚动了。目的是在触碰非滚动容器时不能发生滚动。
  2. 对想要的元素允许滚动:touchmove事件中使用e.stopPropagation(),这样就达到只有指定容器滚动了
  3. 但是考虑到特殊情况滚动对象的转移,因此还要加上判断,到达顶部和底部时,可滚动容器设置e.preventDefault()禁止滚动引发的默认行为
  4. 因为是判断手势移动方向来再进一步判断是否到达顶部底部,考虑到存在连续不抬起手指进行了多个方向的变化,此时就不能仅仅判断移动方向和是否到达顶部底部这么简单的关系来判断了。

把上述思路转换成代码(方法的核心部分拿出来分析):

var startY = null // 记录每次触碰容器时的触点距离可视视口的垂直距离
var canMoveStatus = 'init' // 主要是解决连续不抬起手指会出现两个方向的滚动的问题

var scrollContainers = [] // 这个就是包含要指定哪些dom节点容器不能触发滚动穿透的数组
// 对这些元素设置监听事件
scrollContainers.forEach(el => {
    el.addEventListener('touchstart', handleStart)
    el.addEventListener('touchmove', handleTouchMove, {
        passive: false
    })
})

/**
 * 记录每次触碰容器时重置一些数据
 * @param {Object} e - touchevent
 */
function handleStart (e) {
    startY = e.targetTouches[0].clientY
    canMoveStatus = 'init'
}

/**
 * 滑动手势时进行判断是否阻止引起滚动默认行为
 * @param {Object} e - touchevent
 */
function handleTouchMove (e) {
    const { scrollTop, scrollHeight, clientHeight } = e.currentTarget
    const currentY = e.targetTouches[0].clientY
    e.stopPropagation() // 下面解释
    // 为off代表本次触摸第一次引起的move是被阻止了,转移了浏览器的滚动目标对象了,所以直接禁用滚动了
    if (canMoveStatus === 'off') {
        e.preventDefault()
        return
    }
    // 如果是向下滑动手势,即要查看上方内容时
    if (currentY - startY > 0) {
        // 此时刚好到达弹层容器顶部,继续滑动就阻止默认的滚动事件了
        if (scrollTop === 0) {
            preventMove(e)
        }
    // 否则是向下滑动手势,查看下方内容时,此时刚好到达弹层容器底部,继续滑动就阻止默认的滚动事件了
    // 这里有个小知识点,要判断是否到达底部,得这么写,因为scrollTop是一个非整数,而scrollHeight和clientHeight是四舍五入的,因此确定滚动区域是否滚动到底的唯一方法是查看滚动量是否足够接近某个阈值
    } else if (scrollHeight - clientHeight - scrollTop < 1) {
        preventMove(e)
    } else {
        canMoveStatus = 'on'
    }
}

function preventMove (e) {
    if (e.cancelable) {
        e.preventDefault()
    }
    canMoveStatus === 'init' && (canMoveStatus = 'off')
}

需要注意的,该方案不适合用web端,因为web端的scroll事件使用preventDefault是无法阻止默认行为的。

总结

阅读完本篇文章,虽然解决方案也很重要,但是其实问题出现的本质,以及原因和思考问题中的各种知识点整理,这个才是最大的收益之处。希望大家都有所收获,感谢支持。

顺带一说,uiueutils里还有其他更实用的方法,后续会不断补充,欢迎各位关注。