通过造组件来学习React-2
前言
本文继续上一篇的内容。要实现:点击菜单框以外的其他区域,就会收起列表。
事件
那么问题来了,怎么知道点击的区域是不是菜单框以外的区域?
我想你已经知道,当点击一个元素时会触发这个元素所绑定的点击事件。
const btn = document.getElementById("btn");
btn.addEventListener("click", function (event) {
// 点击事件
});
通过 addEventListener
绑定点击事件,事件通过方法的形式传入,该方法可以接收一个 event 参数,event 参数表示事件触发时的元素。参考:EventTarget.addEventListener()。
简单说明一下这个 event 参数,如下代码:
<style>
div {
width: 200px;
height: 200px;
background-color: gray;
}
</style>
<div id="out">
<button>BTN</button>
</div>
<script>
const out = document.getElementById("out");
out.addEventListener(
"click",
function (event) {
console.log(event.target);
console.log(event.currentTarget);
},
false
);
</script>
界面如下:
div 标签内有一个 按钮,同时 div 元素绑定了一个事件,该事件会打印出 event 的 target 属性和 currentTarget 属性。
当我们点击按钮后,由于按钮没有绑定点击事件,所以不会有任何事发生,然后事件冒泡到 div 元素上,触发了 div 的点击事件,控制台输出如下:
可见,target 属性输出的是 触发事件的按钮元素,currentTarget 属性输出的是绑定事件的 div 元素。所以,我们可以通过 event.target
得知触发事件的是什么元素。参考:Event。
既然如此,我们的思路就是,给整个 window 绑定点击事件,如果事件的 event.target
不是菜单框中的元素,就将菜单框隐藏。
window.addEventListener("pointerdown", callback);
这里使用的 pointerdown
事件,是因为触摸屏的手指点击,画板的画笔也可以触发,兼容性好。参考:Pointer events 指针事件。
假设菜单框的按钮元素和列表元素本别是:button 和 list,我们怎么知道有没有点击到他呢。只需要知道 event.target
在不在 button 和 list 这两个元素的范围内即可。只需要使用 Node 节点的方法 .contains(..)
即可判断传入的元素是不是子元素和当前元素。如,判断 event.target
是不是 button 的子元素和当前元素,只需要:
if (button.contains(event.target)) {
// ...
}
参考:Node.contains。
这样一来,我们的思路就很明显了,使用 .contains()
判断 event.target
,如果为 true,说明点击到的是按钮或列表,什么事都不做,如果不是,就隐藏列表。
if (button.contains(event.target)) {
// 别忘了上一篇中的 dipatch
dipatch({ type: "close" });
}
if (list.contains(event.target)) {
dipatch({ type: "close" });
}
封装一下
在上一篇中,我们在 state 中存了按钮和列表的引用:
type StateDefine = {
visiable: Boolean,
buttonRef: React.MutableRefObject<HTMLButtonElement | null>,
itemsRef: React.MutableRefObject<HTMLSpanElement | null>,
};
所以当判断的时候,要取出引用里的元素:
if (state.buttonRef?.current?.contains(event.target)) {
// 别忘了上一篇中的 dipatch
dipatch({ type: "close" });
}
组合并封装起来就成为了:
type Containers = Contaiter[];
type Contaiter = HTMLElement | React.MutableRefObject<HTMLElement | null>;
export function useOutsideClick(
containers: Containers,
event: (e: PointerEvent) => void,
options?: any
) {
const callback = React.useCallback(
(e: PointerEvent) => {
const target = e.target as HTMLElement;
if (
containers.some((element: Contaiter) => {
const node =
element instanceof HTMLElement ? element : element?.current;
return node?.contains(target);
})
) {
return;
}
event(e);
},
[event]
);
React.useEffect(() => {
console.log("ADD LISTENER");
window.addEventListener("pointerdown", callback, options);
return () => {
console.log("REMOVE LISTENER");
window.removeEventListener("pointerdown", callback, options);
};
}, []);
}
突然封装成了一个不认识的函数,我慢慢来解释。
我把他封装了一个名为 useOutsideClick
的 hook,用于判断点击元素是不是指定的元素,如果是就执行事件。能够看到接收的三个参数,containers 用于指定哪些元素,event 用于指定要执行的事件,options 是一个可选的参数。
然后你会看到一个 useCallback
方法,这个是 React 提供的 hook,作用是缓存一个方法,作为第一个参数传入。该方法会返回一个方法,除非第二个参数里的依赖项更新,否则会一直返回缓存的方法。参考:Hook API 索引 useCallback。
紧接着我使用了 useEffect hook,useEffect 接收一个方法和一个依赖项,方法会在组件加载完成后执行,如果有依赖项的话,那么只有在组件首次加载和依赖项变动后才会执行方法。参考:Hook API 索引 useEffect。使用这个 hook,我们在菜单框加载完毕后给 window 添加事件,然后在菜单框卸载后删除事件。我分别在加载和卸载方法中输出了日志,以查看效果。
但如果你使用的是 React 18,且使用了 <React.StrictMode></React.StrictMode>
的话,你可能会见到控制台输出了如下信息:
ADD LISTENER
REMOVE LISTENER
ADD LISTENER
这是 React 18 使用了 <React.StrictMode></React.StrictMode>
在开发模式下使用 useEffect 后的怪异行为,useEffect 会执行两次,目的是为了让开发者检查 useEffect 有没有忘记添加卸载的函数。这种 feature 很是难受。参考:React18 的 useEffect 新特性为什么被疯狂吐槽?。让 useEffect 回归正常只要把 <React.StrictMode></React.StrictMode>
去掉就可以了。
当使用的时候,只需要:
useOutsideClick([state.buttonRef, state.itemsRef], (e) => {
dispatch({ type: "close" });
});
试一下吧。
参考
EventTarget.addEventListener() by MDN
Event by MDN
Pointer events 指针事件 by MDN
Node.contains by MDN
Hook API 索引 useCallback by React
Hook API 索引 useEffect by React
React18 的 useEffect 新特性为什么被疯狂吐槽? by YvetteLau