likes
comments
collection

目标元素外点击事件 - ClickOut hook 🦚

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

记录Popover元素外点击事件的一次代码实践

需求原型

UI想要实现的UI结构如下所示:(下图一)(组件库来源于开源的semi)目标元素外点击事件 - ClickOut hook 🦚

目标元素外点击事件 - ClickOut hook 🦚

当用户在搜索框中键入搜索项时,下方会自动弹出一个联想框,交互表现和Select下拉框是一致的。但是我们无法使用Select组件,即使这个组件中已经帮助我们实现了UI结构(上图二),因为尝试了很多方法都无法让Select组件的交互与Input组件的交互完全一致,于是就自己封装了一套类似的分类面板组件SelectPanel,并且使用了Input+Popover的形式结合。

但是在进一步实现相同交互逻辑时遇到了一个小坎:如何保证用户在InputSelectPanel的范围内操作使得处于Popover中的SelectPanel是可见的,但是一离开它们的范围就变得不可见。

前提

首先我们应该明确一点,那就是Popover组件对应的实体Dom和其内部包裹的触发Dom并不是我们在代码中显示的层级关系。

举个🌰来说,图中的Tag组件是被Popover组件包裹住的,但是这并不意味着在实际的Dom结构中他们是父子级的关系。

目标元素外点击事件 - ClickOut hook 🦚

下面左图为TagDom位置,右图为PopoverDom位置,可以看出他们的实际Dom结构没有任何关联性:

目标元素外点击事件 - ClickOut hook 🦚目标元素外点击事件 - ClickOut hook 🦚

这本身是合理的,因为一个元素和它的气泡卡片弹窗本来就并不构成父子层级关系,它们只是在UI结构上伴生,而在Dom层级中毫无亲密性。

知道这一点后我们至少可以确定一些方法是行不通的,比如

  1. 1 ❌ 在Popover组件中添加onFocusonBlur事件,分别对应着控制SelectPanel组件的显隐

两个元素没有共同且双向唯一的父元素,所以只在Popover中添加事件没有用完善

  1. 2 ❌ 目前业界有很多类似的hook:使用封装好的useClickOutside hook,比如下面这个:

    /**

    • dom: dom元素
    • onClickAway:点出触发的事件 */ const ref = useClickOutside( dom: RefType = undefined, onClickAway: (event: KeyboardEvent) => void, ): MutableRefObject;

这个hook通过监听用户点击,检查

包裹元素是否包含点击元素 & 点击元素是否曾在包裹元素内而判断是否离开了Dom

但是这个hook中只能传入一个dom作为目标dom,但是我们有两个dom,所以没法使用。

实现

trick

因为时间问题,最开始本着“不管代码怎么写,只要功能实现了就行的原则”,硬捣鼓出了这套流程:

  1. 在全局添加一个点击事件,只要发生点击,就将Popover关闭:

    React.useEffect(() => { window.addEventListener('click', e => { setPopoverVisible(false); }); });

  2. 只要是在Input或者Popover中发生的点击,就将Popover打开,为了保证顺序将其推入下个宏任务栈:

    onClick={() => { setTimeout(() => { setPopoverVisible(true); }, 0); }}

  3. 如果在Popover中发生点击后又要将其关闭怎么办?继续推!:

    const selectPanelChange = ({ label, value }) => { console.log('pick', value); setTimeout(() => { setPopoverVisible(false); }, 10); };

这样的做法可以实现上述想要达到的效果,但是可能会受当前浏览器的执行任务多少和耗时的影响从而无法保证渲染发生在一次loop中,在一些极端的情况下可能并不会得到预期的结果。

serious

言归正传,其实在了解到前提部分后已经很接近答案了,我们没有办法使用上面提到的useClickOutside hook的原因无非是它只支持传入一个Dom,我们自己拓展让它支持两个Dom不就可以了嘛~

首先,我们给这两个元素组件都安装一个Ref:

<Popover
  ref={popoverRef}
  trigger="custom"
  visible={popoverVisible}
  content={(
    <SelectPanel
      highlightWord={searchInput}
      panelTree={panelTree}
      onChange={selectPanelChange}
      innerTopSlot={selectPanelInnerTopSlot}
    />
  )}
>
  <Input
    ref={popoverChildRef}
    onClick={() => {
      setPopoverVisible(true);
    }}
  />
</Popover>

