从 antDesign 来窥探移动端“滚动穿透”行为

趣谈前端

共 12740字,需浏览 26分钟

 · 2023-08-04

引言

相信大多数前端开发者在日常工作中都碰过元素滚动时造成的一些非预期行为。

这篇文章就和大家来聊聊那些滚动中的非预期行为的出现原理和解决方案。

Scroll Chaining

By default, mobile browsers tend to provide a "bounce" effect or even a page refresh when the top or bottom of a page (or other scroll area) is reached. You may also have noticed that when you have a dialog box with scrolling content on top of a page of scrolling content, once the dialog box's scroll boundary is reached, the underlying page will then start to scroll — this is called 「scroll chaining」.

上述是 MDN 中对于 overscroll-behavior 属性的描述,上述这段话恰恰描述了为什么会发生"滚动穿透"现象。

简单直译过来是说默认情况下,当到达页面的顶部或底部(或其他滚动区域)时,移动浏览器倾向于提供“弹跳”效果甚至页面刷新。您可能还注意到,当滚动内容页面顶部有一个包含滚动内容的对话框时,一旦到达对话框的滚动边界,底层页面就会开始滚动 - 这称为滚动「链接」

现象

直观上来说所谓的 Scroll Chaining(滚动链接)通常会在两种情况下被意外触发:

  • 「拖动不可滚动元素时,可滚动背景意外滚动。」

通常情况下,当我们对于某个不可滚动元素进行拖拽时往往会意外触发其父元素(背景元素)的滚动。

常见的业务场景比如在 Dialog、Mask 等存在全屏覆盖的内容中,当我们拖动不可滚动的弹出层元素内容时,背后的背景元素会被意外滚动。

file.gif

比如上方图片中有两个元素,一个为红色边框存在滚动条的父元素,另一个则为蓝色边框黑色背景不存在滚动条的子元素。

当我们拖动不可滚动的子元素时,实际会意外造成父元素会跟随滚动。

  • 「将可滚动元素拖动至顶部或者底部时,继续拖动触发最近可滚动祖先元素的滚动。」

还有另一种常见场景,我们在某个可滚动元素上进行拖动时,当该元素的滚动条已经到达顶部/底部。继续沿着相同方向进行拖动,此时浏览器会寻找当前元素最近的可滚动祖先元素从而意外触发祖先元素的滚动。


同样,动画中红色边框为拥有滚动区域的父元素,蓝色边框为父元素中同样拥有滚动区域的子元素。我们在子元素区域内进行拖拽时,当子元素滚动到底部(顶部)时,仍然继续往下(上)进行拖动。

原理

上述两种情况相信大家也日常业务开发中碰到过不少次。这样的滚动意外行为用专业术语来说,被称为「滚动链接(Scroll Chaining)」

那么,它是如何产生的呢?或者换句话说,浏览器哪条约束规定了这样的行为?

仔细查阅 w3c 上的 scroll-event 并没有明确的此项规定。

image.png

手册上仅仅明确了,滚动事件的 Target 可以是 Document 以及里边的 Element ,当 Target 为 Document 时事件会发生冒泡,而 Target 为 Element 时并不会发生冒泡,仅仅会 fire an event named scroll at target.

换句话说,也就是规范并没有对于 scroll chaining 这样的意外行为进行明确规定如何实现。

就比如,手册上规定了在 Element 以及 Document 中滚动必要的特性以及在代码层面应该如何处理这些特性,但是手册中并没有强制规定某些行为不可以被实现,就好比 scroll chaining 的行为。

不同的浏览器厂商私下里都遵从了 scroll chaining 的行为,而手册中并没有强制规定这种行为不应该被实现,自然这种行为也并不属于不被允许。

解决思路

通过上边的描述我们已经了解了”滚动穿透“的原理:绝大多数浏览器厂商对于滚动,如果目标节点不能滚动则会尝试触发祖先节点的滚动,就比如上述第一种现象。而对于目标节点可以滚动时,当滚动到顶部/底部继续进行滚动时,同样会意外触发祖先节点的滚动。

在移动端,我们完全可以使用一种通用的解决方案来解决上述造成“滚动穿透”意外行为:

无论元素是否可以滚动时,每次元素的拖拽事件触发时我们只需要进行判断:

  1. 寻找当前触发 touchMove 事件 event.target 「距离事件绑定元素最近的(event.currentTarget)(包含)可滚动祖先元素。」

