【解读 ahooks 源码系列】 (开篇)如何获取和监听 DOM 元素

数字探秘者
• 阅读 1736

前言

由于在工作中自定义 Hook 场景写的较多,当实现某个通用场景功能时,可能没想过有已实现好的 Hook 封装或者压根没想去从 Hooks 库里面找,但是社区好的实现使用起来是可以提高开发效率和减少 bug 率的。

公司项目中有依赖库 ahooks,但我用的次数不多,于是有了想详细了解 ahooks 的打算,更主要是为了更加熟练抽离与实现一些场景 Hook,学习如何更好的自定义 Hook,便有开始阅读 ahooks 源码的打算了。

学习 ahooks 源码的好处

在我看来,学习 ahooks 常见 Hooks 封装有以下好处:

  • 熟悉如何根据需求去提炼相应的 Hooks,将通用逻辑进行封装
  • 讲解源码实现思路,提炼核心实现,通过学习源码学习自定义 Hooks 最佳实践
  • 深入学习特定的场景 Hooks,项目开发中一点通,使用时更得心应手

关于源码系列

本系列文章基于 ahooks 版本 v3.7.4,后续会相继输出 ahooks 源码解读的系列文章。

按照 ahooks 官网的分类,我目前先从 DOM 篇开始看起,DOM 篇包括的 Hooks 如下:

【解读 ahooks 源码系列】 (开篇)如何获取和监听 DOM 元素

  • 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 元素

【解读 ahooks 源码系列】 (开篇)如何获取和监听 DOM 元素

参考文章

点赞
收藏
评论区
推荐文章
blmius blmius
3年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
Wesley13 Wesley13
3年前
MySQL部分从库上面因为大量的临时表tmp_table造成慢查询
背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_
美凌格栋栋酱 美凌格栋栋酱
6个月前
Oracle 分组与拼接字符串同时使用
SELECTT.,ROWNUMIDFROM(SELECTT.EMPLID,T.NAME,T.BU,T.REALDEPART,T.FORMATDATE,SUM(T.S0)S0,MAX(UPDATETIME)CREATETIME,LISTAGG(TOCHAR(
皕杰报表之UUID
​在我们用皕杰报表工具设计填报报表时,如何在新增行里自动增加id呢?能新增整数排序id吗?目前可以在新增行里自动增加id,但只能用uuid函数增加UUID编码,不能新增整数排序id。uuid函数说明:获取一个UUID,可以在填报表中用来创建数据ID语法:uuid()或uuid(sep)参数说明:sep布尔值,生成的uuid中是否包含分隔符'',缺省为
Easter79 Easter79
3年前
sql注入
反引号是个比较特别的字符,下面记录下怎么利用0x00SQL注入反引号可利用在分隔符及注释作用,不过使用范围只于表名、数据库名、字段名、起别名这些场景,下面具体说下1)表名payload:select\from\users\whereuser\_id1limit0,1;!(https://o
Peter20 Peter20
4年前
mysql中like用法
like的通配符有两种%(百分号):代表零个、一个或者多个字符。\(下划线):代表一个数字或者字符。1\.name以"李"开头wherenamelike'李%'2\.name中包含"云",“云”可以在任何位置wherenamelike'%云%'3\.第二个和第三个字符是0的值wheresalarylike'\00%'4\
Wesley13 Wesley13
3年前
FLV文件格式
1.        FLV文件对齐方式FLV文件以大端对齐方式存放多字节整型。如存放数字无符号16位的数字300(0x012C),那么在FLV文件中存放的顺序是:|0x01|0x2C|。如果是无符号32位数字300(0x0000012C),那么在FLV文件中的存放顺序是:|0x00|0x00|0x00|0x01|0x2C。2.  
Easter79 Easter79
3年前
Twitter的分布式自增ID算法snowflake (Java版)
概述分布式系统中,有一些需要使用全局唯一ID的场景,这种时候为了防止ID冲突可以使用36位的UUID,但是UUID有一些缺点,首先他相对比较长,另外UUID一般是无序的。有些时候我们希望能使用一种简单一些的ID,并且希望ID能够按照时间有序生成。而twitter的snowflake解决了这种需求,最初Twitter把存储系统从MySQL迁移
Wesley13 Wesley13
3年前
mysql设置时区
mysql设置时区mysql\_query("SETtime\_zone'8:00'")ordie('时区设置失败,请联系管理员!');中国在东8区所以加8方法二:selectcount(user\_id)asdevice,CONVERT\_TZ(FROM\_UNIXTIME(reg\_time),'08:00','0
Wesley13 Wesley13
3年前
PHP创建多级树型结构
<!lang:php<?php$areaarray(array('id'1,'pid'0,'name''中国'),array('id'5,'pid'0,'name''美国'),array('id'2,'pid'1,'name''吉林'),array('id'4,'pid'2,'n
Wesley13 Wesley13
3年前
Java日期时间API系列36
  十二时辰,古代劳动人民把一昼夜划分成十二个时段,每一个时段叫一个时辰。二十四小时和十二时辰对照表:时辰时间24时制子时深夜11:00凌晨01:0023:0001:00丑时上午01:00上午03:0001:0003:00寅时上午03:00上午0
数字探秘者
数字探秘者
Lv1
剑花寒,夜坐归心壮,又是他乡。
文章
4
粉丝
0
获赞
0