React自定義hook之:useClickOutside——判斷是否點選DOM之外區域

不羈的風發表於2022-06-25
最近在開發業務需求的時候,有一個場景是點選彈窗之外的區域後,執行某些操作。比如我們常用的github左上角的搜尋框,當點選了搜尋框之外的區域以後,搜尋框就會自動取消搜尋並收縮起來。

github搜尋框

經過調研發現使用useRef+瀏覽器事件繫結可以實現這一需求,並且可以將這一功能抽象為自定義hook。

本文將首先介紹如何用傳統方式實現這一需求,然後介紹如何抽象成自定義hook,最後結合typescript型別,完善這一自定義hook。

實現檢測點選物件外區域

import React, { useEffect, useRef } from "react";

const Demo: React.FC = () => {
  // 使用useRef繫結DOM物件
  const domRef = useRef<HTMLDivElement>(null);

  // 元件初始化繫結點選事件
  useEffect(() => {
    const handleClickOutSide = (e: MouseEvent) => {
      // 判斷使用者點選的物件是否在DOM節點內部
      if (domRef.current?.contains(e.target as Node)) {
        console.log("點選了DOM裡面區域");
        return;
      }
      console.log("點選DOM外面區域");
    };
    document.addEventListener("mousedown", handleClickOutSide);
    return () => {
      document.removeEventListener("mousedown", handleClickOutSide);
    };
  }, []);

  return (
    <div
      ref={domRef}
      style={{
        height: 300,
        width: 300,
        background: "#bfa",
      }}
    ></div>
  );
};

export default Demo;

程式碼不難理解,首先我們在函式式元件裡面寫了一個長度和寬度都是300畫素的正方形,然後建立了一個名為domRef的物件將其繫結到dom節點上,最後在useEffect鉤子裡面宣告handleClickOutSide的方法判斷使用者是否點選了指定的DOM區域,並使用document.addEventListener方法新增事件監聽,元件解除安裝時清理事件監聽。
在實現的過程中,最核心的是利用了Ref物件上的contains方法,經過研究發現,Node.contains方法是瀏覽器的原生方法,其主要的作用是判斷傳入的DOM節點是否為該節點的後代節點。

使用基本方式實現後,接著封裝自定義hook。

封裝useClickOutside hook

大家在理解自定義hook時不用心生畏懼,無非就是呼叫了其他hook的普通函式,下面來看程式碼實現:

import { RefObject, useEffect } from "react";
const useClickOutside = (ref: RefObject<HTMLElement>, handler: Function) => {
  useEffect(() => {
    const listener = (event: MouseEvent) => {
      if (!ref.current || ref.current.contains(event.target as HTMLElement)) {
        return;
      }
      handler(event);
    };
    document.addEventListener("click", listener);
    return () => {
      document.removeEventListener("click", listener);
    };
  }, [ref, handler]);
};

export default useClickOutside;

可以看到,程式碼將判斷是否點選了DOM之外區域的邏輯都抽離出來,這樣在使用時,只需要把DOM節點傳遞給useClickOutside即可,自定義hook的第二個引數接收一個回撥函式,可以在回撥函式裡面做各種事情。來看一下使用自定義hook後的程式碼:

import React, { useRef } from "react";
import useClickOutside from "../hooks/useOnClickOutside";

const Demo: React.FC = () => {
  // 使用useRef繫結DOM物件
  const domRef = useRef<HTMLDivElement>(null);

  useClickOutside(domRef, () => {
    console.log("點選了外部區域");
  });

  return (
    <div
      ref={domRef}
      style={{
        height: 300,
        width: 300,
        background: "#bfa",
      }}
    ></div>
  );
};

export default Demo;

可以看到,元件的程式碼得到大大的簡化,而且抽離出來的自定義hook可以在其他元件中複用。但到這一步有優化的空間嗎?當然有的,那就是在型別定義方面,可以使用泛型進行優化。
在剛才編寫的useClickOutside自定義hook中,對ref物件的定義是:RefObject<HTMLElement>,這種定義其實是不合理的,因為HTMLElement不夠具體,有可能傳入的是HTMLDivElement或者是HTMLAnchorElement,也有可能是HTMLSpanElement,這裡我們可以使用泛型對其加以限制。

使用泛型優化型別定義

import { RefObject, useEffect, useRef } from 'react'

export function useOnClickOutside<T extends HTMLAnchorElement>(
  node: RefObject<T | undefined>,
  handler: undefined | (() => void)
) {
  const handlerRef = useRef<undefined | (() => void)>(handler)
  useEffect(() => {
    handlerRef.current = handler
  }, [handler])

  useEffect(() => {
    const handleClickOutside = (e: MouseEvent) => {
      if (node.current?.contains(e.target as Node) ?? false) {
        return
      }
      if (handlerRef.current) handlerRef.current()
    }

    document.addEventListener('mousedown', handleClickOutside)

    return () => {
      document.removeEventListener('mousedown', handleClickOutside)
    }
  }, [node])
}

優化後的hook裡,使用泛型T指代傳入的型別繼承自HTMLElement,這樣在宣告ref物件的時候就可以用T指代傳入的DOM型別,使用泛型可以加強對傳入引數的約束,大家在專案開發中可以多加嘗試。
在handleClickOutside方法中,使用了雙問號??判斷,雙問號的意思是如果前面的部分為undefined則返回後面的內容,也就是false。
本文最後完整版的useClickOutside hook借鑑了uniswap開源專案
專案地址

謝謝閱讀,如果覺得不錯,歡迎點贊o( ̄▽ ̄)d!

相關文章