前言
由於在工作中自定義 Hook 場景寫的較多,當實現某個通用場景功能時,可能沒想過有已實現好的 Hook 封裝或者壓根沒想去從 Hooks 庫裏面找,但是社區好的實現使用起來是可以提高開發效率和減少 bug 率的。
公司項目中有依賴庫 ahooks,但我用的次數不多,於是有了想詳細瞭解 ahooks 的打算,更主要是為了更加熟練抽離與實現一些場景 Hook,學習如何更好的自定義 Hook,便有開始閲讀 ahooks 源碼的打算了。
學習 ahooks 源碼的好處
在我看來,學習 ahooks 常見 Hooks 封裝有以下好處:
- 熟悉如何根據需求去提煉相應的 Hooks,將通用邏輯進行封裝
- 講解源碼實現思路,提煉核心實現,通過學習源碼學習自定義 Hooks 最佳實踐
- 深入學習特定的場景 Hooks,項目開發中一點通,使用時更得心應手
關於源碼系列
本系列文章基於 ahooks 版本 v3.7.4,後續會相繼輸出 ahooks 源碼解讀的系列文章。
按照 ahooks 官網的分類,我目前先從 DOM 篇開始看起,DOM 篇包括的 Hooks 如下:
- 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.MutableRefObject、HTMLElement、() => HTMLElement。
- React.MutableRefObject
export default () => {
const ref = useRef(null)
const isHovering = useHover(ref)
return <div ref={ref}>{isHovering ? 'hover' : 'leaveHover'}</div>
}
- HTMLElement
export default () => {
const isHovering = useHover(document.getElementById('test'))
return <div id="test">{isHovering ? 'hover' : 'leaveHover'}</div>
}
- 支持 () => HTMLElement,一般適用在 SSR 場景
export default () => {
const isHovering = useHover(() => document.getElementById('test'))
return <div id="test">{isHovering ? 'hover' : 'leaveHover'}</div>
}
getTargetElement
為了兼容以上三種類型入參,ahooks 封裝了 getTargetElement - 獲取目標 DOM 元素 方法。我們來看看代碼做了什麼:
- 判斷是否為瀏覽器環境,不是則返回
undefined - 判斷目標元素是否為空,為空則返回函數參數指定的默認元素
-
核心:
- 如果是函數,則返回函數執行後的結果
- 如果有
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 內部則封裝 useEffectWithTarget(packages/hooks/src/utils/useEffectWithTarget.ts),來看這個文件的代碼:
import { useEffect } from 'react'
import createEffectWithTarget from './createEffectWithTarget'
const useEffectWithTarget = createEffectWithTarget(useEffect)
export default useEffectWithTarget
看到它實際用了 createEffectWithTarget方法,傳入的參數是 useEffect(packages/hooks/src/utils/createEffectWithTarget.ts)
- createEffectWithTarget 接受參數 useEffect 或 useLayoutEffect,返回 useEffectWithTarget 函數
- useEffectWithTarget 函數接收三個參數:前兩個參數是 effect 和 deps(與 useEffect 參數一致),第三個參數則兼容了 DOM 元素的三種類型,可傳 普通 DOM/ref 類型/函數類型
useEffectWithTarget 實現思路:
- 使用 useEffect/useLayoutEffect 監聽,內部不傳第二個參數依賴項,每次更新都會執行該副作用函數
- 通過 hasInitRef 判斷是否是第一次執行,是則初始化:記錄最後一次目標元素列表和依賴項,執行 effect 函數
- 由於該 useEffectType 函數體每次更新都會執行,所以每次都拿到最新的 targets 和 deps,所以後續執行可與第 2 點記錄的最後一次的ref值進行比對
- 非首次執行:則判斷元素列表長度或目標元素或者依賴發生變化,變化了則執行更新流程:執行上一次返回的卸載函數,更新最新值,重新執行 effect
- 組件卸載:執行 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 元素
參考文章
- ahooks 是怎麼處理 DOM 的?