從 antDesign 來窺探移動端“滾動穿透”行為
引言
相信大多數(shù)前端開發(fā)者在日常工作中都碰過元素滾動時造成的一些非預(yù)期行為。
這篇文章就和大家來聊聊那些滾動中的非預(yù)期行為的出現(xiàn)原理和解決方案。
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 屬性的描述,上述這段話恰恰描述了為什么會發(fā)生"滾動穿透"現(xiàn)象。
簡單直譯過來是說默認情況下,當?shù)竭_頁面的頂部或底部(或其他滾動區(qū)域)時,移動瀏覽器傾向于提供“彈跳”效果甚至頁面刷新。您可能還注意到,當滾動內(nèi)容頁面頂部有一個包含滾動內(nèi)容的對話框時,一旦到達對話框的滾動邊界,底層頁面就會開始滾動 - 這稱為滾動「鏈接」。
現(xiàn)象
直觀上來說所謂的 Scroll Chaining(滾動鏈接)通常會在兩種情況下被意外觸發(fā):
-
「拖動不可滾動元素時,可滾動背景意外滾動。」
通常情況下,當我們對于某個不可滾動元素進行拖拽時往往會意外觸發(fā)其父元素(背景元素)的滾動。
常見的業(yè)務(wù)場景比如在 Dialog、Mask 等存在全屏覆蓋的內(nèi)容中,當我們拖動不可滾動的彈出層元素內(nèi)容時,背后的背景元素會被意外滾動。
比如上方圖片中有兩個元素,一個為紅色邊框存在滾動條的父元素,另一個則為藍色邊框黑色背景不存在滾動條的子元素。
當我們拖動不可滾動的子元素時,實際會意外造成父元素會跟隨滾動。
-
「將可滾動元素拖動至頂部或者底部時,繼續(xù)拖動觸發(fā)最近可滾動祖先元素的滾動?!?/strong>
還有另一種常見場景,我們在某個可滾動元素上進行拖動時,當該元素的滾動條已經(jīng)到達頂部/底部。繼續(xù)沿著相同方向進行拖動,此時瀏覽器會尋找當前元素最近的可滾動祖先元素從而意外觸發(fā)祖先元素的滾動。