之所以寻找 event.target 元素至 event.currentTarget(包含)可滚动祖先元素」,是因为我们需要判断本次滚动是否有效。

  • 如果在上述的范围内,祖先元素中不存在可滚动的元素,表示整个区域实际上是不可滚动的。那么不需要触发任何父元素的意外滚动行为,直接进行 event.preventDefault() 阻止默认。
  1. 如果在上述的范围内,祖先元素中存在可滚动的元素:
    1. 首先我们需要区域内的元素可以正常滚动。
    2. 其次,如果该元素已经滚动了顶部/底部,此时我们需要调用  event.preventDefault() 阻止继续相同方向滚动时的父元素意外滚动行为。

通用 Hook 方案

useTouch 拖动位置

首先,我们先来看一个有关于移动端滚动的简单 Hook:

import { useRef } from 'react'

const MIN_DISTANCE = 10

type Direction = '' | 'vertical' | 'horizontal'

function getDirection(x: number, y: number) {
if (x > y && x > MIN_DISTANCE) {
return 'horizontal'
}
if (y > x && y > MIN_DISTANCE) {
return 'vertical'
}
return ''
}

export function useTouch() {
const startX = useRef(0)
const startY = useRef(0)
const deltaX = useRef(0)
const deltaY = useRef(0)
const offsetX = useRef(0)
const offsetY = useRef(0)
const direction = useRef<Direction>('')

const isVertical = () => direction.current === 'vertical'
const isHorizontal = () => direction.current === 'horizontal'

const reset = () => {
deltaX.current = 0
deltaY.current = 0
offsetX.current = 0
offsetY.current = 0
direction.current = ''
}

const start = ((event: TouchEvent) => {
reset()
startX.current = event.touches[0].clientX
startY.current = event.touches[0].clientY
}) as EventListener

const move = ((event: TouchEvent) => {
const touch = event.touches[0]
// Fix: Safari back will set clientX to negative number
deltaX.current = touch.clientX < 0 ? 0 : touch.clientX - startX.current
deltaY.current = touch.clientY - startY.current
offsetX.current = Math.abs(deltaX.current)
offsetY.current = Math.abs(deltaY.current)

if (!direction.current) {
direction.current = getDirection(offsetX.current, offsetY.current)
}
}) as EventListener

return {
move,
start,
reset,
startX,
startY,
deltaX,
deltaY,
offsetX,
offsetY,
direction,
isVertical,
isHorizontal,
}
}

上述代码我相信大家一看便知,useTouch 这个 hook 定义了三个 startmovereset 方法。

  • start 方法中接受 TouchEvent 对象,同时调用 reset 清空 deltaoffset 以及 direction 值。同时记录事件对象发生时距离视口的距离 clientXclientY 值作为初始值。

  • move 方法中同样接受 TouchEvent 对象作为入参,根据 TouchEvent 上的位置属性分别计算:

    • deltaXdeltaY 两个值,表示移动时相较初始值的距离,不同方向可为负数。
    • offsetXoffsetY 分别表示移动时相较初始值 X 方向和 Y 方向的绝对距离。
    • direction 则是通过 offsetXoffsetY 相较计算出移动的方向。
  • reset 方法则是对于上述提到的变量进行一次统一的清空重制。

通过 useTouch 这个 hook 我们可以在移动端配合 touchstartonTouchMove 轻松的计算出手指拖动时的方向和距离。

getScrollParent 寻找区域内可滚动祖先元素

// canUseDom 方法是对于是否可以使用 Dom 情况下的判断,主要为了甄别( Server Side Render )
import { canUseDom } from './can-use-dom'

type ScrollElement = HTMLElement | Window

const defaultRoot = canUseDom ? window : undefined

const overflowStylePatterns = ['scroll', 'auto', 'overlay']

function isElement(node: Element) {
const ELEMENT_NODE_TYPE = 1
return node.nodeType === ELEMENT_NODE_TYPE
}
export function getScrollParent(
el: Element,
root: ScrollElement | null | undefined = defaultRoot
): Window | Element | null | undefined {
let node = el

while (node && node !== root && isElement(node)) {
if (node === document.body) {
return root
}
const { overflowY } = window.getComputedStyle(node)
if (
overflowStylePatterns.includes(overflowY) &&
node.scrollHeight > node.clientHeight
) {
return node
}
node = node.parentNode as Element
}
return root
}

getScrollParent 方法本质上从 el(event.target) 到 root(event.currentTarget) 范围内寻找最近的滚动祖先元素。

代码同样也并不是特别难理解,在 while 循环中从传入的第一个参数 el 一层一层往上寻找。要么寻找到可滚动的元素,要么一直寻找到 node === root 直接返回 root

