『手写ahooks系列-useClickAway』一个hook解决监听点击元素外部

最近在学习ahooks的源码,打算写些文章记录并分享,讲解ahooks中的一些方法是如何实现的,并实现一个mini版本。如果你对该文章或者系列有任何建议,欢迎打出来我们一起探讨。

前言

我们日常开发中,有很多时候需要监听用户是否点击到了某个元素外面。

useClickAway 可以帮助我们在点击元素外部时触发回调函数,比如点击Modal/Dialog/Popover 外部就关闭弹窗等。

在本篇文章中,我们将通过注释逐行讲解 ahooks 中的 useClickAway 方法的源码。

源码解读

useClickAway 的原理也并不复杂,其实就是监听整个文档的点击事件,判断点击的元素是否在目标元素内部,如果不在则执行回调函数

那么如何把上述行为封装到一个hooks里的呢?

我们来看一下官方示例中的基础用法

ts 复制代码
import React, { useState, useRef } from 'react';
import { useClickAway } from 'ahooks';

export default () => {
  const [counter, setCounter] = useState(0);
  const ref = useRef<HTMLButtonElement>(null);
  useClickAway(() => {
    setCounter((s) => s + 1);
  }, ref);

  return (
    <div>
      <button ref={ref} type="button">
        box
      </button>
      <p>counter: {counter}</p>
    </div>
  );
};

那么我们已经可以大致推测 useClickAway 是如何实现的

  1. 监听整个文档的点击事件 :通过传入的 refuseClickAway 可以获取到目标元素的引用。然后在 useEffect 监听document的点击事件。
  2. 判断点击的元素是否在目标元素内部 :当点击事件触发时,判断点击的元素是否在目标元素内部。如果不在,则执行传入的回调函数。一般情况下我们会利用 element.contains 进行判断

至于具体是怎么实现的,我们来看下真正的源码。

下面,是整体源码的解读,如果你想看简单的实现,也可以直接跳到动手实现的部分:

ts 复制代码
import useLatest from '../useLatest';
import type { BasicTarget } from '../utils/domTarget';
import { getTargetElement } from '../utils/domTarget';  // 获取目标元素的实际 DOM 元素
import getDocumentOrShadow from '../utils/getDocumentOrShadow';
import useEffectWithTarget from '../utils/useEffectWithTarget';

type DocumentEventKey = keyof DocumentEventMap;

export default function useClickAway<T extends Event = Event>(
  onClickAway: (event: T) => void,  // 点击外部区域时触发的回调函数
  target: BasicTarget | BasicTarget[],  // 可以指定一个或多个元素作为点击对象
  eventName: DocumentEventKey | DocumentEventKey[] = 'click',  // 要监听的事件类型,默认为 'click'
) {
  // 使用 useLatest 创建一个持续更新的引用
  const onClickAwayRef = useLatest(onClickAway);

  // 使用 useEffectWithTarget 处理事件监听的添加和移除
  useEffectWithTarget(
    () => {
      // 创建事件处理函数
      const handler = (event: any) => {
        // 确保target是个数组类型
        const targets = Array.isArray(target) ? target : [target];
        // 检查点击事件的目标是否在指定的 target 内部
        if (
          targets.some((item) => {
            const targetElement = getTargetElement(item);
            return !targetElement || targetElement.contains(event.target);
          })
        ) {
          return;
        }
        // 不在指定的 target 内部,则触发 onClickAway 回调
        onClickAwayRef.current(event);
      };

      // 获取正确的 document 或 shadowRoot 对象
      const documentOrShadow = getDocumentOrShadow(target);

      // 将 eventName 也给转为数组形式
      const eventNames = Array.isArray(eventName) ? eventName : [eventName];

      // 遍历事件名称数组,添加事件监听器
      eventNames.forEach((event) => documentOrShadow.addEventListener(event, handler));

      // 组件卸载时移除事件监听器
      return () => {
        eventNames.forEach((event) => documentOrShadow.removeEventListener(event, handler));
      };
    },
    // 依赖项数组,当 eventName 或 target 发生变化时重新执行 Effect Hook
    Array.isArray(eventName) ? eventName : [eventName],
    target,
  );
}

