前言

因为在工作中自定义 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.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 | nulltype TargetType = HTMLElement | Element | Window | Documentexport 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 元素

参考文章

  • ahooks 是怎么解决 DOM 的?