比如这样的场景:

import { useEffect, useRef } from 'react';
import './App.css';
import { getScrollParent } from './hooks/getScrollParent';

function App() {
const ref = useRef<HTMLDivElement>(null);

const onTouchMove = (event: TouchEvent) => {
const el = getScrollParent(event.target as Element, ref.current);
console.log(el, 'el'); // child-1
};

useEffect(() => {
document.addEventListener('touchmove', onTouchMove);
}, []);

return (
<>
<div ref={ref} className="parent">
<div
className="child-1"
style={{
height: '300px',
overflowY: 'auto',
}}
>
<div
style={{
height: '600px',
}}
>
This is child-2
</div>
</div>
</div>
</>
);
}

export default App;

我们在页面中拖拽滚动 This is child-2 内容时,此时控制台会打印 getScrollParentevent.target (也就是 This is child-2 元素开始)寻找到的类名为 .parent 区域内的最近滚动元素 .child-1 元素。

useScrollLock 通用解决方案

上边我们了解了一个基础的 useTouch 关于拖拽位置计算的 hook 以及 getScrollParent 获取区域内最近的可滚动祖先元素的方法,接下来我们就来看看在移动端中关于阻止 scroll chaining 意外滚动行为的通用 hook

这里,我直接贴一段 ant-design-mobile 中的实现代码,(实际这是 ant-design-mobile 中从 vant 中搬运的代码):

import { useTouch } from './use-touch'
import { useEffect, RefObject } from 'react'
import { getScrollParent } from './get-scroll-parent'
import { supportsPassive } from './supports-passive'

let totalLockCount = 0

const BODY_LOCK_CLASS = 'adm-overflow-hidden'

function getScrollableElement(el: HTMLElement | null) {
let current = el?.parentElement

while (current) {
if (current.clientHeight < current.scrollHeight) {
return current
}

current = current.parentElement
}

return null
}

export function useLockScroll(
rootRef: RefObject<HTMLElement>,
shouldLock: boolean | 'strict'
) {
const touch = useTouch()

/**
* 当手指拖动时
* @param event
* @returns
*/
const onTouchMove = (event: TouchEvent) => {
touch.move(event)

// 获取拖动方向
// 如果 deltaY 大于0,拖动的当前Y轴位置大于起始位置即从下往上拖动将 direction 变为 '10',否则则会 `01`
const direction = touch.deltaY.current > 0 ? '10' : '01'

// 我们在上边提到过,找到范围内可滚动的元素
const el = getScrollParent(
event.target as Element,
rootRef.current
) as HTMLElement
if (!el) return

// This has perf cost but we have to compatible with iOS 12
if (shouldLock === 'strict') {
const scrollableParent = getScrollableElement(event.target as HTMLElement)
if (
scrollableParent === document.body ||
scrollableParent === document.documentElement
) {
event.preventDefault()
return
}
}

// 获取可滚动元素的位置属性
const { scrollHeight, clientHeight, offsetHeight, scrollTop } = el

// 定义初始 status
let status = '11'

if (scrollTop === 0) {
// 滚动条在顶部,表示还未滚动
// 滚动条在顶部时,需要判断是当前元素不可以滚动还是可以滚动但是未进行任何滚动

// 当 offsetHeight >= scrollHeight 表示当前元素不可滚动,此时将 status 变为 00,
// 否则表示当前元素可滚动但滚动条在顶部,将status变为 01
status = offsetHeight >= scrollHeight ? '00' : '01'
} else if (Math.abs(scrollHeight - clientHeight - scrollTop) < 1) {
// 滚动条已经到达底部(表示已经滚动到底),将 status 变为 '10'
status = '10'
}

// 1. 完成上述的判断后,如果 status === 11 表示当前元素可滚动并且滚动条不在顶部也不在底部(即在中间),表示 touchMove 事件不应该阻止元素滚动(当前滚动为正常现象)

// 2. 同时 touch.isVertical() 明确确保是垂直方向的拖动

// 3. parseInt(status, 2),当 status 不为 11 时,分为以下三种情况分别代表:

// 3.1 status 00 表示区域内未寻找到任何可滚动元素
// 3.2 status 01 表示寻找到可滚动元素,当前元素为滚动条在顶部
// 3.3 status 10 表示寻找到可滚动元素,当前元素滚动条在底部
// 自然 parseInt(status, 2) & parseInt(direction, 2) 这里使用了二进制的方式,

// 3.4 当 status 为 00 时, 0 & 任何数都是 0.自然 !(parseInt(status, 2) & parseInt(direction, 2)) 会变为 true (对应 3.1 情况),需要阻止意外的滚动行为。

// 3.5 当 status 为 01 时(对应 3.2 滚动条在顶部),此时当用户从下往上拖动时,需要阻止意外的滚动行为发生。否则,则不需要阻止正常滚动。自然 status === '01' ,direction === 10(从下往上拖动),!(parseInt(status, 2) & parseInt(direction, 2)) 为 true 需要进行阻止默认滚动行为。(进制上 1 & 1 为 1 ,1 & 2 为 0)

// 3.6 根据 3.5 的情况,当 status 为 10 (对应 3.3)滚动到达底部,自然对于从上往下拖动时 direction 为 01 时也应该阻止,所以 (2&1 = 0) 自然 !(parseInt(status, 2) & parseInt(direction, 2)) 为 true,同样会进入 if 语句阻止意外滚动。

if (
status !== '11' &&
touch.isVertical() &&
!(parseInt(status, 2) & parseInt(direction, 2))
) {
if (event.cancelable) {
event.preventDefault()
}
}
}

/**
* 锁定方法
* 1. 添加 touchstart 和 touchmove 事件监听
* 2. 根据 totalLockCount,当 hook 运行时为 body 添加 overflow hidden 的样式类名称
*/
const lock = () => {
document.addEventListener('touchstart', touch.start)
document.addEventListener(
'touchmove',
onTouchMove,
supportsPassive ? { passive: false } : false
)

if (!totalLockCount) {
document.body.classList.add(BODY_LOCK_CLASS)
}

totalLockCount++
}

/**
* 组件销毁时移除事件监听方法,以及清空 body 上的 overflow hidden 的类名
*/
const unlock = () => {
if (totalLockCount) {
document.removeEventListener('touchstart', touch.start)
document.removeEventListener('touchmove', onTouchMove)

totalLockCount--

if (!totalLockCount) {
document.body.classList.remove(BODY_LOCK_CLASS)
}
}
}

useEffect(() => {
// 如果传入 shouldLock 表示需要防止意外滚动
if (shouldLock) {
lock()
return () => {
unlock()
}
}
}, [shouldLock])
}

