大家好,我是小杨。在日常的前端开发中,我们经常会遇到这样的需求:点击一个按钮,弹出一个小窗口(Modal),然后希望点击窗口以外的任何区域都能自动关闭它。
这个功能看起来简单,但实现起来却有几个关键的坑点。今天,我就来分享一下我是如何用 React 的 useRef
和 useEffect
优雅地解决这个问题的。这个方法非常经典,希望你读完也能把它纳入自己的工具库。
为什么不用普通的 state?
首先,可能有新手朋友会想:"我直接用 onClick
监听整个文档(document
)的点击事件不就行了?"
想法很好,但实现起来会发现一个问题:事件冒泡。当你点击弹窗内部时,这个点击事件也会冒泡到 document 上,导致弹窗刚一打开就立刻被关闭了。这显然不是我们想要的效果。
所以,我们需要一个办法来判断一次点击事件,究竟是不是发生在弹窗的外部。
核心思路:useRef 来充当"坐标轴"
我的解决方案是:
- 使用
useRef
为弹窗组件创建一个引用(ref)。 - 在
useEffect
中,给 document 添加点击事件监听。 - 当任何点击事件发生时,检查被点击的元素(
event.target
)是否包含在我们弹窗的 ref 中。 - 如果不包含,说明点击发生在弹窗外部,触发关闭函数。
useRef
在这里扮演了至关重要的角色,因为它可以让我们直接访问到真实的 DOM 节点,而且它的值在组件生命周期内保持不变,不会引起额外的渲染,非常适合用来做这种 DOM 相关的判断。
Show Me The Code!
下面是我在一个实际项目中写的自定义 Hook,我把它命名为 useClickOutside
。它的职责就是封装这个逻辑,让任何需要"点击外部关闭"的组件都能轻松复用。
jsx
import { useEffect, useRef } from 'react';
const useClickOutside = (handler) => {
const ref = useRef(null);
useEffect(() => {
const handleClickOutside = (event) => {
// 检查 ref 当前是否已经关联到DOM元素
// 再检查点击事件的目标是否在 ref 所指向的DOM元素内部
if (ref.current && !ref.current.contains(event.target)) {
// 如果在外部,则调用传入的处理函数
handler();
}
};
// 添加事件监听
document.addEventListener('mousedown', handleClickOutside);
// 清除副作用:组件卸载时移除事件监听
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [handler]); // 只有当 handler 改变时,才重新创建 effect
return ref;
};
export default useClickOutside;
代码解读:
const ref = useRef(null);
: 创建一个 ref 对象,初始化为null
。useEffect
: 在组件挂载后执行。handleClickOutside
: 定义事件处理函数。ref.current.contains(event.target)
是核心逻辑,用于判断点击目标是否在 ref 指向的节点内部。document.addEventListener(...)
: 监听整个 document 的mousedown
事件(比click
更早触发)。return () => { ... }
: 这是 effect 的清理函数,在组件卸载时执行,移除事件监听,防止内存泄漏。return ref
: 最后,将这个 ref 返回出去,让使用这个 Hook 的组件能够绑定到具体的 DOM 节点上。
在弹窗组件中如何使用?
有了这个自定义 Hook,我们的弹窗组件就变得非常简洁和易用了。
jsx
import React, { useState } from 'react';
import useClickOutside from './useClickOutside'; // 假设Hook放在同目录
const Modal = ({ isOpen, onClose, children }) => {
// 使用自定义Hook,传入关闭弹窗的函数
const modalRef = useClickOutside(onClose);
// 如果 isOpen 为 false,则不渲染任何内容
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center">
{/* 将返回的 ref 绑定到最外层的 div 上 */}
<div ref={modalRef} className="bg-white p-8 rounded-lg shadow-xl">
{children}
<button
onClick={onClose}
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded"
>
关闭
</button>
</div>
</div>
);
};
export default Modal;
使用场景:
jsx
function App() {
const [isModalOpen, setModalOpen] = useState(false);
return (
<div>
<button onClick={() => setModalOpen(true)}>打开弹窗</button>
<Modal
isOpen={isModalOpen}
onClose={() => setModalOpen(false)}
>
<h2>我是弹窗内容!</h2>
<p>点击我内部不会关闭,点击外部就会自动关闭哦~</p>
</Modal>
</div>
);
}
总结与思考
通过这个例子,我们可以看到 useRef
的强大之处:
- 操作 DOM: 这是它的老本行,直接获取组件实例或 DOM 节点。
- 存储可变值 : 它可以存储任何可变值,且值的改变不会触发组件重新渲染。这使得它非常适合用来保存那些你需要在渲染过程中保留,但又不想引起重新渲染的数据(例如 setTimeout 的 ID、上一次的 props 值等)。
"点击外部关闭"这个功能只是 useRef
的冰山一角。理解了它的这种能力,你就能在诸如集成第三方 DOM 库、管理焦点、触发动画等场景中游刃有余。
⭐ 写在最后
请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.
✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式
✅ 认为我部分代码过于老旧,可以提供新的API或最新语法
✅ 对于文章中部分内容不理解
✅ 解答我文章中一些疑问
✅ 认为某些交互,功能需要优化,发现BUG
✅ 想要添加新功能,对于整体的设计,外观有更好的建议
✅ 一起探讨技术加qq交流群:906392632
最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!