博客 / 詳情

返回

【解讀 ahooks 源碼系列】 (開篇)如何獲取和監聽 DOM 元素

前言

由於在工作中自定義 Hook 場景寫的較多,當實現某個通用場景功能時,可能沒想過有已實現好的 Hook 封裝或者壓根沒想去從 Hooks 庫裏面找,但是社區好的實現使用起來是可以提高開發效率和減少 bug 率的。

公司項目中有依賴庫 ahooks,但我用的次數不多,於是有了想詳細瞭解 ahooks 的打算,更主要是為了更加熟練抽離與實現一些場景 Hook,學習如何更好的自定義 Hook,便有開始閲讀 ahooks 源碼的打算了。

學習 ahooks 源碼的好處

在我看來,學習 ahooks 常見 Hooks 封裝有以下好處:

  • 熟悉如何根據需求去提煉相應的 Hooks,將通用邏輯進行封裝
  • 講解源碼實現思路,提煉核心實現,通過學習源碼學習自定義 Hooks 最佳實踐
  • 深入學習特定的場景 Hooks,項目開發中一點通,使用時更得心應手

關於源碼系列

本系列文章基於 ahooks 版本 v3.7.4,後續會相繼輸出 ahooks 源碼解讀的系列文章。

按照 ahooks 官網的分類,我目前先從 DOM 篇開始看起,DOM 篇包括的 Hooks 如下:

image.png

  • useEventListener:優雅的使用 addEventListener。
  • useClickAway:監聽目標元素外的點擊事件。
  • useDocumentVisibility:優雅的使用 addEventListener。
  • useDrop & useDrag:處理元素拖拽的 Hook。
  • useEventTarget:常見表單控件(通過 e.target.value 獲取表單值) 的 onChange 跟 value 邏輯封裝,支持自定義值轉換和重置功能。
  • useExternal:動態注入 JS 或 CSS 資源,useExternal 可以保證資源全局唯一。
  • useTitle:用於設置頁面標題。
  • useFavicon:設置頁面的 favicon。
  • useFullscreen:管理 DOM 全屏的 Hook。
  • useHover:監聽 DOM 元素是否有鼠標懸停。
  • useMutationObserver:一個監聽指定的 DOM 樹發生變化的 Hook。
  • useInViewport:觀察元素是否在可見區域,以及元素可見比例。
  • useKeyPress:監聽鍵盤按鍵,支持組合鍵,支持按鍵別名。
  • useLongPress:監聽目標元素的長按事件。
  • useMouse:監聽鼠標位置。
  • useResponsive:獲取響應式信息。
  • useScroll:監聽元素的滾動位置。
  • useSize:監聽 DOM 節點尺寸變化的 Hook。
  • useFocusWithin:監聽當前焦點是否在某個區域之內,同 css 屬性: focus-within。

由於內容較多,DOM 篇會分成幾篇文章輸出,這樣每篇讀起來既不太耗時也能快速過一遍。文章會在解讀源碼的基礎上,也會把涉及到的 JS 基礎知識拎出來,在學源碼的過程也能查漏補缺基礎。

回到本文正題,在看 DOM 篇分類下的 Hooks 時,我發現 getTargetElement 方法和 useEffectWithTarget 內部 Hook 使用較多,所以在講源碼之前先來了解這兩個 Hook。

如何獲取 DOM 元素

三種類型的 target

在 DOM 類 Hooks 使用規範中提到:

ahooks 大部分 DOM 類 Hooks 都會接收 target 參數,表示要處理的元素。

target 支持三種類型 React.MutableRefObjectHTMLElement() => HTMLElement

  1. React.MutableRefObject
export default () => {
  const ref = useRef(null)
  const isHovering = useHover(ref)
  return <div ref={ref}>{isHovering ? 'hover' : 'leaveHover'}</div>
}
  1. HTMLElement
export default () => {
  const isHovering = useHover(document.getElementById('test'))
  return <div id="test">{isHovering ? 'hover' : 'leaveHover'}</div>
}
  1. 支持 () => HTMLElement,一般適用在 SSR 場景
export default () => {
  const isHovering = useHover(() => document.getElementById('test'))
  return <div id="test">{isHovering ? 'hover' : 'leaveHover'}</div>
}

getTargetElement

為了兼容以上三種類型入參,ahooks 封裝了 getTargetElement - 獲取目標 DOM 元素 方法。我們來看看代碼做了什麼:

  1. 判斷是否為瀏覽器環境,不是則返回 undefined
  2. 判斷目標元素是否為空,為空則返回函數參數指定的默認元素
  3. 核心:

    • 如果是函數,則返回函數執行後的結果
    • 如果有 current 屬性,則返回 .current屬性的值,兼容 React.MutableRefObject 類型
    • 以上都不是,則代表普通 DOM 元素,直接返回
export function getTargetElement<T extends TargetType>(target: BasicTarget<T>, defaultElement?: T) {
  // 判斷是否為瀏覽器環境
  if (!isBrowser) {
    return undefined;
  }

  // 目標元素為空則返回函數參數指定的默認元素
  if (!target) {
    return defaultElement;
  }

  let targetElement: TargetValue<T>;

  // 支持函數執行返回
  if (isFunction(target)) {
    targetElement = target();
  } else if ('current' in target) {
    // 兼容 React.MutableRefObject 類型,返回 .current 屬性的值
    targetElement = target.current;
  } else {
    // 普通 DOM 元素
    targetElement = target;
  }

  return targetElement;
}

對應的 TS 類型:

type TargetValue<T> = T | undefined | null