我们可以看到实现的方式和我们之前说过的也很接近,但ahooks做了更多的边界的考虑,比如你可以传入一个元素数组作为targets,再比如除了document之外他们也还考虑到了shadowRoot的情况,甚至你也可以自定义dom事件类型,并不是非要用 click 事件!

至于useLatest,你可以通过下面的场景来理解:

当一个组件需要在用户输入时进行一些操作,但又不想在每次输入时重新渲染整个组件时,就可以使用useLatest来保存input时的回调函数。这样,在回调函数中既可以访问到最新的状态和属性值,又不需要重新渲染整个组件。

当然,它也并不影响你理解整个useClickAway的流程。以后我们也会出文章讲解下useLatest,其实也很简单,源码不到10行。

动手实现

在大概了解了 useClickAway 的源码之后,我们也可以尝试着自己不引入任何依赖,手动去实现一个mini版本。

整理一下思路和先前说过的一致:

  1. 监听整个文档的点击事件,并在点击时判断点击的元素是否在目标元素内部
  2. 如果不在目标元素内部,则执行传入的回调函数

实现的mini版本大概如下所示:

ts 复制代码
import { RefObject, useEffect } from 'react';

export function useClickAway(onClickAway: (event: MouseEvent) => void, targetRef: RefObject<HTMLElement | null>) {
  useEffect(() => {
    const handler = (event: MouseEvent) => {
      if (!targetRef.current || targetRef.current.contains(event.target as Node)) {
        return;
      }
      onClickAway(event);
    };
    document.addEventListener('click', handler);
    return () => {
      document.removeEventListener('click', handler);
    };
  }, [onClickAway, targetRef]);
}

我们不需要去考虑数组,也不需要去考虑ShadowRoot,也不需要考虑别的。这就是最简单的实现啦。

效果预览:

tsx 复制代码
import { useClickAway } from '@/hooks/useClickAway'
import { useRef, useState } from 'react'

const ClickAway = () => {
  const ref = useRef(null)
  const [count, setCount] = useState(0)
  useClickAway(() => {
    setCount(count + 1)
  }, ref)

  return (
    <div>
      <div ref={ref} className="p-20px bg-gray-200">
        Click outside count: {count}
      </div>
    </div>
  )
}

export default ClickAway

总结

通过分析源码和示例,我们了解了 useClickAway 的工作原理,以及如何利用它来处理点击元素外部的事件。

在源码解读部分,我们逐步分析了 useClickAway 方法的实现细节,包括监听整个文档的点击事件、判断点击的元素是否在目标元素内部等。此外,我们还尝试手动实现了一个简化版的 useClickAway 功能,通过自定义 Hook 来实现相似的功能。

点击外部关闭弹窗这种需求在实际开发中经常会遇到,通过 useClickAway 这个自定义 Hook,我们可以很方便地实现这样的功能,提高用户体验。

如果这篇文章对你有帮助的话,还请你不吝小手点一个免费的赞,这会给我很大的鼓励,谢谢你!

相关推荐
四岁半儿25 分钟前
常用css
前端·css
你的人类朋友1 小时前
说说git的变基
前端·git·后端
姑苏洛言1 小时前
网页作品惊艳亮相!这个浪浪山小妖怪网站太治愈了!
前端
字节逆旅1 小时前
nvm 安装pnpm的异常解决
前端·npm
Jerry2 小时前
Compose 从 View 系统迁移
前端
IT码农-爱吃辣条2 小时前
Three.js 初级教程大全
开发语言·javascript·three.js
GIS之路2 小时前
2025年 两院院士 增选有效候选人名单公布
前端
四岁半儿2 小时前
vue,H5车牌弹框定制键盘包括新能源车牌
前端·vue.js
烛阴2 小时前
告别繁琐的类型注解:TypeScript 类型推断完全指南
前端·javascript·typescript
gnip2 小时前
工程项目中.env 文件原理
前端·javascript