检测 React 组件外的点击

IT技术 javascript dom reactjs
2021-01-17 04:40:58

我正在寻找一种方法来检测单击事件是否发生在组件之外,如本文所述jQuery Nearest() 用于查看来自单击事件的目标是否将 dom 元素作为其父元素之一。如果有匹配项,则单击事件属于其中一个子项,因此不会被视为在组件之外。

所以在我的组件中,我想将一个点击处理程序附加到窗口。当处理程序触发时,我需要将目标与组件的 dom 子项进行比较。

click 事件包含诸如“path”之类的属性,它似乎保存了事件经过的 dom 路径。我不确定要比较什么或如何最好地遍历它,我想一​​定有人已经把它放在一个聪明的实用函数中......不是吗?

6个回答

React 16.3+ 中的 Refs 用法发生了变化。

以下解决方案使用 ES6 并遵循绑定和通过方法设置 ref 的最佳实践。

要查看它的实际效果:

类实现:

import React, { Component } from 'react';
import PropTypes from 'prop-types';

/**
 * Component that alerts if you click outside of it
 */
export default class OutsideAlerter extends Component {
    constructor(props) {
        super(props);

        this.wrapperRef = React.createRef();
        this.setWrapperRef = this.setWrapperRef.bind(this);
        this.handleClickOutside = this.handleClickOutside.bind(this);
    }

    componentDidMount() {
        document.addEventListener('mousedown', this.handleClickOutside);
    }

    componentWillUnmount() {
        document.removeEventListener('mousedown', this.handleClickOutside);
    }

    /**
     * Alert if clicked on outside of element
     */
    handleClickOutside(event) {
        if (this.wrapperRef && !this.wrapperRef.current.contains(event.target)) {
            alert('You clicked outside of me!');
        }
    }

    render() {
        return <div ref={this.wrapperRef}>{this.props.children}</div>;
    }
}

OutsideAlerter.propTypes = {
    children: PropTypes.element.isRequired,
};

钩子实现:

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

/**
 * Hook that alerts clicks outside of the passed ref
 */
function useOutsideAlerter(ref) {
    useEffect(() => {
        /**
         * Alert if clicked on outside of element
         */
        function handleClickOutside(event) {
            if (ref.current && !ref.current.contains(event.target)) {
                alert("You clicked outside of me!");
            }
        }

        // Bind the event listener
        document.addEventListener("mousedown", handleClickOutside);
        return () => {
            // Unbind the event listener on clean up
            document.removeEventListener("mousedown", handleClickOutside);
        };
    }, [ref]);
}

/**
 * Component that alerts if you click outside of it
 */
export default function OutsideAlerter(props) {
    const wrapperRef = useRef(null);
    useOutsideAlerter(wrapperRef);

    return <div ref={wrapperRef}>{props.children}</div>;
}
谢谢你。它适用于我的某些情况,但不适用于弹出窗口display: absolute-handleClickOutside当您单击任何地方(包括内部)时,它总是会触发最终切换到 react-outside-click-handler,这似乎以某种方式涵盖了这种情况
2021-03-11 04:40:58
我收到以下错误:“this.wrapperRef.contains 不是函数”
2021-03-20 04:40:58
@polkovnikov.ph 1.context只有在使用时才需要作为构造函数中的参数。在这种情况下没有使用它。React 团队建议props在构造函数中将其作为参数的原因是因为使用this.propsbefore 调用super(props)将是未定义的并且可能导致错误。context在内部节点上仍然可用,这就是context. 这样你就不必像使用 props 那样将它从一个组件传递到另一个组件。2. 这只是一种风格偏好,在这种情况下不值得争论。
2021-04-04 04:40:58
@Bogdan ,我在使用样式组件时遇到了同样的错误。考虑在顶层使用 <div>
2021-04-06 04:40:58
你如何使用 React 的合成事件,而不是document.addEventListener这里的?
2021-04-07 04:40:58

2021 更新:

自从我添加这个回复已经有一段时间了,因为它似乎仍然引起了一些兴趣,我想我会把它更新到一个更新的 React 版本。在 2021 年,这就是我编写此组件的方式:

import React, { useState } from "react";
import "./DropDown.css";