type TargetType = HTMLElement | Element | Window | Document

export type BasicTarget<T extends TargetType = Element> =
  | (() => TargetValue<T>)
  | TargetValue<T>
  | MutableRefObject<TargetValue<T>>

監聽 DOM 元素

target 支持動態變化

ahooks 的 DOM 類 Hooks 使用規範第二條點指出:

DOM 類 Hooks 的 target 是支持動態變化的,如下:

export default () => {
  const [boolean, { toggle }] = useBoolean()

  const ref = useRef(null)
  const ref2 = useRef(null)

  const isHovering = useHover(boolean ? ref : ref2)
  return (
    <>
      <div ref={ref}>{isHovering ? 'hover' : 'leaveHover'}</div>
      <div ref={ref2}>{isHovering ? 'hover' : 'leaveHover'}</div>
    </>
  )
}

useEffectWithTarget

為了滿足上述條件, ahooks 內部則封裝 useEffectWithTargetpackages/hooks/src/utils/useEffectWithTarget.ts),來看這個文件的代碼:

import { useEffect } from 'react'
import createEffectWithTarget from './createEffectWithTarget'

const useEffectWithTarget = createEffectWithTarget(useEffect)

export default useEffectWithTarget

看到它實際用了 createEffectWithTarget方法,傳入的參數是 useEffectpackages/hooks/src/utils/createEffectWithTarget.ts

  • createEffectWithTarget 接受參數 useEffect 或 useLayoutEffect,返回 useEffectWithTarget 函數
  • useEffectWithTarget 函數接收三個參數:前兩個參數是 effect 和 deps(與 useEffect 參數一致),第三個參數則兼容了 DOM 元素的三種類型,可傳 普通 DOM/ref 類型/函數類型

useEffectWithTarget 實現思路:

  1. 使用 useEffect/useLayoutEffect 監聽,內部不傳第二個參數依賴項,每次更新都會執行該副作用函數
  2. 通過 hasInitRef 判斷是否是第一次執行,是則初始化:記錄最後一次目標元素列表和依賴項,執行 effect 函數
  3. 由於該 useEffectType 函數體每次更新都會執行,所以每次都拿到最新的 targets 和 deps,所以後續執行可與第 2 點記錄的最後一次的ref值進行比對
  4. 非首次執行:則判斷元素列表長度或目標元素或者依賴發生變化,變化了則執行更新流程:執行上一次返回的卸載函數,更新最新值,重新執行 effect
  5. 組件卸載:執行 unLoadRef.current?.() 卸載函數,重置 hasInitRef
const createEffectWithTarget = (
  useEffectType: typeof useEffect | typeof useLayoutEffect,
) => {
  /**
   *
   * @param effect
   * @param deps
   * @param target target should compare ref.current vs ref.current, dom vs dom, ()=>dom vs ()=>dom
   */
  const useEffectWithTarget = (
    effect: EffectCallback,
    deps: DependencyList,
    target: BasicTarget<any> | BasicTarget<any>[],
  ) => {
    // 判斷是否已初始化
    const hasInitRef = useRef(false)

    const lastElementRef = useRef<(Element | null)[]>([]) // 最後一次
    const lastDepsRef = useRef<DependencyList>([])

    const unLoadRef = useRef<any>()

    // useEffectType:代表 useEffect 或 useLayoutEffect,每次更新都會執行該函數
    useEffectType(() => {
      const targets = Array.isArray(target) ? target : [target]
      const els = targets.map((item) => getTargetElement(item)) // 獲取 DOM 元素列表

      // 首次執行:初始化
      if (!hasInitRef.current) {
        hasInitRef.current = true
        lastElementRef.current = els // 最後一次執行的相應的 target 元素
        lastDepsRef.current = deps // 最後一次執行的相應的依賴

        unLoadRef.current = effect() // 執行外部傳入的 effect 函數,返回卸載函數
        return
      }

      // 非首次執行:判斷元素列表長度或目標元素或者依賴發生變化
      if (
        els.length !== lastElementRef.current.length ||
        !depsAreSame(els, lastElementRef.current) ||
        !depsAreSame(deps, lastDepsRef.current)
      ) {
        // 依賴發生變更了,相當於走 useEffect 更新流程
        unLoadRef.current?.()
        lastElementRef.current = els
        lastDepsRef.current = deps
        unLoadRef.current = effect() // 再次執行 effect,賦值卸載函數給 unLoadRef
      }
    }) // 沒有傳第二個參數,則每次都會執行

    // 卸載操作 Hook
    useUnmount(() => {
      unLoadRef.current?.() // 執行卸載操作
      // for react-refresh
      hasInitRef.current = false
    })
  }

  return useEffectWithTarget
}

depsAreSame 實現:

import type { DependencyList } from 'react'

export default function depsAreSame(
  oldDeps: DependencyList,
  deps: DependencyList,
): boolean {
  if (oldDeps === deps) return true // 淺比較
  for (let i = 0; i < oldDeps.length; i++) {
    if (!Object.is(oldDeps[i], deps[i])) return false
  }
  return true
}

這樣使用起來跟 useEffect 的區別就是有第三個參數——監聽的 DOM 元素

image.png

參考文章

  • ahooks 是怎麼處理 DOM 的?
user avatar lanlanjintianhenhappy 頭像 musicfe 頭像 joytime 頭像 yiiouo 頭像 hachimei 頭像 ipromise 頭像 lawler61 頭像 niaonao 頭像 wuyuedexingkong 頭像 kandole 頭像 y_lucky 頭像 xiaodaigua_ray 頭像
15 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.