Vue2和Vue3的命令式组件的开发

251 阅读3分钟

一般在开发中使用的是声明式组件,可以根据相应的条件来显示隐藏该组件。但是在很多时候会觉得这种方法会显得非常笨重,每次使用都需要引入组件、声明变量和定义方法来控制显示隐藏。所以这时候就需要命令式组件,可以非常方便的使用,就如一些组件库中的弹窗、通知、提示框等组件,通过函数就能直接使用,不需要一大堆的麻烦事。

什么是命令式组件

直接通过 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函数进行创建,组件的模版以及使用都差不多,只是写法不一样