如何用 Vue3 打造一个通用的右键菜单组件,让你的网页更加灵活和有趣

前段时间一位渡一的同学说他做右键菜单做的心态炸裂,步步是坑。

但是这个功能又非常常见,所有的前端开发工程师都会遇到类似的功能组件开发需求

所以今天,子辰就来和大家一起封装一个通用的右键菜单!

本文将教你如何使用 Vue3 和 Composition API 来封装一个通用的右键菜单组件,让你的网页更加灵活和有趣!

业务需求

在开始制作右键菜单之前,我们先来看看我们的业务需求:

  1. 右键菜单是一个可复用的组件
  2. 根据不同的区域生成不同的菜单
  3. 菜单区域可以嵌套
  4. 菜单在展开时需要高度过渡,收起时不需要过渡

这些需求虽然不多,但是要实现起来细节问题还是很多的,我们需要考虑以下几个方面:

  • 组件设计:如何让组件能够接收不同的菜单项和事件,并且能够嵌套使用。
  • 菜单显示与位置:如何根据鼠标点击的位置来显示菜单,并且避免菜单超出视口或者被其他元素遮挡。
  • 菜单展开动画:如何实现菜单高度的过渡效果,并且保证动画流畅和自然。
  • 菜单选择:如何让用户能够选择菜单项,并且返回相应的数据。

接下来,我们就一一解决这些问题。

组件设计

首先的问题就是组件设计,因为不同区域的右键菜单是不同的,所以右键菜单需要跟区域进行绑定。

所以你不能这样设计:

<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 事件,当他点击某一个菜单的时候将菜单对象返回。

接收到数据后就可以做任何想做的事情了,可是任何事情哦~

image.png

我们这里就简单使用标题的形式显示了一下选择的菜单项。

这样我们就可以灵活的在不同的区域传递不同的菜单项和注册不一样的事件,而且插槽中就可以再次嵌套一个菜单区域。

组件设计好了之后,接下来的问题就是怎么去写这个组件了。

实现菜单组件

ContextMenu 组件的基础代码还是很简单的。

<template>
  <div ref="containerRef">
    <!-- 定义插槽,传递的内容就要显示在插槽之中 -->
    <slot></slot>
    <!-- 设置一个 div 用来显示菜单 -->
    <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>
    <!-- etc... -->
  </ContextMenu>
</div>

我们知道这个固定定位的元素,一旦在父级元素找到了 transform,那么它就不再相对于视口了,而是相对于这个元素,到时候设置位置肯定会出问题的。

所以我们就要想办法不让这样元素在这个层级之下,让他直接在 body 下就好了。

这里就可以利用 Vue3 的 <Teleport>,Teleport 是一个内置组件,它可以将一个组件内部的一部分模板 “传送” 到该组件的 DOM 结构外层的位置去。

<template>
  <div ref="containerRef">
    <slot></slot>
    <!-- 通过 Teleport 将菜单传送到 body 中  -->
    <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">
    <!-- etc... -->
  </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...
}

测试后可以看到菜单可以正常显示了。

但是你会发现两个问题:

  1. 不同区域的菜单是同时存在的
  2. 在空白区域左键时菜单并没有被关闭

所以我们要处理 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 的 <Transition>,给它注册两个事件,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 结束之后才会移除,所以我们要在过渡结束之后把 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>

再次为 <Transition> 组件注册一个叫做 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 来制作了一个自定义的右键菜单组件,我们主要解决了以下几个问题:

  • 组件设计:我们使用插槽来让组件能够接收不同的菜单项和事件,并且能够嵌套使用。
  • 菜单显示与位置:我们使用 contextmenu 事件来根据鼠标点击的位置来显示菜单,并且使用 Teleport 来避免菜单被其他元素遮挡或者影响定位。
  • 菜单展开动画:我们使用 Transition 组件和 JS 来实现菜单高度的过渡效果,并且保证动画流畅和自然。
  • 菜单选择:我们使用 emit 事件来让用户能够选择菜单项,并且返回相应的数据。

通过这个案例,我们可以学习到 Vue3 和 Composition API 的一些新特性和优势,以及如何封装一个通用的组件。

本文来源

本文来源自渡一公众号:Duing,欢迎关注,获取超新超深入的技术讲解

感谢你阅读本文,如果你有任何疑问或建议,请在评论区留言,如果你觉得这篇文章有用,请点赞收藏或分享给你的朋友!

  • 6
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
当然,我可以帮你写一个用Vue 3编写的自定义菜单组件。以下是一个简单的示例: 1. 首先,创建一个名为`ContextMenu.vue`的组件文件。 ```vue <template> <div class="context-menu" :style="{ top: `${top}px`, left: `${left}px` }"> <ul> <li v-for="(item, index) in items" :key="index" @click="handleItemClick(item.action)"> {{ item.label }} </li> </ul> </div> </template> <script> export default { props: { items: { type: Array, required: true }, top: { type: Number, required: true }, left: { type: Number, required: true } }, methods: { handleItemClick(action) { // 执行相应的操作 if (action) { this[action](); } }, // 示例操作函数 exampleAction() { console.log("执行了示例操作"); } } }; </script> <style scoped> .context-menu { position: fixed; background-color: #fff; border: 1px solid #ccc; padding: 8px; } </style> ``` 2. 在需要使用菜单的地方,引入并使用`ContextMenu`组件。 ```vue <template> <div class="container" @contextmenu.prevent="showContextMenu($event)"> 点击此处显示菜单 <ContextMenu v-if="showMenu" :items="menuItems" :top="menuTop" :left="menuLeft" /> </div> </template> <script> import ContextMenu from "@/components/ContextMenu.vue"; export default { components: { ContextMenu }, data() { return { showMenu: false, menuItems: [ { label: "菜单项1", action: "exampleAction" }, { label: "菜单项2", action: "exampleAction" }, { label: "菜单项3", action: "exampleAction" } ], menuTop: 0, menuLeft: 0 }; }, methods: { showContextMenu(event) { event.preventDefault(); this.showMenu = true; this.menuTop = event.clientY; this.menuLeft = event.clientX; }, exampleAction() { console.log("执行了示例操作"); } } }; </script> <style scoped> .container { width: 200px; height: 200px; background-color: lightgray; } </style> ``` 这个示例中,我们创建了一个`ContextMenu`组件,它接收一个菜单项数组和菜单显示的位置作为props。当点击触发`@contextmenu.prevent`事件时,显示菜单,并根据鼠标位置设置菜单的top和left样式。 请注意,这只是一个简单的示例,你可以根据自己的需求对菜单样式和行为进行进一步的定制。希望对你有所帮助!如果你还有其他问题,请随时提问。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值