在本文中,我们将研究如何使用React创建外部焦点和单击处理程序。您将学习如何react-foco从头开始重新创建开源React组件。为了充分利用本文,您需要对JavaScript类,DOM事件委托和React有基本的了解。到本文结尾,您将知道如何使用JavaScript类实例属性和事件委托来创建React组件,以帮助您检测任何React组件之外的点击或焦点。
通常,我们需要检测何时单击发生在元素外部或何时焦点移到元素外部。此用例的一些明显示例是弹出菜单,下拉菜单,工具提示和弹出窗口。让我们开始进行此检测功能的过程。
DOM检测外部点击的方法
如果要求您编写代码来检测单击是在DOM节点内还是在DOM节点外,该怎么办?您可能会使用Node.containsDOM API。这是MDN的解释方式:
该Node.contains()方法返回一个Boolean值,该值指示节点是否为给定节点的后代,即节点本身,其直接子代(childNodes)之一,子代的直接子代之一,等等。
让我们快速测试一下。让我们创建一个我们想要检测外部点击的元素。我很方便地给它上了一堂click-text课。
<div class="click-text">
click inside and outside me
</div>
</section>
document.addEventListener("mousedown", (event) => {
if (concernedElement.contains(event.target)) {
console.log("Clicked Inside");
} else {
console.log("Clicked Outside / Elsewhere");
}
});
我们做了以下事情:
- 用类选择了HTML元素click-text。
- 放下鼠标向下的事件侦听器,document并设置事件处理程序回调函数。
- 在回调函数中,我们正在检查相关元素(我们必须检测其外部点击)是否包含触发mousedown事件(event.target)的元素(包括自身)。
- 如果触发鼠标按下事件的元素是我们所关注的元素或该元素内的任何元素,则意味着我们已经在我们所关注的元素内单击。
让我们在下面的Codesandbox中单击元素的内部和外部,然后检查控制台。
在React组件中包装基于DOM层次结构的检测逻辑
伟大的!到目前为止,我们已经看到了如何使用DOM的Node.containsAPI来检测元素外部的点击。我们可以将该逻辑包装在React组件中。我们可以命名新的React组件OutsideClickHandler。我们的OutsideClickHandler组件将像这样工作:
onOutsideClick={() => {
console.log("I am called whenever click happens outside of 'AnyOtherReactComponent' component")
}}
>
<AnyOtherReactComponent />
</OutsideClickHandler>
OutsideClickHandler 需要两个道具:
-
children 它可以是任何有效的React子代。在上面的示例中,我们将AnyOtherReactComponentcomponent作为OutsideClickHandler的孩子传递。
-
onOutsideClick 如果在AnyOtherReactComponent组件外部的任何地方发生单击,则将调用此函数。
到目前为止听起来不错?让我们开始构建OutsideClickHandler组件。
class OutsideClickHandler extends React.Component {
render() {
return this.props.children;
}
}
只是一个基本的React组件。到目前为止,我们还没有做太多事情。我们只是在将孩子传递给我们的OutsideClickHandler组件时返回它们。让我们children用div元素包装,并在其上附加一个React ref。
class OutsideClickHandler extends React.Component {
wrapperRef = createRef();
render() {
return (
<div ref={this.wrapperRef}>
{this.props.children}
</div>
)
}
}
我们将使用它ref来访问与该div元素关联的DOM节点对象。使用该代码,我们将重新创建上面所做的外部检测逻辑。
让我们mousedown在componentDidMountReact生命周期方法内将事件附加到文档上,并在componentWillUnmountReact生命周期方法内清理该事件。
让我们mousedown在componentDidMountReact生命周期方法内将事件附加到文档上,并在componentWillUnmountReact生命周期方法内清理该事件。
现在,让我们在handleClickOutside处理程序函数中编写检测代码。
componentDidMount() {
document
.addEventListener('mousedown', this.handleClickOutside);
}
componentWillUnmount(){
document
.removeEventListener('mousedown', this.handleClickOutside);
}
handleClickOutside = (event) => {
if (
this.wrapperRef.current &&
!this.wrapperRef.current.contains(event.target)
) {
this.props.onOutsideClick();
}
}
}
内部逻辑handleClickOutside方法表示以下内容:
如果被单击(event.target)的DOM节点既不是我们的容器div(this.wrapperRef.current),也不是其内部的任何节点(!this.wrapperRef.current.contains(event.target)),则我们将其称为onOutsideClickprop。
此操作应与外部点击检测以前的工作方式相同。让我们尝试单击下面的codeandbox中灰色文本元素之外的位置,并观察控制台:
基于DOM层次结构的外部点击检测逻辑问题
但是有一个问题。如果它的任何子组件在React门户中呈现,则我们的React组件将不起作用。 但是什么是React门户?
“门户提供了一种一流的方法来将子级呈现到父组件的DOM层次结构之外的DOM节点中。”
—门户网站的React文档
在上图中,您可以看到尽管TooltipReact组件是React组件的子代Container,但是如果我们检查DOM,我们会发现Tooltip DOM节点实际上位于完全独立的DOM结构中,即它不在Container DOM节点内。
问题在于,到目前为止,在我们的外部检测逻辑中,我们假设的子级OutsideClickHandler将成为DOM树中其直接后代。对于React门户来说并非如此。如果我们组件的子组件在React门户中渲染(也就是说,它们在一个单独的DOM节点中渲染,该节点不在container div我们OutsideClickHandler组件渲染其子组件的层次结构中),那么Node.contains逻辑将失败。
怎么会失败呢?如果您尝试单击OutsideClickHandler组件的子级(使用React门户在单独的DOM节点中进行渲染),则组件将注册外部点击,但不应单击。你自己看:
即使单击按钮时打开的弹出窗口是OutsideClickHandler组件的子级,也无法检测到它不在其外部,而是在单击时将其关闭。
使用类实例属性和事件委托来检测外部点击
那么可能是什么解决方案?我们肯定不能依靠DOM来告诉我们点击是否发生在任何地方。我们将不得不通过重写OutsideClickHandler实现来对JavaScript进行一些处理。
让我们从一片空白开始。所以这时OutsideClickHandler是一个空的React类。
正确检测外部点击的症结在于:
不依赖DOM结构。 将“单击”状态存储在JavaScript代码中的某个位置。 为此,代表团将为我们提供帮助。让我们以上面的GIF中看到的相同的按钮和弹出框为例。
我们有两个孩子OutsideClickHandler。一个按钮和一个弹出窗口-OutsideClickHandler在单击按钮时,将在DOM层次结构之外的门户中呈现,如下所示:
当我们的两个孩子中的任何一个都被点击时,我们将变量设置clickCaptured为true。如果单击它们之外的任何内容,clickCaptured将保留的值false。
我们会将clickCaptured的值存储在:
类实例属性,如果您使用的是类react组件。 ref,如果您正在使用功能性的React组件。
我们没有使用React状态来存储clickCaptured的值,因为我们没有基于此clickCaptured数据呈现任何内容。的用途clickCaptured是短暂的,一旦我们检测到点击是在内部还是外部发生的,它就会终止。
让我们在下面的图像中查看设置逻辑clickCaptured:
每当在任何地方发生点击时,默认情况下它就会在React中冒泡。它将达到document最终。
点击到达时document,可能会发生两件事:
clickCaptured 如果孩子被点击,则为true。 clickCaptured 如果单击了它们以外的任何位置,则为false。 在文档的事件监听器中,我们现在将做两件事:
如果clickCaptured为true,我们会触发一个外部点击处理程序,该外部处理程序可能是用户OutsideClickHandler通过道具给我们的。 我们重置clickCaptured为false,以便我们准备进行另一次点击检测。
让我们将其翻译为代码。
import React from 'react'
clickCaptured = false;
render() {
if ( typeof this.props.children === 'function' ) {
return this.props.children(this.getProps())
}
return this.renderComponent()
}
}
我们有以下几件事:
将clickCapturedinstance属性的初始值设置为false。 在该render方法中,我们检查childrenprop是否为函数。如果是的话,我们调用它,并通过调用getProps类方法将要传递给它的所有道具传递给它。我们尚未实施getProps。 如果childrenprop不是函数,则调用renderComponentmethod。让我们现在实现此方法。
renderComponent() {
return React.createElement(
this.props.component || 'span',
this.getProps(),
this.props.children
)
}
}
由于我们没有使用JSX,因此我们直接使用React的createElement API将我们的孩子包装在this.props.component或中span。this.props.component可以是React组件,也可以是任何HTML元素的标签名,例如'div','section'等。我们通过将getProps类方法作为第二个参数,将所有想要传递的属性传递给新创建的元素。
让我们getProps现在编写该方法:
getProps() {
return {
onMouseDown: this.innerClick,
onTouchStart: this.innerClick
};
}
}
我们新创建的反应元素,将有向下传递给它的以下道具:onMouseDown和onTouchStart触摸设备。它们的两个值都是innerClickclass方法。
innerClick = () => {
this.clickCaptured = true;
}
}
如果单击了我们新的React组件或其中的任何内容(可能是React门户),则将clickCaptured类实例属性设置为true。现在,让我们将mousedownandtouchstart事件添加到文档中,以便我们可以捕获从下面冒泡的事件。
componentDidMount(){
document.addEventListener('mousedown', this.documentClick);
document.addEventListener('touchstart', this.documentClick);
}
componentWillUnmount(){
document.removeEventListener('mousedown', this.documentClick);
document.removeEventListener('touchstart', this.documentClick);
}
documentClick = (event) => {
if (!this.clickCaptured && this.props.onClickOutside) {
this.props.onClickOutside(event);
}
this.clickCaptured = false;
};
}
在文档mousedown和touchstart事件处理程序中,我们正在检查是否clickCaptured虚假。
clickCaptured只有在true我们的React组件的子代被点击的情况下。 如果还有其他需要单击的地方,clickCaptured将为false,并且我们知道发生了外部点击。 如果clickCaptured是虚假的,我们将调用onClickOutside在prop中传递给OutsideClickHandler组件的方法。
而已!让我们确认一下,如果我们在弹出窗口内单击,它不会像以前一样被关闭:
外部焦点检测
现在,让我们更进一步。我们还添加一些功能来检测焦点何时转移到React组件之外。这将与我们对点击检测所做的实现非常相似。让我们编写代码。
focusCaptured = false
innerFocus = () => {
this.focusCaptured = true;
}
componentDidMount(){
document.addEventListener('mousedown', this.documentClick);
document.addEventListener('touchstart', this.documentClick);
document.addEventListener('focusin', this.documentFocus);
}
componentWillUnmount(){
document.removeEventListener('mousedown', this.documentClick);
document.removeEventListener('touchstart', this.documentClick);
document.removeEventListener('focusin', this.documentFocus);
}
documentFocus = (event) => {
if (!this.focusCaptured && this.props.onFocusOutside) {
this.props.onFocusOutside(event);
}
this.focusCaptured = false;
};
getProps() { return { onMouseDown: this.innerClick, onTouchStart: this.innerClick, onFocus: this.innerFocus }; }
除了一件事外,其他所有内容的添加方式基本相同。您可能已经注意到,尽管我们在子级上添加了一个onFocus事件事件处理程序,但我们focusin仍在为文档设置事件侦听器。你为什么不focus说一个事件?因为,从 v17开始,React现在在内部将React事件映射到本机事件。onFocus focusin
如果您使用的是v16或更早版本,则无需在捕获阶段focusin添加focus事件,而不必在文档中添加事件处理程序。因此将是:
为什么在捕获阶段您可能会问?因为尽管如此,焦点事件并没有冒出来。
由于我在所有示例中都使用v17,因此我将继续使用前者。让我们看看这里有什么:
让我们自己尝试一下,尝试在粉红色背景的内部和外部单击。还可以使用Tab和Shift+Tab键(在Chrome,Firefox,Edge中)或Opt/Alt+Tab和Opt/Alt+ Shift+ Tab(在Safari中)在内部按钮和外部按钮之间切换焦点,并查看焦点状态如何变化。
结论
在本文中,我们了解了在JavaScript中检测DOM节点外部点击的最直接方法是使用Node.containsDOM API。我解释了了解当React组件具有在React门户中呈现的子级时,为什么使用相同的方法来检测React组件外部的点击不起作用的重要性。
此外,现在您知道如何在事件委托旁边使用类实例属性来正确检测在React组件外部是否发生了单击,以及如何将相同的检测技术扩展到具有focusin事件的React组件的外部焦点检测警告。