然后再通过给全局增加一个click监听事件,每当监听到用户的鼠标点击事件时,就会判断用户点击的元素是否包含在Popover组件元素或是Input组件元素中,如果是的话,则不做任何事情,如果不是,则关闭Popover

React.useEffect(() => {
    const hanlder = e => {
      const popoverInstance = popoverRef.current;
      const popoverChildDom = popoverChildRef.current;
      const popoverDom = ReactDOM.findDOMNode(popoverInstance);
      // let isInPopoverDom = popoverDom && popoverDom.contains(e.target);
      // let isInPopoverChildDom = popoverChildDom && popoverChildDom.contains(e.target);
      if (popoverDom && !popoverDom?.contains(e.target) && popoverChildDom && !popoverChildDom?.contains(e.target)) {
        setPopoverVisible(false);
      }
    };
    document.addEventListener('click', hanlder);
    return () => {
      document.removeEventListener('click', hanlder);
    };
  }, []);

这样便可以达到我们的目的,实际上,已开源的Semi源码中对于Select以及其GroupPanel的处理也是这样的。

抽象

可以将这个能力抽象成一个hook,其实也算是对上面提到的useClickOutside hook的拓展:

  1. 可以传入一个dom Ref,也可以传入一个dom Refs数组

  2. 可以自定义用户事件

自定义拓展的useClickOut hook代码如下:

import * as React from 'react';
import ReactDOM from 'react-dom';

type UseClickOutProps = {
  domTargetRef: React.MutableRefObject<HTMLElement>[];
  onClickOut: (event: Event) => void;
  eventName?: string;
};

/**
 * core func - 判断用户事件目标元素是否在指定元素外
 * @param domTargetRefArr dom-Ref数组
 * @param targetDom 用户事件dom
 * @returns boolean
 */
const isClickDomOutside = (domTargetRefArr: React.MutableRefObject<HTMLElement>[], targetDom: HTMLElement) => {
  for (const domRef of domTargetRefArr) {
    const realDom = ReactDOM.findDOMNode(domRef?.current);
    if (realDom?.contains(targetDom)) {
      return false;
    }
  }
  return true;
};

/**
 * hook - useClickOut: 用于触发在指定dom外发生用户特定行为的事件
 * @param domTargetRef: 单个dom的Ref 或 dom-Ref数组
 * @param onClickOut: 点出事件
 * @param eventName: 默认click事件
 */
export default function useClickOut({ domTargetRef = [], eventName = 'click', onClickOut }: UseClickOutProps) {
  const domTargetRefArr = [].concat(domTargetRef);
  React.useEffect(() => {
    const hanlder = e => {
      if (isClickDomOutside(domTargetRefArr, e?.target)) {
        onClickOut(e);
      }
    };
    document.addEventListener(eventName, hanlder);
    return () => {
      document.removeEventListener(eventName, hanlder);
    };
  }, []);
}

这时候在我们的业务代码中这样写就可以了:

import useClickOut from '@common/hooks/useClickOut';
// React.FC:
    useClickOut({
        domTargetRef: [popoverRef, popoverChildRef],
        onClickOut: e => {
          console.log('poker face');
          setPopoverVisible(false);
        },
      });

其他

如果不想以Ref而是以元素ID的形式传入的话:

{/* 不要在Popover组件虚拟Dom中加ID */}
<Popover
  trigger="custom"
  visible={popoverVisible}
  content={(
      <div id="popover_father">
        <SelectPanel
          highlightWord={searchInput}
          panelTree={panelTree}
          onChange={selectPanelChange}
          innerTopSlot={selectPanelInnerTopSlot}
        />
       </div>
  )}
>
  <Input
    id="popover_children"
    onClick={() => {
      setPopoverVisible(true);
    }}
  />
</Popover>
  • Element-ID的形式传入

    useClickOut({ domTargetRef: [document.getElementById('popover_father'), document.getElementById('popover_children')], onClickOut: e => { console.log('poker face'); setPopoverVisible(false); }, });

这种需要在hook中以Ref的形式或者为Effect添加依赖项以保持最新记录,否则在dom还未render之前传入的是null,而它不会继续更新。

  • Function的形式传入(上面提到的useClickOutside hook就是这种传入方式)

    useClickOut({ domTargetRef: [()=>document.getElementById('popover_father'), ()=>document.getElementById('popover_children')], onClickOut: e => { console.log('poker face'); setPopoverVisible(false); }, });

hook中不需要取得最新值,而是直接运行时执行函数取得Dom结点。

const realDom = dom(); 
console.log(">>>>>", realDom);