本文概述
右键菜单功能,在项目中,还是一个比较常用的功能,平常的话,我们可以会选择一个库,直接使用。
不过,进一步了解实现原理,可以让我们更好的把控需求。
本文,讲述右键菜单的实现方式,有react和vue两个版本,并附上线上预览地址,和github仓库代码,有助于大家更好的理解。
预览地址和github仓库代码
- react版本预览地址:ashuai.site/reactExampl...
- react版本github地址:github.com/shuirongshu...
- vue版本预览地址:ashuai.work:8890/34
- vue版本github地址:github.com/shuirongshu...
创作不易,感谢诸位道友,点赞收藏github给个star
效果图
篇幅原因,本文的右键菜单,只有一层,带有子集的右键菜单,就是把组件进行递归处理,这里不赘述
右键菜单封装思路------react
- 菜单状态管理:定义变量,控制右键菜单的显示或关闭状态、右键菜单的位置和菜单项数据(新增、编辑、删除等项)
- 菜单点击事件监听:监听页面的点击事件(click或contextmenu),区分是点击在菜单上,还是点击在菜单外,对应阻止默认的事件,把菜单弹出层展示出来等(右键菜单关闭和打开的时机,注意,同一个页面,我们要控制不能同时出现两个右键菜单)。
- 定位处理:根据鼠标点击位置动态计算菜单定位,确保不超出视口(要做好边界值判断)
首先当页面didMount的时候,绑定事件
- 这里要把常用的事件监听,如果是click事件或者是右键事件或者是滚动事件
- 默认想要执行的逻辑是关闭菜单
- 不过要区分一下,事件的触发位置,是在菜单内部,还是外部(外部执行关闭菜单逻辑,内部交由菜单上的事件去控制)
js
useEffect(() => {
const menuElement = menuRef.current;
if (!menuElement) return;
menuElement.addEventListener('contextmenu', handleContextMenu);
window.addEventListener('click', closeMenu, true);
window.addEventListener('contextmenu', closeMenu, true);
window.addEventListener('scroll', closeMenu, true);
return () => {
menuElement.removeEventListener('contextmenu', handleContextMenu);
window.removeEventListener('click', closeMenu, true);
window.removeEventListener('contextmenu', closeMenu, true);
window.removeEventListener('scroll', closeMenu, true);
};
}, [handleContextMenu, closeMenu]);
关闭菜单逻辑
js
// 关闭菜单
const closeMenu = useCallback((e) => {
// 检查点击的是否是菜单内部的元素
const isMenuClick = e.target.closest('#contextMenuWrap');
// 如果点击的是菜单内部,不关闭菜单(让菜单项自己处理)
if (isMenuClick) {
return;
}
// 点击菜单外部,关闭菜单
setMenuInfo(prev => ({ ...prev, show: false }));
}, []);
注意,这里使用e.target.closest去判断事件的触发是否是菜单内部还是外部
e.target.closest('#contextMenuWrap') 触发事件的元素是否在某个特定容器内
- 假设我有一个如下的结构,层级有ABC三个
html
<div class="A">
外层A
<div class="B">
中层B
<div class="C">
内层C
</div>
</div>
</div>
- 我的需求是点击的时候,要区分是不是B层级外点击的,还是在B层级内点击的
- 这种需求,可以直接使用closest进行快速区分,简洁高效
js
document.onclick = function (e) {
/**
* 查找点击位置往外层找,是否有B类。
* 若是能够找到,说明在B内部点击的
* 若是找不到,说明在B外部点击的
* */
const found = e.target.closest('.B');
console.log(found)
}
效果图
如上图,大家会发现,当在B内部点击的时候,才能够找到最近的B类的div元素,所以通过这种方式去判断,事件触发的位置,是在右键菜单内部还是外部
也就对应上文中的const isMenuClick = e.target.closest('#contextMenuWrap');
使用ReactDOM.createPortal把右键菜单层传送到body下
ReactDOM.createPortal 定义:
ReactDOM.createPortal()
是 React 提供的一个特殊 API- 用于将组件的渲染内容「传送」到当前 DOM 树中的指定位置,而不是默认的父组件 DOM 层级下。
- 也就是说,它能让组件在 JSX 中逻辑上属于某个组件树,但实际渲染时会被插入到 DOM 中的另一个位置(比如
<body>
标签下)。 - 常用语处理弹窗、模态框、悬浮层、提示框等,就是让元素能正确显示在最上层,避免被父元素的 overflow: hidden 或 z-index 等样式限制(让位置在最顶级,不被遮挡)
- 类似的,Vue中也提供了teleport组件可以实现相应的功能
- 如:
<teleport to="body"></teleport>
js
// 主右键菜单组件
const ContextMenu = ({ children, menus = [] }) => {
const [menuInfo, setMenuInfo] = useState({
show: false, // 打开或关闭右键菜单
x: 0, // 右键菜单的x和y的坐标
y: 0
});
const menuRef = useRef(null); // 菜单相关的dom引用
const menuWrapRef = useRef(null);
const margin = 36; // 定义菜单与视口边缘的最小间距,防止太靠边会展示不全
// ...... 省略部分代码
// 菜单项点击处理
const handleMenuItemClick = useCallback(() => {
setMenuInfo(prev => ({ ...prev, show: false }));
}, []);
// 创建Portal到body(Vue中的<teleport to="body">也是这个意思)
const menuPortal = ReactDOM.createPortal(
<div
className={`${styles.menuWrap} ${menuInfo.show ? styles.show : ''}`}
id="contextMenuWrap"
ref={menuWrapRef}
style={{ left: `${menuInfo.x}px`, top: `${menuInfo.y}px` }}
>
{menus.map((menu, index) => (
<MenuItem
key={index}
menu={menu}
onMenuItemClick={handleMenuItemClick}
/>
))}
</div>,
document.body
);
return (
<div className={styles.contextMenu} ref={menuRef}>
{children}
{menuInfo.show && menuPortal}
</div>
);
};
export default ContextMenu;
通过props的children实现外层把组件传递过来(类似vue的插槽功能)
最外层使用这个右键菜单
通过传递菜单项数组menus,控制右键菜单的有哪些内容
js
import ContextMenu from './ContextMenu'
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
const RightMenu = () => {
const menus = [
{
label: '新增新增新增新增新增新增新增新增新增新增新增新增新增新增新增^_^',
icon: PlusOutlined,
onClick: (menu, e) => { console.log('新增', menu) }
},
{
label: '编辑',
icon: EditOutlined,
onClick: (menu, e) => { console.log('编辑', menu) },
},
{
label: '删除',
icon: DeleteOutlined,
onClick: (menu, e) => { console.log('删除', menu) },
disabled: true
}
];
return (
<div className="boxWrap">
<ContextMenu menus={menus}>
<div style={{ height: '100vh', border: '1px solid #666' }}>
我是box
</div>
</ContextMenu>
</div>
);
};
export default RightMenu;
实际上,Modal弹出层也是类似的思路操作,和右键菜单的使用方式类似
如下伪代码:
js
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
>
<h2>这是一个弹窗</h2>
<p>我被渲染到了 body 下的 portal-root 中</p>
</Modal>
当右键的时候,计算菜单位置,并显示出来
逻辑控制如下:
- 当用户右键的时候,阻止默认的右键事件
- 同时计算右键的xy的坐标(要让右键菜单显示的位置,正好在用户右键的位置)
- 这里笔者为了能够正确地拿到菜单dom的引入
- 先让菜单dom显示在不可视区域
- 而后,加上了一个事件循环异步等待
await new Promise((resolve) => setTimeout(resolve, 0));
(相当于vue的nexttick) - 最后,通过菜单的宽高等,计算菜单出现的坐标(要注意边界值的控制)
- 最后,再显示右键菜单
- 如下代码:
js
// 计算菜单正确位置
const calculateMenuPosition = useCallback((clientX, clientY, actualWidth, actualHeight) => {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let x = clientX;
let y = clientY;
// 水平边界检查
if (x + actualWidth + margin > viewportWidth) {
x = viewportWidth - actualWidth - margin;
}
// 垂直边界检查
if (y + actualHeight + margin > viewportHeight) {
y = viewportHeight - actualHeight - margin;
}
// 确保菜单不超出左/上边界
x = Math.max(margin, x);
y = Math.max(margin, y);
return { x, y };
}, [margin]);
// 处理右键菜单逻辑
const handleContextMenu = useCallback(async (e) => {
e.preventDefault(); // 阻止浏览器默认右键菜单
e.stopPropagation(); // 阻止事件冒泡,避免触发外部关闭逻辑
const clickX = e.clientX; // 获取点击的x坐标
const clickY = e.clientY; // 获取点击的y坐标
// 首先在屏幕外先显示菜单以获取实际尺寸
setMenuInfo({
show: true,
x: -9999,
y: -9999
});
// 然后,再等待下一个事件循环,确保DOM已渲染(相当于vue的nexttick)
await new Promise((resolve) => setTimeout(resolve, 0));
if (menuWrapRef.current) {
const rect = menuWrapRef.current.getBoundingClientRect();
const actualWidth = rect.width;
const actualHeight = rect.height;
// 计算菜单的正确位置并重新定位
const { x, y } = calculateMenuPosition(clickX, clickY, actualWidth, actualHeight);
// 最后,再在正确的右键菜单点击的位置,显示出来菜单(从可视区域外,到可视区域里)
setMenuInfo({
show: true,
x,
y
});
}
}, [calculateMenuPosition]);
菜单项组件的事件抛出触发和渲染控制
- 最后,再在菜单项目组件中控制即可
- 比如,若菜单项是禁用的,不触发操作
- 非禁用的,就触发父组件(最外层)的点击事件、而后关闭菜单
js
// 菜单项组件
const MenuItem = ({ menu, onMenuItemClick }) => {
const handleClick = (e) => {
if (menu.disabled) {
console.warn('菜单项禁用,点击不触发事件动作');
return;
}
menu.onClick(menu, e); // 触发父组件(最外层)的点击事件
onMenuItemClick?.(); // 点击后关闭菜单
};
return (
<div
className={`${styles.menuItem} ${menu.disabled ? styles.disabled : ''}`}
data-disabled={menu.disabled}
onClick={handleClick}
>
{/* 菜单图标 */}
{menu.icon && (
<div className={styles.menuItemIcon}>
{React.createElement(menu.icon)}
</div>
)}
{/* 菜单文本 */}
{menu.label && (
<div className={styles.menuItemLabel}>
{menu.label}
</div>
)}
</div>
);
};
至此,右键菜单封装思路------react已经完成,接下来,我们简单提一下vue的,思路也是差不多的
右键菜单封装思路------vue3
外层用法也是套一层壳子方式的用法
html
<template>
<div class="boxWrap">
<ContextMenu :menus="menus">
<div class="box">
我是box
</div>
</ContextMenu>
</div>
</template>
<script setup>
import { ref, onMounted } from "vue";
import ContextMenu from "./ContextMenu.vue";
const menus = ref([
{
label: '新增新增新增新增新增^_^',
icon: 'Plus',
onClick: (menu, e) => {
// console.log('新增', menu)
// console.log('新增', e)
}
},
{
label: '编辑',
icon: 'Edit',
onClick: (menu, e) => {
// console.log('编辑', menu)
// console.log('编辑', e)
},
},
{
label: '删除',
icon: 'Delete',
onClick: (menu, e) => {
// console.log('删除', menu)
// console.log('删除', e)
},
disabled: true
}
])
</script>
里层组件封装参见代码中的注释
Vue右键菜单组件的html部分
html
<template>
<div class="contextMenu" ref="menuRef">
<slot></slot>
<!-- teleport 到 body,通过固定 id 确保 scoped 样式生效 -->
<teleport to="body">
<transition name="fade">
<div
class="menuWrap"
id="contextMenuWrap"
v-if="menuInfo.show"
ref="menuWrapRef"
:style="{ left: menuInfo.x + 'px', top: menuInfo.y + 'px' }"
>
<div
class="menuItem"
v-for="menu in menus"
:key="menu.label"
@click="(e) => handleClick(menu, e)"
:class="{ disabled: menu.disabled }"
:data-disabled="menu.disabled"
>
<!-- 菜单图标 -->
<div class="menuItemIcon" v-if="menu.icon" @click.stop>
<el-icon>
<component :is="menu.icon" />
</el-icon>
</div>
<!-- 菜单文本 -->
<div class="menuItemLabel" v-if="menu.label" @click.stop>
{{ menu.label }}
</div>
</div>
</div>
</transition>
</teleport>
</div>
</template>
Vue右键菜单的js部分
js
<script setup>
import { ref, reactive, onMounted, onUnmounted, nextTick } from "vue";
// 引入 Element Plus 图标组件(确保项目已安装并注册 Element Plus)
import { ElIcon } from "element-plus";
// 组件 props 定义
const props = defineProps({
menus: {
type: Array,
default: () => [],
// 校验菜单格式:确保每个菜单项至少包含 label,可选 icon/disabled/onClick
validator: (value) => {
return value.every((menu) => {
if (!menu.label) return false;
return true;
});
},
},
});
// 组件内部引用
const menuRef = ref(null);
const menuWrapRef = ref(null);
// 菜单状态管理(显示/隐藏、位置、尺寸)
const menuInfo = reactive({
show: false,
x: 0,
y: 0,
width: 0,
height: 0,
});
// 菜单与视口边缘的最小间距
const margin = 36;
/**
* 更新菜单实际尺寸(宽度/高度)
*/
const updateMenuSize = () => {
if (menuWrapRef.value) {
const rect = menuWrapRef.value.getBoundingClientRect();
menuInfo.width = rect.width;
menuInfo.height = rect.height;
}
};
/**
* 处理右键菜单触发(阻止默认右键菜单,计算菜单位置)
*/
const handleContextMenu = async (e) => {
// 阻止浏览器默认右键菜单
e.preventDefault();
// 阻止事件冒泡,避免触发外部关闭逻辑
e.stopPropagation();
// 显示菜单
menuInfo.show = true;
// 等待 DOM 渲染完成(确保 teleport 元素已挂载到 body)
await nextTick();
// 再等待一次微任务,确保过渡动画初始帧完成,尺寸计算准确
await new Promise((resolve) => setTimeout(resolve, 0));
// 更新菜单尺寸
updateMenuSize();
// 计算视口边界
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// 初始位置:鼠标点击坐标
let x = e.clientX;
let y = e.clientY;
// 水平边界检查:避免菜单超出右边界
if (menuInfo.width && x + menuInfo.width + margin > viewportWidth) {
x = viewportWidth - menuInfo.width - margin;
}
// 垂直边界检查:避免菜单超出下边界
if (menuInfo.height && y + menuInfo.height + margin > viewportHeight) {
y = viewportHeight - menuInfo.height - margin;
}
// 确保菜单不超出左/上边界
x = Math.max(margin, x);
y = Math.max(margin, y);
// 更新菜单位置
menuInfo.x = x;
menuInfo.y = y;
};
/**
* 关闭菜单(禁用项点击不关闭)
*/
const closeMenu = (e) => {
const isDisabledItem = e.target.closest('.menuItem[data-disabled="true"]');
if (!isDisabledItem) {
menuInfo.show = false;
}
};
/**
* 处理菜单项点击(执行自定义回调并关闭菜单)
*/
const handleClick = (menu, e) => {
if (!menu.disabled && typeof menu.onClick === "function") {
menu.onClick(menu, e);
}
// 点击后关闭菜单(无论是否执行回调,非禁用项都关闭)
if (!menu.disabled) {
menuInfo.show = false;
}
};
// 组件挂载:绑定事件监听
onMounted(() => {
// 给触发区域绑定右键事件
menuRef.value?.addEventListener("contextmenu", handleContextMenu);
// 点击页面任意位置关闭菜单(捕获阶段监听,确保优先执行)
window.addEventListener("click", closeMenu, true);
// 右键页面其他位置关闭菜单
window.addEventListener("contextmenu", closeMenu, true);
// 滚动页面时关闭菜单
window.addEventListener("scroll", closeMenu, true);
});
// 组件卸载:移除事件监听(避免内存泄漏)
onUnmounted(() => {
menuRef.value?.removeEventListener("contextmenu", handleContextMenu);
window.removeEventListener("click", closeMenu, true);
window.removeEventListener("contextmenu", closeMenu, true);
window.removeEventListener("scroll", closeMenu, true);
});
</script>
样式部分略,参见github完整代码