原理
上述兩種情況相信大家也日常業(yè)務(wù)開發(fā)中碰到過不少次。這樣的滾動意外行為用專業(yè)術(shù)語來說,被稱為「滾動鏈接(Scroll Chaining)」。
那么,它是如何產(chǎn)生的呢?或者換句話說,瀏覽器哪條約束規(guī)定了這樣的行為?
仔細查閱 w3c 上的 scroll-event 并沒有明確的此項規(guī)定。
手冊上僅僅明確了,滾動事件的 Target 可以是 Document 以及里邊的 Element ,當 Target 為 Document 時事件會發(fā)生冒泡,而 Target 為 Element 時并不會發(fā)生冒泡,僅僅會 fire an event named scroll at target.
換句話說,也就是規(guī)范并沒有對于 scroll chaining 這樣的意外行為進行明確規(guī)定如何實現(xiàn)。
就比如,手冊上規(guī)定了在 Element 以及 Document 中滾動必要的特性以及在代碼層面應(yīng)該如何處理這些特性,但是手冊中并沒有強制規(guī)定某些行為不可以被實現(xiàn),就好比 scroll chaining 的行為。
不同的瀏覽器廠商私下里都遵從了 scroll chaining 的行為,而手冊中并沒有強制規(guī)定這種行為不應(yīng)該被實現(xiàn),自然這種行為也并不屬于不被允許。
解決思路
通過上邊的描述我們已經(jīng)了解了”滾動穿透“的原理:絕大多數(shù)瀏覽器廠商對于滾動,如果目標節(jié)點不能滾動則會嘗試觸發(fā)祖先節(jié)點的滾動,就比如上述第一種現(xiàn)象。而對于目標節(jié)點可以滾動時,當滾動到頂部/底部繼續(xù)進行滾動時,同樣會意外觸發(fā)祖先節(jié)點的滾動。
在移動端,我們完全可以使用一種通用的解決方案來解決上述造成“滾動穿透”意外行為:
無論元素是否可以滾動時,每次元素的拖拽事件觸發(fā)時我們只需要進行判斷:
-
尋找當前觸發(fā) touchMove事件event.target「距離事件綁定元素最近的(event.currentTarget)(包含)可滾動祖先元素?!?/strong>
之所以尋找 「event.target 元素至 event.currentTarget(包含)可滾動祖先元素」,是因為我們需要判斷本次滾動是否有效。
-
如果在上述的范圍內(nèi),祖先元素中不存在可滾動的元素,表示整個區(qū)域?qū)嶋H上是不可滾動的。那么不需要觸發(fā)任何父元素的意外滾動行為,直接進行 event.preventDefault()阻止默認。
-
如果在上述的范圍內(nèi),祖先元素中存在可滾動的元素: -
首先我們需要區(qū)域內(nèi)的元素可以正常滾動。 -
其次,如果該元素已經(jīng)滾動了頂部/底部,此時我們需要調(diào)用 event.preventDefault()阻止繼續(xù)相同方向滾動時的父元素意外滾動行為。
通用 Hook 方案
useTouch 拖動位置
首先,我們先來看一個有關(guān)于移動端滾動的簡單 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 定義了三個 start、move、reset 方法。
-
start方法中接受TouchEvent對象,同時調(diào)用reset清空delta、offset以及direction值。同時記錄事件對象發(fā)生時距離視口的距離clientX、clientY值作為初始值。 -
move方法中同樣接受TouchEvent對象作為入?yún)?,根?jù)TouchEvent上的位置屬性分別計算: -
deltaX、deltaY兩個值,表示移動時相較初始值的距離,不同方向可為負數(shù)。 -
offsetX、offsetY分別表示移動時相較初始值 X 方向和 Y 方向的絕對距離。 -
direction則是通過offsetX、offsetY相較計算出移動的方向。 -
reset方法則是對于上述提到的變量進行一次統(tǒng)一的清空重制。
通過 useTouch 這個 hook 我們可以在移動端配合 touchstart、onTouchMove 輕松的計算出手指拖動時的方向和距離。
getScrollParent 尋找區(qū)域內(nèi)可滾動祖先元素
// 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 方法本質(zhì)上從 el(event.target) 到 root(event.currentTarget) 范圍內(nèi)尋找最近的滾動祖先元素。
代碼同樣也并不是特別難理解,在 while 循環(huán)中從傳入的第一個參數(shù) 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 內(nèi)容時,此時控制臺會打印 getScrollParent 從 event.target (也就是 This is child-2 元素開始)尋找到的類名為 .parent 區(qū)域內(nèi)的最近滾動元素 .child-1 元素。
useScrollLock 通用解決方案
上邊我們了解了一個基礎(chǔ)的 useTouch 關(guān)于拖拽位置計算的 hook 以及 getScrollParent 獲取區(qū)域內(nèi)最近的可滾動祖先元素的方法,接下來我們就來看看在移動端中關(guān)于阻止 scroll chaining 意外滾動行為的通用 hook。
這里,我直接貼一段 ant-design-mobile 中的實現(xiàn)代碼,(實際這是 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 變?yōu)?'10',否則則會 `01`
const direction = touch.deltaY.current > 0 ? '10' : '01'
// 我們在上邊提到過,找到范圍內(nèi)可滾動的元素
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 變?yōu)?00,
// 否則表示當前元素可滾動但滾動條在頂部,將status變?yōu)?01
status = offsetHeight >= scrollHeight ? '00' : '01'
} else if (Math.abs(scrollHeight - clientHeight - scrollTop) < 1) {
// 滾動條已經(jīng)到達底部(表示已經(jīng)滾動到底),將 status 變?yōu)?'10'
status = '10'
}
// 1. 完成上述的判斷后,如果 status === 11 表示當前元素可滾動并且滾動條不在頂部也不在底部(即在中間),表示 touchMove 事件不應(yīng)該阻止元素滾動(當前滾動為正?,F(xiàn)象)
// 2. 同時 touch.isVertical() 明確確保是垂直方向的拖動
// 3. parseInt(status, 2),當 status 不為 11 時,分為以下三種情況分別代表:
// 3.1 status 00 表示區(qū)域內(nèi)未尋找到任何可滾動元素
// 3.2 status 01 表示尋找到可滾動元素,當前元素為滾動條在頂部
// 3.3 status 10 表示尋找到可滾動元素,當前元素滾動條在底部
// 自然 parseInt(status, 2) & parseInt(direction, 2) 這里使用了二進制的方式,
// 3.4 當 status 為 00 時, 0 & 任何數(shù)都是 0.自然 !(parseInt(status, 2) & parseInt(direction, 2)) 會變?yōu)?true (對應(yīng) 3.1 情況),需要阻止意外的滾動行為。
// 3.5 當 status 為 01 時(對應(yīng) 3.2 滾動條在頂部),此時當用戶從下往上拖動時,需要阻止意外的滾動行為發(fā)生。否則,則不需要阻止正常滾動。自然 status === '01' ,direction === 10(從下往上拖動),!(parseInt(status, 2) & parseInt(direction, 2)) 為 true 需要進行阻止默認滾動行為。(進制上 1 & 1 為 1 ,1 & 2 為 0)
// 3.6 根據(jù) 3.5 的情況,當 status 為 10 (對應(yīng) 3.3)滾動到達底部,自然對于從上往下拖動時 direction 為 01 時也應(yīng)該阻止,所以 (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 事件監(jiān)聽
* 2. 根據(jù) 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++
}
/**
* 組件銷毀時移除事件監(jiān)聽方法,以及清空 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])
}
我在上述代碼片段中每一行都進行了詳細的注釋,認真看這段代碼相信大家不難看懂。上述的代碼仍然是按照我們在文章開頭講述的解決思路來解決移動端滾動鏈接的意外行為。
關(guān)于上邊代碼中有幾個小 Tips ,這里和大家稍微贅述下:
-
關(guān)于 shouldLock === 'strict'這種情況antd源碼中標明是對于 IOS12 清空的兼容,如果這段代碼混淆了你的思路完全可以忽略它,因為它并不是我們主要想贅述的內(nèi)容。 -
addEventListener第三個參數(shù){ passive: false },在 safari 以外的瀏覽器默認為 true ,它會導致部分事件函數(shù)中preventDefault()無效,所謂的passive在 chrome51 版本后出現(xiàn)的,本質(zhì)上是為了通過被動偵聽器提高滾動性能。詳情可以查看 MDN 的解釋,這里我就不在贅述了。 -
BODY_LOCK_CLASS的實際樣式其實就是overflow:hidden,之所以通過totalLockCount計數(shù)的方式添加,沒什么特別的。想象一下,如果你的頁面中每個 Modal 彈窗都使用了useLockScroll這個 hook ,那么當頁面中開啟兩個彈窗,當關(guān)閉一個時另一個還存在時總不能移除了BODY_LOCK_CLASS吧。 -
為 body添加overflow:hidden其實在移動端并沒什么太大的實際作用,我們touchmove事件中的處理邏輯對于阻止意外滾動行為的發(fā)生已經(jīng)完全足夠了。這點最初我也不太明白為什么這么做,所以我也去 vant 中進行了請教,詳見 vant Discussions。實際上源碼中并不是使用 Math.abs(scrollHeight - clientHeight - scrollTop) < 1判斷滾動條是否到達底部,而是使用scrollTop + offsetHeight >= scrollHeight顯然這是不準確的可能會導致 Bug(因為scrollTop是一個非四舍五入的數(shù)字(可以為小數(shù)),而scrollHeight和clientHeight是四舍五入的數(shù)字)所以極端場景下會導致不準確,我就遇到過,有興趣了解的朋友詳見我對于 antd-mobile 的 PR。
結(jié)語
文章到這里就和大家說聲再見了,剛好前段時間在公司內(nèi)編寫移動端組件時遇到過這個問題所以拿出來和大家分享。
當然,如果大家對于文章中的內(nèi)容有什么疑惑或者有更好的解決方案。你可以在評論區(qū)留下你的看法,我們可以一起進行討論,謝謝大家。