我在上述代码片段中每一行都进行了详细的注释,认真看这段代码相信大家不难看懂。上述的代码仍然是按照我们在文章开头讲述的解决思路来解决移动端滚动链接的意外行为。

关于上边代码中有几个小 Tips ,这里和大家稍微赘述下:

  1. 关于 shouldLock === 'strict' 这种情况 antd 源码中标明是对于 IOS12 清空的兼容,如果这段代码混淆了你的思路完全可以忽略它,因为它并不是我们主要想赘述的内容。
  2. addEventListener 第三个参数 { passive: false } ,在 safari 以外的浏览器默认为 true ,它会导致部分事件函数中 preventDefault() 无效,所谓的 passive 在 chrome51 版本后出现的,本质上是为了通过被动侦听器提高滚动性能。详情可以查看 MDN 的解释,这里我就不在赘述了。
  3. BODY_LOCK_CLASS 的实际样式其实就是 overflow:hidden,之所以通过 totalLockCount 计数的方式添加,没什么特别的。想象一下,如果你的页面中每个 Modal 弹窗都使用了 useLockScroll 这个 hook ,那么当页面中开启两个弹窗,当关闭一个时另一个还存在时总不能移除了 BODY_LOCK_CLASS 吧。
  4. body 添加 overflow:hidden 其实在移动端并没什么太大的实际作用,我们 touchmove 事件中的处理逻辑对于阻止意外滚动行为的发生已经完全足够了。这点最初我也不太明白为什么这么做,所以我也去 vant 中进行了请教,详见 vant Discussions。
    实际上源码中并不是使用 Math.abs(scrollHeight - clientHeight - scrollTop) < 1 判断滚动条是否到达底部,而是使用 scrollTop + offsetHeight >= scrollHeight 显然这是不准确的可能会导致 Bug(因为 scrollTop 是一个非四舍五入的数字(可以为小数),而 scrollHeightclientHeight 是四舍五入的数字)所以极端场景下会导致不准确,我就遇到过,有兴趣了解的朋友详见我对于 antd-mobile 的 PR。
     

结语

文章到这里就和大家说声再见了,刚好前段时间在公司内编写移动端组件时遇到过这个问题所以拿出来和大家分享。

当然,如果大家对于文章中的内容有什么疑惑或者有更好的解决方案。你可以在评论区留下你的看法,我们可以一起进行讨论,谢谢大家。


浏览 692
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报