likes
comments
collection
share

如何封装一个hooks解决ClickAway

作者站长头像
站长
· 阅读数 2

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

前言

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

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

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

源码解读

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

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

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

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 进行判断

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

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

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版本大概如下所示:

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,也不需要考虑别的。这就是最简单的实现啦。

效果预览:

如何封装一个hooks解决ClickAway

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,我们可以很方便地实现这样的功能,提高用户体验。

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