天天看點

從 antDesign 來窺探移動端“滾動穿透”行為

作者:愛摸魚的程式員

引言

相信大多數前端開發者在日常工作中都碰過元素滾動時造成的一些非預期行為。

這篇文章就和大家來聊聊那些滾動中的非預期行為的出現原理和解決方案。

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 等存在全屏覆寫的内容中,當我們拖動不可滾動的彈出層元素内容時,背後的背景元素會被意外滾動。

從 antDesign 來窺探移動端“滾動穿透”行為

比如上方圖檔中有兩個元素,一個為紅色邊框存在滾動條的父元素,另一個則為藍色邊框黑色背景不存在滾動條的子元素。

當我們拖動不可滾動的子元素時,實際會意外造成父元素會跟随滾動。

  • 将可滾動元素拖動至頂部或者底部時,繼續拖動觸發最近可滾動祖先元素的滾動。

還有另一種常見場景,我們在某個可滾動元素上進行拖動時,當該元素的滾動條已經到達頂部/底部。繼續沿着相同方向進行拖動,此時浏覽器會尋找目前元素最近的可滾動祖先元素進而意外觸發祖先元素的滾動。

從 antDesign 來窺探移動端“滾動穿透”行為

同樣,動畫中紅色邊框為擁有滾動區域的父元素,藍色邊框為父元素中同樣擁有滾動區域的子元素。我們在子元素區域内進行拖拽時,當子元素滾動到底部(頂部)時,仍然繼續往下(上)進行拖動。

原理

上述兩種情況相信大家也日常業務開發中碰到過不少次。這樣的滾動意外行為用專業術語來說,被稱為滾動連結(Scroll Chaining)。

那麼,它是如何産生的呢?或者換句話說,浏覽器哪條限制規定了這樣的行為?

仔細查閱 w3c 上的 scroll-event 并沒有明确的此項規定。

從 antDesign 來窺探移動端“滾動穿透”行為

手冊上僅僅明确了,滾動事件的 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. 如果在上述的範圍内,祖先元素中存在可滾動的元素: 首先我們需要區域内的元素可以正常滾動。 其次,如果該元素已經滾動了頂部/底部,此時我們需要調用 event.preventDefault() 阻止繼續相同方向滾動時的父元素意外滾動行為。

通用 Hook 方案

useTouch 拖動位置

首先,我們先來看一個有關于移動端滾動的簡單 Hook:

tsx複制代碼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 對象,同時調用 reset 清空 delta、offset 以及 direction 值。同時記錄事件對象發生時距離視口的距離 clientX、clientY 值作為初始值。
  • move 方法中同樣接受 TouchEvent 對象作為入參,根據 TouchEvent 上的位置屬性分别計算:
    • deltaX、deltaY 兩個值,表示移動時相較初始值的距離,不同方向可為負數。
    • offsetX、offsetY 分别表示移動時相較初始值 X 方向和 Y 方向的絕對距離。
    • direction 則是通過 offsetX、offsetY 相較計算出移動的方向。
  • reset 方法則是對于上述提到的變量進行一次統一的清空重制。

通過 useTouch 這個 hook 我們可以在移動端配合 touchstart、onTouchMove 輕松的計算出手指拖動時的方向和距離。

getScrollParent 尋找區域内可滾動祖先元素

tsx複制代碼// 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。

比如這樣的場景:

tsx複制代碼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 内容時,此時控制台會列印 getScrollParent 從 event.target (也就是 This is child-2 元素開始)尋找到的類名為 .parent 區域内的最近滾動元素 .child-1 元素。

useScrollLock 通用解決方案

上邊我們了解了一個基礎的 useTouch 關于拖拽位置計算的 hook 以及 getScrollParent 擷取區域内最近的可滾動祖先元素的方法,接下來我們就來看看在移動端中關于阻止 scroll chaining 意外滾動行為的通用 hook。

這裡,我直接貼一段 ant-design-mobile 中的實作代碼,(實際這是 ant-design-mobile 中從 vant 中搬運的代碼):

tsx複制代碼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。
  5. 實際上源碼中并不是使用 Math.abs(scrollHeight - clientHeight - scrollTop) < 1 判斷滾動條是否到達底部,而是使用 scrollTop + offsetHeight >= scrollHeight 顯然這是不準确的可能會導緻 Bug(因為 scrollTop 是一個非四舍五入的數字(可以為小數),而 scrollHeight 和 clientHeight 是四舍五入的數字)是以極端場景下會導緻不準确,我就遇到過,有興趣了解的朋友詳見我對于 antd-mobile 的 PR。

結語

文章到這裡就和大家說聲再見了,剛好前段時間在公司内編寫移動端元件時遇到過這個問題是以拿出來和大家分享。

當然,如果大家對于文章中的内容有什麼疑惑或者有更好的解決方案。你可以在評論區留下你的看法,我們可以一起進行讨論,謝謝大家。

作者:19組清風

連結:https://juejin.cn/post/7261493331188449341

繼續閱讀