一般在开发中使用的是声明式组件,可以根据相应的条件来显示隐藏该组件。但是在很多时候会觉得这种方法会显得非常笨重,每次使用都需要引入组件、声明变量和定义方法来控制显示隐藏。所以这时候就需要命令式组件,可以非常方便的使用,就如一些组件库中的弹窗、通知、提示框等组件,通过函数就能直接使用,不需要一大堆的麻烦事。
什么是命令式组件
直接通过 js 代码控制组件的创建和销毁,通过暴露的函数进行操作就行。具有更高的灵活性和易用性。接下来展示不同版本的 右键菜单组件,体验一下如何开发命令式组件
Vue2
创建文件夹以及文件:在 components 目录下创建 RightMenu 文件夹,并在其中创建 index.js 和 index.vue 文件。
index.vue 文件
<template>
<div id="contextmenu" class="contextmenu">
<div
class="contextmenu-item"
:class="{ disabled: item.disabled }"
v-for="item in menuList"
:key="item.id"
@click="handleClick(item)">
{{ item.name }}
</div>
</div>
</template>
<script>
export default {
props: {
menuList: {
type: Array,
default: () => []
},
clientY: {
type: Number,
default: 0
},
clientX: {
type: Number,
default: 0
},
handleClick: {
type: Function,
default: () => { }
}
},
data() {
return {
contextmenuDom: null
}
},
mounted() {
document.addEventListener('click', this.closecContextmenuFn)
this.openContextmenuFn()
},
beforeDestroy() {
document.removeEventListener('click', this.closeContextmenuFn)
},
methods: {
closecContextmenuFn(e) {
const contextmenuItemDoms = Array.from(document.querySelectorAll('.contextmenu-item'))
// // 判断点击的元素是否是上下文菜单的DOM元素,如果是,则不关闭菜单
if (contextmenuItemDoms.includes(e.target) || e.target === this.contextmenuDom) return
this.contextmenuDom.remove()
},
openContextmenuFn() {
this.$nextTick(() => {
// 获取上下文菜单的DOM元素
this.contextmenuDom = document.querySelector('#contextmenu')
// 设置菜单的高度为自动,以便获取其实际高度
this.contextmenuDom.style.height = 'auto'
// 获取菜单的高度和宽度
const { height, width } = this.contextmenuDom.getBoundingClientRect()
// 先将菜单的高度设置为0,避免初始显示影响定位计算
this.contextmenuDom.style.height = 0
// 根据鼠标点击位置和菜单高度,决定菜单的上边距
if (this.clientY + height > window.innerHeight) {
this.contextmenuDom.style.top = this.clientY - height + 'px'
} else {
this.contextmenuDom.style.top = this.clientY + 'px'
}
// 根据鼠标点击位置和菜单宽度,决定菜单的左边距
if (this.clientX + width > window.innerWidth) {
this.contextmenuDom.style.left = this.clientX - width + 'px'
} else {
this.contextmenuDom.style.left = this.clientX + 'px'
}
// 强制浏览器重绘菜单,以便正确显示
this.contextmenuDom.scrollHeight
// 最后设置菜单的高度为其实际高度,确保菜单完全显示
this.contextmenuDom.style.height = height + 'px'
})
}
}
}
</script>
<style lang="scss" scoped>
.contextmenu {
position: fixed;
background-color: #fff;
padding: 5px 0;
font-size: 12px;
z-index: 99;
border-radius: 3px;
overflow: hidden;
transition: height 0.3s;
box-shadow: 2px 2px 5px #dcdcdc;
.contextmenu-item {
padding: 8px 20px;
border-radius: 3px;
cursor: pointer;
&:hover {
background-color: #e4e4e4;
}
&.disabled {
color: #ccc;
cursor: not-allowed;
&:hover {
background-color: transparent;
}
}
}
}
</style>
此右键菜单组件还实现了高度过渡动画,可以学习一下不定高的怎么才能有过渡效果,当然高度过渡还有很多方法,但我认为这是一个比较完美的方案
index.js 文件
主要是用 Vue.extend 创建构造器 结合 h 函数 实现自定义组件,并且使用了 分别导出 以及 默认导出,导入时也可以 分别导入 以及 导入全部。
import Vue from 'vue'
import RightMneu from './index.vue'
let RightMneuInstance = null
/**
* 功能:实例化并显示右键菜单
* 参数:options: { menuList: [], clientY: 0, clientX: 0 } - 包含菜单列表及鼠标点击坐标的对象
* 返回值:Promise对象,用于异步处理菜单项点击事件
*/
export const open = (options) => {
// 如果右键菜单实例已存在,则关闭右键菜单
if (RightMneuInstance) {
close()
}
// 返回一个Promise对象,用于处理右键菜单的异步操作
return new Promise((resolve, reject) => {
// 创建右键菜单的构造器,使用Vue.extend扩展自定义组件
const RightMneuConstructor = Vue.extend({
render(h) {
// 渲染右键菜单组件,并传递属性
return h(RightMneu, {
props: {
handleClick(val) {
// 当子菜单被点击时,返回子菜单的对象信息
resolve(val)
// 点击子菜单后销毁右键菜单实例
close()
},
// 展开其他选项,以传递额外的属性
...options
}
})
}
})
// 创建右键菜单实例
RightMneuInstance = new RightMneuConstructor()
// 挂载右键菜单实例
RightMneuInstance.$mount()
// 将右键菜单添加到body元素中
document.body.appendChild(RightMneuInstance.$el)
})
}
/**
* 功能:关闭并销毁右键菜单实例
*/
export const close = () => {
// 销毁右键菜单实例和从body中移除右键菜单元素
RightMneuInstance.$destroy()
RightMneuInstance.$el.remove()
RightMneuInstance = null
}
// 默认导出模块,包含open和close方法
export default {
open,
close
}
实际使用中只需要引入函数直接调用即可。
<template>
<div class="test" @contextmenu="openContextmenuFn"> </div>
</template>
<script>
import RightMenu from '@/components/RightMenu/index.js'
export default {
data() {
return {}
},
methods: {
async openContextmenuFn(e) {
e.preventDefault()
const menuList = [
{
id: 1,
name: '复制',
disabled: true
},
{
id: 2,
name: '粘贴'
}
]
const res = await RightMenu.open({
menuList: menuList,
clientX: e.clientX,
clientY: e.clientY
})
console.log('res:', res)
}
}
}
</script>
<style lang="scss" scoped>
.test {
width: 100vw;
height: 100vh;
}
</style>
Vue3
index.vue 文件
Vue3 主要是 API 方面的不同,Vue2 使用 options API,而 Vue3 是使用 components API,逻辑还是一样的
<template>
<div id="contextmenu" class="contextmenu">
<div class="contextmenu-item" :class="{ disabled: item.disabled }" v-for="item in menuList" :key="item.id"
@click="handleClick(item)">
{{ item.name }}
</div>
</div>
</template>
<script setup>
import { onMounted, onUnmounted, ref } from 'vue';
const props = defineProps({
menuList: {
type: Array,
default: () => []
},
clientY: {
type: Number,
default: 0
},
clientX: {
type: Number,
default: 0
},
handleClick: {
type: Function,
default: () => { }
}
})
let contextmenuDom = ref(null)
onMounted(() => {
document.addEventListener('click', closecContextmenuFn)
openContextmenuFn()
})
onUnmounted(() => {
document.removeEventListener('click', closecContextmenuFn)
})
const openContextmenuFn = () => {
// 获取上下文菜单的DOM元素
contextmenuDom.value = document.querySelector('#contextmenu')
// 设置菜单的高度为自动,以便获取其实际高度
contextmenuDom.value.style.height = 'auto'
// 获取菜单的高度和宽度
const { height, width } = contextmenuDom.value.getBoundingClientRect()
// 先将菜单的高度设置为0,避免初始显示影响定位计算
contextmenuDom.value.style.height = 0
// 根据鼠标点击位置和菜单高度,决定菜单的上边距
if (props.clientY + height > window.innerHeight) {
contextmenuDom.value.style.top = props.clientY - height + 'px'
} else {
contextmenuDom.value.style.top = props.clientY + 'px'
}
// 根据鼠标点击位置和菜单宽度,决定菜单的左边距
if (props.clientX + width > window.innerWidth) {
contextmenuDom.value.style.left = props.clientX - width + 'px'
} else {
contextmenuDom.value.style.left = props.clientX + 'px'
}
// 强制浏览器重绘菜单,以便正确显示
contextmenuDom.value.scrollHeight
// 最后设置菜单的高度为其实际高度,确保菜单完全显示
contextmenuDom.value.style.height = height + 'px'
}
const closecContextmenuFn = (e) => {
const contextmenuItemDoms = Array.from(document.querySelectorAll('.contextmenu-item'))
// // 判断点击的元素是否是上下文菜单的DOM元素,如果是,则不关闭菜单
if (contextmenuItemDoms.includes(e.target) || e.target === contextmenuDom.value) return
contextmenuDom.value.remove()
}
</script>
<style lang="scss" scoped>
.contextmenu {
position: fixed;
background-color: #fff;
padding: 5px 0;
font-size: 12px;
z-index: 99;
border-radius: 3px;
overflow: hidden;
transition: height 0.3s;
box-shadow: 2px 2px 5px #dcdcdc;
.contextmenu-item {
padding: 8px 20px;
border-radius: 3px;
cursor: pointer;
&:hover {
background-color: #e4e4e4;
}
&.disabled {
color: #ccc;
cursor: not-allowed;
&:hover {
background-color: transparent;
}
}
}
}
</style>
index.js 文件
主要是使用 CreatApp 以及 h 函数完成的自定义组件和挂载
import RightMenu from './index.vue'
import { createApp, h } from 'vue'
let app = null
let container = null
export const open = (options: any) => {
if (container) {
close()
}
return new Promise((resolve) => {
container = document.createElement("div");
document.body.appendChild(container);
app = createApp({
render() {
return h(
RightMenu,
{
handleClick: (val) => {
resolve(val)
close()
},
...options,
}
)
},
})
app.mount(container)
})
}
export const close = () => {
app.unmount()
container.remove()
}
export default {
open,
close
}
实际使用与 Vue2 一样
<template>
<div class="layout" @contextmenu="openContextmenuFn">
</div>
</template>
<script setup>
import RightMenu from '@/components/RightMenu'
const openContextmenuFn = async (e) => {
e.preventDefault()
const menuList = [
{
id: 1,
name: '复制',
disabled: true
},
{
id: 2,
name: '粘贴'
}
]
const res = await RightMenu.open({
menuList: menuList,
clientX: e.clientX,
clientY: e.clientY
})
console.log('res:', res)
}
</script>
<style scoped lang="scss">
.layout {
background-color: var(--main-color);
width: 100vw;
height: 100vh;
}
</style>
总结
这两者的命令式组件主要是体现在创建方式的不同,Vue2 使用 extend,而 Vue3 是使用 CreatApp,都是用 h函数进行创建,组件的模版以及使用都差不多,只是写法不一样