export function DropDown({ options, callback }) {
    const [selected, setSelected] = useState("");
    const [expanded, setExpanded] = useState(false);

    function expand() {
        setExpanded(true);
    }

    function close() {
        setExpanded(false);
    }

    function select(event) {
        const value = event.target.textContent;
        callback(value);
        close();
        setSelected(value);
    }

    return (
        <div className="dropdown" tabIndex={0} onFocus={expand} onBlur={close} >
            <div>{selected}</div>
            {expanded ? (
                <div className={"dropdown-options-list"}>
                    {options.map((O) => (
                        <div className={"dropdown-option"} onClick={select}>
                            {O}
                        </div>
                    ))}
                </div>
            ) : null}
        </div>
    );
}

原始答案(2016):

这是最适合我的解决方案,无需将事件附加到容器:

某些 HTML 元素可以具有所谓的“焦点”,例如输入元素。当这些元素失去焦点时,它们也会响应模糊事件。

要使任何元素具有获得焦点的能力,只需确保将其 tabindex 属性设置为 -1 以外的任何值。在常规 HTML 中,这将通过设置tabindex属性来实现,但在 React 中您必须使用tabIndex(注意大写I)。

您也可以通过 JavaScript 使用 element.setAttribute('tabindex',0)

这就是我用它来制作自定义下拉菜单的目的。

var DropDownMenu = React.createClass({
    getInitialState: function(){
        return {
            expanded: false
        }
    },
    expand: function(){
        this.setState({expanded: true});
    },
    collapse: function(){
        this.setState({expanded: false});
    },
    render: function(){
        if(this.state.expanded){
            var dropdown = ...; //the dropdown content
        } else {
            var dropdown = undefined;
        }
        
        return (
            <div className="dropDownMenu" tabIndex="0" onBlur={ this.collapse } >
                <div className="currentValue" onClick={this.expand}>
                    {this.props.displayValue}
                </div>
                {dropdown}
            </div>
        );
    }
});
要单击目标内的元素,请参阅此答案:stackoverflow.com/a/44378829/642287
2021-03-11 04:40:58
如果下拉内容包含一些可聚焦元素(例如表单输入),您可能会面临其他一些问题。它们会窃取您的注意力,onBlur 将在您的下拉容器上触发并expanded 设置为 false。
2021-03-12 04:40:58
我对这种方法有问题,下拉内容中的任何元素都不会触发诸如 onClick 之类的事件。
2021-03-15 04:40:58
我在某种程度上有这种“工作”,这是一个很酷的小技巧。我的问题是我的下拉内容是display:absolute这样下拉不会影响父 div 的大小。这意味着当我单击下拉列表中的一个项目时,onblur 会触发。
2021-04-03 04:40:58
这不会造成可访问性问题吗?由于您使一个额外的元素可聚焦只是为了检测它何时进入/退出焦点,但交互应该在下拉值上不是吗?
2021-04-06 04:40:58

我被困在同样的问题上。我参加聚会有点晚了,但对我来说这是一个非常好的解决方案。希望它会对其他人有所帮助。您需要findDOMNodereact-dom

import ReactDOM from 'react-dom';
// ... ✂

componentDidMount() {
    document.addEventListener('click', this.handleClickOutside, true);
}

componentWillUnmount() {
    document.removeEventListener('click', this.handleClickOutside, true);
}

handleClickOutside = event => {
    const domNode = ReactDOM.findDOMNode(this);

    if (!domNode || !domNode.contains(event.target)) {
        this.setState({
            visible: false
        });
    }
}

React Hooks 方法 (16.8 +)

您可以创建一个名为useComponentVisible.

import { useState, useEffect, useRef } from 'react';

export default function useComponentVisible(initialIsVisible) {
    const [isComponentVisible, setIsComponentVisible] = useState(initialIsVisible);
    const ref = useRef(null);

    const handleClickOutside = (event) => {
        if (ref.current && !ref.current.contains(event.target)) {
            setIsComponentVisible(false);
        }
    };

    useEffect(() => {
        document.addEventListener('click', handleClickOutside, true);
        return () => {
            document.removeEventListener('click', handleClickOutside, true);
        };
    });

    return { ref, isComponentVisible, setIsComponentVisible };
}

然后在您希望添加功能的组件中执行以下操作:

const DropDown = () => {
    const { ref, isComponentVisible } = useComponentVisible(true);
    return (
       <div ref={ref}>
          {isComponentVisible && (<p>Dropdown Component</p>)}
       </div>
    );

}

在此处查找代码沙盒示例。

伟大而干净的解决方案
2021-03-18 04:40:58
这应该是公认的答案。非常适合具有绝对定位的下拉菜单。
2021-03-28 04:40:58
这很棒,因为它可以重复使用。完美的。
2021-04-04 04:40:58
@LeeHanKyeol 不完全 - 此答案在事件处理的捕获阶段调用事件处理程序,而链接到的答案在冒泡阶段调用事件处理程序。
2021-04-09 04:40:58
ReactDOM.findDOMNode已弃用,应使用ref回调:github.com/yannickcr/eslint-plugin-react/issues/...
2021-04-09 04:40:58

