前段时间一位渡一的同学说他做右键菜单做的心态炸裂,步步是坑。
但是这个功能又非常常见,所有的前端开发工程师都会遇到类似的功能组件开发需求
所以今天,子辰就来和大家一起封装一个通用的右键菜单!
本文将教你如何使用 Vue3 和 Composition API 来封装一个通用的右键菜单组件,让你的网页更加灵活和有趣!
在开始制作右键菜单之前,我们先来看看我们的业务需求:
这些需求虽然不多,但是要实现起来细节问题还是很多的,我们需要考虑以下几个方面:
接下来,我们就一一解决这些问题。
首先的问题就是组件设计,因为不同区域的右键菜单是不同的,所以右键菜单需要跟区域进行绑定。
所以你不能这样设计:
<div>
<ContextMenu>ContextMenu>
div>
你这样设计的话这个组件在编写的过程中就非常麻烦了,它并不知道是哪个区域在使用菜单。
那么怎么设计会好点呢?其实就是用插槽:
<ContextMenu>
ContextMenu>
这样区域就很明显了,凡是在插槽里的东西都属于这个菜单的点击范围。
于是按照这样的设计,我们在使用菜单的时候就可以这样使用了:
<template>
<div class="container">
<ContextMenu class="block" :menu="[
{ label: '添加' },
{ label: '编辑' },
{ label: '删除' },
{ label: '查看' },
{ label: '复制' },
]" @select="choose1 = $event.label">
<h2>{{ choose1 }}h2>
ContextMenu>
<ContextMenu class="block" :menu="[
{ label: '员工' },
{ label: '部门' },
{ label: '角色' },
{ label: '权限' },
{ label: '菜单' },
]" @select="choose2 = $event.label">
<h2>{{ choose2 }}h2>
<ContextMenu class="block" :menu="[
{ label: '菜单1' },
{ label: '菜单2' },
{ label: '菜单3' },
{ label: '菜单4' },
]" @select="choose3 = $event.label">
<h2>{{ choose3 }}h2>
ContextMenu>
ContextMenu>
div>
template>
通过 menu 以数组的形式告诉组件有哪些菜单,然后接收一个 select 事件,当他点击某一个菜单的时候将菜单对象返回。
接收到数据后就可以做任何想做的事情了,可是任何事情哦~
我们这里就简单使用标题的形式显示了一下选择的菜单项。
这样我们就可以灵活的在不同的区域传递不同的菜单项和注册不一样的事件,而且插槽中就可以再次嵌套一个菜单区域。
组件设计好了之后,接下来的问题就是怎么去写这个组件了。
ContextMenu 组件的基础代码还是很简单的。
<template>
<div ref="containerRef">
<slot>slot>
<div class="context-menu">
<div class="menu-list">
<div @click="handleClick(item)" class="menu-item" v-for="(item, i) in menu" :key="item.label">
{{ item.label }}
div>
div>
div>
div>
template>
<script setup>
const props = defineProps({
// 接收传递进来的菜单项
menu: {
type: Array,
default: () => [],
},
});
// 声明一个事件,选中菜单项的时候返回数据
const emit = defineEmits(['select']);
script>
一番操作后就得到了这样一个效果:
第一个问题出现了,因为菜单的位置是根据鼠标点击的位置来确定的,所以最好是将菜单设置为固定定位。
但是这样就有可能出问题了,因为这个是一个通用型的组件,固定定位不出意外的话是相对与视口的,但是如果别人在使用的时候在组件外嵌套了一个元素,元素设置了 transform 属性:
<div style="transform">
<ContextMenu>
ContextMenu>
div>
我们知道这个固定定位的元素,一旦在父级元素找到了 transform,那么它就不再相对于视口了,而是相对于这个元素,到时候设置位置肯定会出问题的。
所以我们就要想办法不让这样元素在这个层级之下,让他直接在 body 下就好了。
这里就可以利用 Vue3 的
,Teleport 是一个内置组件,它可以将一个组件内部的一部分模板 “传送” 到该组件的 DOM 结构外层的位置去。
<template>
<div ref="containerRef">
<slot>slot>
<Teleport to="body">
<div class="context-menu">
<div class="menu-list">
<div @click="handleClick(item)" class="menu-item" v-for="(item, i) in menu" :key="item.label">
{{ item.label }}
div>
div>
div>
Teleport>
div>
template>
现在所有的菜单就都跑到 body 里了,这样就避免出现刚才说的隐患。
设置菜单的显示和位置就要监控组件的鼠标点击事件,这里我们将这个监控事件提取成一个 Composition API
<template>
<div ref="containerRef">
div>
template>
<script setup>
import { ref } from 'vue';
import useContextMenu from './useContextMenu';
const containerRef = ref(null);
const { x, y, showMenu } = useContextMenu(containerRef);
// etc...
script>
引入我们设置的函数 useContextMenu,只要一调用这个函数把 div 的引用传进去,然后函数就可以计算各种响应式的数据,比如横坐标 x,纵坐标 y,以及是否显示菜单 showMenu。
这些数据都有了以后事情就简单了:
<template>
<div ref="containerRef">
<slot>slot>
<Teleport to="body">
<div v-if="showMenu" class="context-menu" :style="{
left: x + 'px',
top: y + 'px',
}">
<div class="menu-list">
<div @click="handleClick(item)" class="menu-item" v-for="(item, i) in menu" :key="item.label">
{{ item.label }}
div>
div>
div>
Teleport>
div>
template>
是否显示菜单通过 v-if
控制,坐标也通过 style 传入进去。
然后我们去实现 useContextMenu 函数:
import { onMounted, onUnmounted, ref } from "vue";
export default function (containerRef) {
const showMenu = ref(false);
const x = ref(0);
const y = ref(0);
onMounted(() => {
// ...
});
onUnmounted(() => {
// ...
});
return {
showMenu,
x,
y,
};
}
这个函数就是搞定 x,y,showMenu 这三个数据并将数据返回。
首先我们在 onMounted 中监听 containerRef 的事件。
import { onMounted, onUnmounted, ref } from "vue";
export default function (containerRef) {
// etc...
onMounted(() => {
const div = containerRef.value;
div.addEventListener("contextmenu");
});
// etc...
}
你可能注意到了,这里我们监听的并非 click 事件,而是 contextmenu 事件。
因为在我们的界面上,菜单的出现并不一定是点击右键,有可能是 Alt + 左键,也会出现菜单,或者说有的键盘有一个菜单按键,通过按键也可以触发菜单。
所有说这里最好就去监控 contextmenu 事件,这是一个系统事件,只要触发了菜单的行为,它就会运行这个事件。
我们现在去写一个事件处理函数,然后把事件函数传递进去:
import { onMounted, onUnmounted, ref } from "vue";
export default function (containerRef) {
// etc...
// 事件处理函数
const handleContextMenu = (e) => {
console.log("x y >>> ", e.clientX, e.clientY);
};
onMounted(() => {
const div = containerRef.value;
// 将事件处理函数传递传入事件中
div.addEventListener("contextmenu", handleContextMenu);
});
// etc...
}
在函数中打印一下鼠标的位置:
可以看到鼠标的位置确实正常输出了,但是在右键的时候,系统菜单不应该出来的,所有我们要阻止浏览器的默认行为,而且你会发现在点击嵌套的组件时打印了两次位置,这是因为冒泡,所以我们还要阻止冒泡。
import { onMounted, onUnmounted, ref } from "vue";
export default function (containerRef) {
// etc...
const handleContextMenu = (e) => {
e.preventDefault(); // 阻止浏览器的默认行为
e.stopPropagation(); // 阻止冒泡
console.log("x y >>> ", e.clientX, e.clientY);
};
// etc...
}
可以看到浏览器的菜单和冒泡已经不存在了,那么我们在函数里要具体做的事情就是设置 showMenu 为 true,然后将 x 和 y 的值设置为鼠标位置:
import { onMounted, onUnmounted, ref } from "vue";
export default function (containerRef) {
// etc...
const handleContextMenu = (e) => {
e.preventDefault();
e.stopPropagation();
showMenu.value = true;
x.value = e.clientX;
y.value = e.clientY;
};
// etc...
}
测试后可以看到菜单可以正常显示了。
但是你会发现两个问题:
所以我们要处理 window 的 contextmenu 事件让它在打开菜单的时候关闭之前的菜单。
同时要处理 window 的 click 事件,让他关闭所有已经打开的菜单。
我们这里先处理 window 的 click 事件:
import { onMounted, onUnmounted, ref } from "vue";
export default function (containerRef) {
// etc...
// 注册一个事件函数用来关闭菜单
function closeMenu() {
showMenu.value = false;
}
onMounted(() => {
const div = containerRef.value;
div.addEventListener("contextmenu", handleContextMenu);
// 触发 window 点击事件的时候执行函数
window.addEventListener("click", closeMenu);
});
// etc...
}
可以看到,现在在空白区域点击确实可以关闭菜单了。
但实际上这样写是不好的,因为我们封装的是一个通用型组件,有可能别人在用的时候可能会给组件的父元素设置阻止冒泡:
<template>
<div class="container" @click.stop>
<ContextMenu ...>
// ...
ContextMenu>
div>
template>
可以看到,一旦设置了阻止冒泡,再次点击时就取消不掉了。
所以我们最好在捕获的阶段来处理,保证这个事件的触发:
import { onMounted, onUnmounted, ref } from "vue";
export default function (containerRef) {
// etc...
onMounted(() => {
const div = containerRef.value;
div.addEventListener("contextmenu", handleContextMenu);
// 第三个参数设置为 true 表示事件句柄在捕获阶段执行
window.addEventListener("click", closeMenu, true);
});
// etc...
}
这样我们看效果就正常了。
同理,我们处理 window 的 contextmenu 事件让它在打开菜单的时候关闭之前的菜单,当然也得在捕获阶段执行:
import { onMounted, onUnmounted, ref } from "vue";
export default function (containerRef) {
// etc...
onMounted(() => {
const div = containerRef.value;
div.addEventListener("contextmenu", handleContextMenu);
window.addEventListener("click", closeMenu, true);
// 处理 window 的 contextmenu 事件,用来关闭之前打开的菜单
window.addEventListener("contextmenu", closeMenu, true);
});
// etc...
}
设置之后菜单就只可以打开一个了。
最后一步我们需要在组建 onUnmounted 的时候清除所有事件:
import { onMounted, onUnmounted, ref } from "vue";
export default function (containerRef) {
const showMenu = ref(false);
const x = ref(0);
const y = ref(0);
const handleContextMenu = (e) => {
e.preventDefault();
e.stopPropagation();
showMenu.value = true;
x.value = e.clientX;
y.value = e.clientY;
};
function closeMenu() {
showMenu.value = false;
}
onMounted(() => {
const div = containerRef.value;
div.addEventListener("contextmenu", handleContextMenu);
window.addEventListener("click", closeMenu, true);
window.addEventListener("contextmenu", closeMenu, true);
});
onUnmounted(() => {
const div = containerRef.value;
div.removeEventListener("contextmenu", handleContextMenu);
window.removeEventListener("click", closeMenu, true);
window.removeEventListener("contextmenu", closeMenu, true);
});
return {
showMenu,
x,
y,
};
}
现在需求的 1、2、3 条我们已经实现了,就差最后一步,菜单的展开效果。
但是问题就在于每一个菜单的内容并非固定的,组件并不知道它的高度,所以高度过渡的话应该是 0 到 auto,但是 auto 这个东西是没法过渡的,因为 auto 并不是一个数值,所以这里就要用到 JS 了:
<template>
<div ref="containerRef">
<slot>slot>
<Teleport to="body">
// 使用 Transition 组件,并注册 beforeEnter 和 enter 事件
<Transition @beforeEnter="handleBeforeEnter" @enter="handleEnter">
<div v-if="showMenu" class="context-menu" :style="{ left: x + 'px', top: y + 'px' }">
<div class="menu-list">
<div @click="handleClick(item)" class="menu-item" v-for="(item, i) in menu" :key="item.label">
{{ item.label }}
div>
div>
div>
Transition>
Teleport>
div>
template>
<script setup>
// etc...
function handleBeforeEnter(el) {}
function handleEnter(el) {}
// etc...
script>
这里我们使用 Vue 的
,给它注册两个事件,beforeEnter 和 enter,这是 Transition 组件的钩子函数,它会在动画的不同时期调用这个函数,并且将元素传进去。
beforeEnter 就表示这个元素加入到页面之前,它执行的钩子函数。
function handleBeforeEnter(el) {
el.style.height = 0;
}
我们在 beforeEnter 里将元素的高度设置为 0。
enter 就是在元素加入到页面之后钩子函数。
function handleEnter(el) {
el.style.height = 'auto';
}
我们在 enter 里将元素的高度设置为 auto。
你可能会疑惑 auto 不是没有动画效果吗?
确实 auto 没有效果,但是把设置为 auto 之后我们就可以拿到它的高度了。
function handleEnter(el) {
el.style.height = 'auto';
const h = el.clientHeight;
console.log('h >>> ', h)
}
可以看到,高度已经得到了,那么我们将高度再设置为 0,然后再设置为得到的高度是不是就有过渡的效果了?
function handleEnter(el) {
el.style.height = 'auto';
const h = el.clientHeight;
el.style.height = 0;
requestAnimationFrame(() => {
el.style.height = h + 'px';
el.style.transition = '.5s';
});
}
高度设置为 0 之后,利用 requestAnimationFrame 在下一帧将高度设置为获取到的高度并设置过渡时间。
有些同学可能会说为什么要在 requestAnimationFrame 里,因为最终的渲染是等到 JS 执行完毕之后的,这是渲染主线程的知识,在我们免费的大师课里有见过,感兴趣的同学在页尾可以根据提示去看一下。
所以,如果不在 requestAnimationFrame 里写的话,是没有效果的,他只会执行最后一个样式的设置,之前相同的样式设置都会失效。
我们现在去试下效果如何:
可以看到过渡正常,但是还有一个小小的问题。
打开菜单时我们设置的过渡时间是 0.5 秒,点击空白区域关闭时,它还会等 0.5 秒才关闭。
这是因为它里边加了 transition 样式,
组件的作用是等到 transition 结束之后才会移除,所以我们要在过渡结束之后把 transition 给直接去除掉:
<template>
<div ref="containerRef">
<slot>slot>
<Teleport to="body">
// 注册一个 afterEnte 事件
<Transition @beforeEnter="handleBeforeEnter" @enter="handleEnter" @afterEnter="handleAfterEnter">
<div v-if="showMenu" class="context-menu" :style="{ left: x + 'px', top: y + 'px' }">
<div class="menu-list">
<div class="menu-item" v-for="(item, i) in menu" :key="item.label">
{{ item.label }}
div>
div>
div>
Transition>
Teleport>
div>
template>
<script setup>
// etc...
function handleAfterEnter(el) {
el.style.transition = 'none';
}
// etc...
script>
再次为
组件注册一个叫做 afterEnter 的事件,afterEnter 事件表示当进入过渡完成时调用。
所以我们在 afterEnter 事件中将 transition 设置为 none:
这样离开的时候就不会有任何的过渡效果了。
最后一步也是最简单的一步了,选择菜单项,并且选择之后还要关闭菜单:
<template>
<div ref="containerRef">
<slot>slot>
<Teleport to="body">
<Transition @beforeEnter="handleBeforeEnter" @enter="handleEnter" @afterEnter="handleAfterEnter">
<div v-if="showMenu" class="context-menu" :style="{ left: x + 'px', top: y + 'px' }">
<div class="menu-list">
<div @click="handleClick(item)" class="menu-item" v-for="(item, i) in menu" :key="item.label">
{{ item.label }}
div>
div>
div>
Transition>
Teleport>
div>
template>
<script setup>
import { ref } from 'vue';
import useContextMenu from './useContextMenu';
const props = defineProps({
menu: {
type: Array,
default: () => [],
},
});
const containerRef = ref(null);
const emit = defineEmits(['select']);
const { x, y, showMenu } = useContextMenu(containerRef);
// 菜单的点击事件
function handleClick(item) {
// 选中菜单后关闭菜单
showMenu.value = false;
// 并返回选中的菜单
emit('select', item);
}
function handleBeforeEnter(el) {
el.style.height = 0;
}
function handleEnter(el) {
el.style.height = 'auto';
const h = el.clientHeight;
el.style.height = 0;
requestAnimationFrame(() => {
el.style.height = h + 'px';
el.style.transition = '.5s';
});
}
function handleAfterEnter(el) {
el.style.transition = 'none';
}
script>
我们只需要给菜单加一个点击事件就可以,在事件里关闭菜单和返回数据就可以了。
至此右键菜单组件的封装就全部完成了。
本文我们使用 Vue3 和 Composition API 来制作了一个自定义的右键菜单组件,我们主要解决了以下几个问题:
通过这个案例,我们可以学习到 Vue3 和 Composition API 的一些新特性和优势,以及如何封装一个通用的组件。
本文来源自渡一官方公众号:Duing,欢迎关注,获取最新、最全、最深入的技术讲解
感谢你阅读本文,如果你有任何疑问或建议,请在评论区留言,如果你觉得这篇文章有用,请点赞收藏或分享给你的朋友!