前言: 最近在公司 PC 端的项目中使用到了右键出现菜单选项这样的一个工作需求,并且自己现在也在实现一个偶然迸发的 idea( 想用前端实现一个 windows 系统从开机到桌面的 UI),其中也要用到右键弹出菜单这样的一个功能,个人觉得这个实现还不错,特来分享🎁。
tips: 我个人是喜欢使用图文来讲解知识点的,相比于直接讲概念,我个人更倾向于使用费曼学习法来讲解某一个功能的实现过程,因为我也是刚从一只菜鸟走过来,所以我更加清楚一个新手在去学习一个全新的知识的时候,他其实不是需要你给他讲实现原理,而是你需要作为一个 “引路人” 让他先简单知道这个知识是用来干什么的,后面随着他自己一步一步的深入了解,他会自己慢慢领悟其中的原理。
一. 前期准备
-
我们需要清楚的认识到,这种用户点击右键然后弹出菜单的动作行为是非常不适合将组件写死在页面上,然后通过使用
v-show
或者v-if
去控制它的出现和消失的,我们需要想办法使用函数式去控制它的行为。 -
在此之前,你需要准备两个文件来和我一起实现这个右键菜单。
-
预览图:
二. 右键菜单的样式
-
菜单样式的书写不是我们本文的重点,你可以快速在 Menu.vue 里简单书写你自己喜欢的一个简单 div 即可,我们的重点是在于如何右键弹出它。你也可以在下方的源码标题中直接复制我书写的样式,不过你需要使用
UnoCSS
来支持内敛样式属性。 -
如果你不知道如何使用
Unocss
,你可以参考这篇文章的内容 手把手教你实现一个代码仓库里面有详细的过程来帮助你去完成代码仓库的构建,其中包括了Unocss
如何引入和使用。)
三. h 函数 和 render 函数的使用
-
现在我们已经完成了
Menu.vue
,文件的内容,接下来我们需要转头去书写index.ts
内的内容。 -
在此之前,我们需要引入两个
vue
暴露给我们的,十分重要的函数。h,和 render
。
-
如果你之前读过我另外三篇文章,我相信你对这两个函数的使用一定不陌生,但是为了照顾之前没有了解过的读者,我还是会在接下来的内容中简单介绍一下。不过我还是建议你去看一看下面的实现方式,你一定会有不一样的收获。
- Vue3 如何实现一个 Toast 小弹窗
- Vue3 如何实现一个全局搜索框
- Vue3 如何实现一个Dialog
-
接下来我简单的介绍一下,这两个函数的使用方式。你需要知道一个前提知识,我们在
template
标签里书写的样式,最终都会被转变成虚拟dom
。
这里面书写的div
其实是和我们在浏览器里看到的div
“并不是同一个”div
,只不过经过vue
帮我们进行了处理,让它们的表现形式显得一样了。 -
那
template
是经过了怎样的处理呢?其实就是经过了h
函数。然后h
函数会返回一个特殊的 JS 对象,这个特殊的对象就是我们所说的虚拟dom。 -
那我们在这个场景怎么使用呢?首先你需要在
index.ts
文件内引入我们刚刚书写的右键菜单的样式。然后将这个组件作为h
函数的第一个参数放入,对,就是这么简单。这个vnode
就是我们需要用到的虚拟 dom。
-
有了虚拟 dom 还不行,我们得告诉 vue 我们要把这个虚拟 dom 渲染到什么地方,这时候就需要用到
render
函数。render
函数要做的事情比较复杂,不过在这里你只需要简单的知道。render
函数会将一个 虚拟dom 转换成一个真实的 dom 节点。既然需要一个虚拟 dom,那我刚刚正好用h
函数转换了得到了一个,于是我们自然而然可以写出下面的代码。
-
怎么回事?怎么还报错了呢?
我们看一下报错信息,发现这个render
函数需要两个参数,我们只给了一个。那么第二个参数是什么呢?我们思考一下,现在这个dom
已经被转换成真实的 dom 节点了,但是目前它不知道自己应该被渲染到哪里,什么意思呢?其实理解起来很简单。
就好比你现在是一个外卖员,你到了餐厅取餐,餐厅人员说你去吧,你端着手上的一份外卖餐一脸茫然,我去哪啊?
就对应着,vue 帮你处理好了这个虚拟节点,但是你没告诉它应该在哪里去渲染。 -
知道原因就好办了,我们直接创建一个空的
div
,先让render
用着。
四. 右键弹出菜单的实现
-
在进行下面的功能之前,你需要知道一个前提知识。
如上面的gif
所示,我们可以看到,浏览器本身是存在默认的右键点击事件的。在这里我们需要取消浏览器自身的右键弹出菜单事件。 -
我们再具体一点讲,其实我们需要做的就是替换掉浏览器默认的右键事件。通过查阅 MDN 我们可以得知,window 对象存在一个叫做
contextMenus
的事件。
-
那接下来就好办了,我们直接替换这个事件为我们的自定义事件即可。(这里阻止默认事件需要调用 e.preventDefault 方法。)
然后我们在随便一个全屏的组件引入这个函数,我们来测试一下,看看效果
-
嗯,现在已经不会弹出浏览器默认的菜单了。那么接下来要做的就是如何让我们写好的菜单呈现到页面上。首先第一点,我们需要明确告诉这个组件你的父元素是谁。
我们上面只是临时创造了一个简单的div
,但是目前我们还是没告诉它应该渲染到哪里。处理方法也很简单,这里我提前创建好了一个很简单的页面,并且设置好了一个唯一 ID。
-
那么我们就可以非常轻松的获得这个元素。
-
现在父元素也有了,只需要将我们的
containerEl
元素放入到scope
里即可。
不过你需要知道的是,我们这个元素是不应该出现在正常的文档流里的,因为它的位置是不固定的,所以我们在放进去scope
元素之前,应该给它处理成绝对定位类型的元素。
-
对了,这里需要注意,我们需要给
scope
设置一个relative
属性,来告诉我们的containerEl
它要在谁的范围内是绝对定位。
-
接下来我们进入到我们的
scope
组件内引入这个函数,调用一下看看效果。
ok,现在已经实现我们的右键弹出菜单的基本功能了。
五. 菜单位置出现的位置
-
在这里我们需要用到
clientX,和 clientY
这两个属性。
-
如果你是第一次看到这个属性,那么我简单介绍一下。
假设我在屏幕的上点击了一下(类比上图的红点出),那么此时这个点到屏幕最左边的距离就是clientX
,同理到屏幕顶部的距离就是clientY
。 -
聪明的你一定想到了,那我此时将
containerEl
的top
和left
的值分别设置成这两个属性的值,不就恰好会让菜单出现在我们的右边吗?我们试一下。
然后看看效果:
-
目前看起来一切正常,但是我们需要考虑一个边界情况。
当我们距离屏幕右侧过近的时候,此时右键会导致有部分内容被遮挡。所以我们要想办法解决这个边界情况。
六. 解决右侧过近的问题
-
不要觉得很难,其实目前我们要做的事情很简单。
-
如上图,我们仅仅只需要去判断
scope 的 clientWidth 的长度 - clientX 的长度= 是否大于containerEl 的 offsetWidth ?
如果大于,则调转left
的方向为right
,并设置right=0px
即可。 -
如果上面所说的
offsetWidth
和clientWidth
你还不了解。我强烈建议你请点击这篇博文先去了解清楚这几个width
属性到底代表着什么意思,因为对于前端开发来说,这是极其重要的几个属性。如果你之后要接触移动端,那么这是你必须掌握的知识点。
你必须知道的 clientWdith,scrollWidth,offsetWidth -
既然知道了原理,那么代码写起来就非常简单了,在此之前在这里我们需要调整一下
scope.appendChild
的执行时机。
我们测试一下效果。
七. 增强该函数的健壮性
-
目前这个框我们无法确保它的唯一性,所以我们还需要改造一下这个函数。
-
增加一个变量
isShow
,我们需要知道当前的Menu
菜单是否正在展示。
-
将
containerEl
由const
声明变为let
声明。并将创造时机延迟到调用右键时再创建,这样我们就能保证每次右键制造的这个Menu
组件是都是全新的。(不然就会出现沿用上一次 css 属性,导致样式错乱的 bug )
-
获取
scope
元素的时机也推迟到用户点击右键的时候再获取。(因为下面的close
函数也需要用到这个变量)
-
拆分两个函数,一个打开
openMenu
函数,一个关闭函数closeMenu
。
-
最后在
window.oncontextmenu
的匿名函数里去调取这两个函数。
-
然后我们将这三个变量暴露出去。
八. 右键菜单的使用方法
-
我们进到
scope
的.vue
组件内,引入。
-
这样我们既可以通过右键创建这个菜单栏,也可以自己在合适的时间去做一些逻辑判断手动打开。
-
效果如下
源码
- Menu.vue 的源码。
<script lang="ts" setup>
import { ref } from "vue"
const menuItemsGroup = [
{
name: "查看(V)",
arrow: true,
action: () => {
console.log("查看")
},
},
{
name: "排序方式(O)",
arrow: false,
action: () => {
console.log("刷新")
},
},
{
name: "刷新(E)",
arrow: false,
action: () => {
console.log("刷新")
},
},
{
name: "粘贴(P)",
arrow: false,
action: () => {
console.log("刷新")
},
},
{
name: "粘贴快捷方式(S)",
arrow: false,
action: () => {
console.log("刷新")
},
},
{
name: "新建(W)",
arrow: false,
action: () => {
console.log("刷新")
},
},
{
name: "个性化(R)",
arrow: false,
action: () => {
console.log("刷新")
},
},
]
</script>
<template>
<div
class="w-17rem bg-#ECECEC flex flex-col py-0.5rem shadow-[4px_4px_5px_2px_rgba(0,0,0,0.3)]"
>
<div
v-for="(item, i) in menuItemsGroup"
:key="i"
@click="item.action"
class="w-full h-2.5rem px-3rem text-1.5rem leading-2.5rem text-black hover:bg-white mb-0.3rem"
:class="[3, 5, 6].includes(i) ? `b-t-1px b-gray` : `static`"
>
<span>{{ item.name }}</span>
</div>
</div>
</template>
- 这是 openContextMenus 的源码。
import { h, render } from "vue"
import Menu from "./Menu.vue"
export function openContextMenus() {
let isShow = false
let scope: HTMLElement | null // 拿到桌面元素
let containerEl: HTMLDivElement // 创建一个容器元素,给 render 先用着
window.oncontextmenu = function (e: MouseEvent) {
e.preventDefault()
if (isShow) closeMenu()
openMenu(e)
}
//tips: open the menu
function openMenu(e: MouseEvent) {
scope = document.getElementById("PCDesktop")
containerEl = document.createElement("div")
const vnode = h(Menu)
render(vnode, containerEl) //将 vnode 传递给 render 函数
containerEl.style.position = "absolute"
scope?.appendChild(containerEl) // 1. 为了拿到 offsetWidth,因为只有出现在浏览器才会产生 offsetWidth 属性值,我们需要先渲染出真实 dom
const { offsetWidth } = containerEl //2 .取出 containerEl 的真实宽度
const { clientWidth } = scope! //3. 获取父元素的 clientWidth 准备进行计算
const { clientX, clientY } = e //4. 取出 click 时鼠标的坐标
const _X = clientWidth - clientX > offsetWidth ? "left" : "right" //调整方向
const _X_offset = clientWidth - clientX // 如果是需要显示在左边,则需要获取当前的差值
containerEl.style.top = `${clientY}px`
containerEl.style[_X] = _X === "left" ? `${clientX}px` : `${_X_offset}px`
isShow = true
}
//tips: close the menu
function closeMenu() {
if (isShow) {
render(null, containerEl)
scope?.removeChild(containerEl)
console.log("清楚")
isShow = false
}
}
return {
isShow,
openMenu,
closeMenu,
}
}
结语
最近在实现一个 window
的全套 UI
,代码开源到了 github
。
我会在之后一直更新类似的内容,包括拖拽的实现。
如果你觉得本文对你有帮助,还希望点个赞
赠人玫瑰,手有余香🌹