在这里尝试了很多方法后,我决定使用github.com/Pomax/react-onclickoutside,因为它很完整。

我通过 npm 安装了module并将其导入到我的组件中:

import onClickOutside from 'react-onclickoutside'

然后,在我的组件类中,我定义了handleClickOutside方法:

handleClickOutside = () => {
  console.log('onClickOutside() method called')
}

在导出我的组件时,我将其包裹在onClickOutside()

export default onClickOutside(NameOfComponent)

而已。

与 Pablo 提出tabIndex/onBlur方法相比,这有什么具体优势吗?实现是如何工作的,它的行为与onBlur方法有何不同
2021-03-15 04:40:58
@BeauSmith - 非常感谢!拯救了我的一天!
2021-03-28 04:40:58
@MarkAmery - 我对这种tabIndex/onBlur方法发表了评论当下拉菜单为 时不起作用position:absolute,例如菜单悬停在其他内容上。
2021-03-30 04:40:58
最好使用专用组件然后使用 tabIndex hack。点赞!
2021-04-08 04:40:58
使用它而不是 tabindex 的另一个优点是,如果您专注于子元素,tabindex 解决方案也会触发模糊事件
2021-04-10 04:40:58

基于 Tanner Linsley在 JSConf Hawaii 2020上的精彩演讲的Hook 实现

useOuterClick 应用程序接口

const Client = () => {
  const innerRef = useOuterClick(ev => {/*event handler code on outer click*/});
  return <div ref={innerRef}> Inside </div> 
};

执行

function useOuterClick(callback) {
  const callbackRef = useRef(); // initialize mutable ref, which stores callback
  const innerRef = useRef(); // returned to client, who marks "border" element

  // update cb on each render, so second useEffect has access to current value 
  useEffect(() => { callbackRef.current = callback; });
  
  useEffect(() => {
    document.addEventListener("click", handleClick);
    return () => document.removeEventListener("click", handleClick);
    function handleClick(e) {
      if (innerRef.current && callbackRef.current && 
        !innerRef.current.contains(e.target)
      ) callbackRef.current(e);
    }
  }, []); // no dependencies -> stable click listener
      
  return innerRef; // convenience for client (doesn't need to init ref himself) 
}

这是一个工作示例:

关键点

  • useOuterClick利用可变引用来提供精益ClientAPI
  • 包含组件([]deps)生命周期的稳定点击监听器
  • Client 可以设置回调而无需记住它 useCallback
  • 回调主体可以访问最新的 props 和 state -没有陈旧的闭包值

(iOS 的旁注)

iOS 通常只将某些元素视为可点击的。要使外部点击起作用,请选择不同的点击侦听器document- 不包括body. 例如,在 React 根上添加一个侦听器div并扩展其高度,例如height: 100vh,以捕获所有外部点击。资料来源:quirksmode.org

@ford04 是的,顺便说一句,根据这个线程,有一个更具体的媒体查询@supports (-webkit-overflow-scrolling: touch),它只针对 IOS,尽管我不知道更多!我刚刚测试了它并且它有效。
2021-03-13 04:40:58
我需要测试。但感谢您的解决方案。不过看起来是不是很笨重?
2021-03-23 04:40:58
@Omar 似乎是一个非常特殊的 IOS 怪癖。看看这里:quirksmode.org/blog/archives/2014/02/mouse_event_bub.htmlstackoverflow.com/questions/18524177/...gravitydept.com/blog/js-click-event-bubbling-on-ios我能想象的最简单的解决方法是:在代码和框示例中,为根 div 设置一个空的点击处理程序,如下所示:<div onClick={() => {}}> ... </div>,以便 IOS 注册来自外部的所有点击。它在 Iphone 6+ 上对我有用。这能解决你的问题吗?
2021-03-26 04:40:58
@ford04 感谢您的挖掘,我偶然发现了另一个暗示以下技巧的线程@media (hover: none) and (pointer: coarse) { body { cursor:pointer } }将此添加到我的全局样式中,似乎解决了问题。
2021-03-27 04:40:58
没有错误,但不适用于 chrome、iphone 7+。它不会检测到外面的水龙头。在移动设备上的 chrome 开发工具中它可以工作,但在真正的 ios 设备中它不工作。
2021-04-06 04:40:58