Vue 组件开发中 $createElement API 与右键菜单的动态渲染优化?

在开发的时候发现公司封装的树组件的右键菜单是预先在页面中放置一个DOM,通过修改绝对定位top,left的方式来将菜单定位到鼠标点击的位置,但是又引发了一些样式和布局的问题。所以想用Vue的$createElementAPI去优化一下。同时也希望能更深入了解Vue的VNode实现原理。目前的设计思路是声明一个局部组件,在点击菜单项时通过$emit传递状态, 最后统一在父组件中处理点击事件。
代码如下

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="initial-scale=1.0, user-scalable=no"/>
  <title>Title</title>
  <script src="./dependency/vue.js"></script>
</head>
<body>
<div id="app">
  <context-menu :test-render="testRender" @click-li="collectClick"></context-menu>
</div>
<script>
  let contextMenu = Vue.component('contextMenu', {
    props: ['testRender'],
    render: function (h) {
      return h('div', {
          class: 'contextMenuBox'
        },
        [h('ul', this.testRender.map(item => h('li', {
          on: {
            click: (...args) => {
              this.$emit('click-li', args)
            }
          }
        }, item)))]);
    }
  });
  const vm = new Vue({
    data: {
      testRender: ['Item 1', 'Item 2', 'Item 3'],
    },
    components: {
      'context-menu': contextMenu
    },
    methods: {
      collectClick(e) {
        console.log(e)
      }
    }
  }).$mount('#app');

</script>
</body>
</html>

目前遇到的问题是:这种实现方式与在页面中预先放置一个DOM的实现方式是一致的,只是代码的复用性会稍强一些(也可能更优雅)包括在createElementAPI中传递的参数,只能是固定的某些内容,那能否在一个场景下,譬如问题中的右键菜单,也许会加入权限来控制可选项的数量。这些更抽象的设计思路,有没有更优雅的设计思路呢?希望路过的大佬不吝赐教。谢谢各位大佬。

阅读 1.6k
2 个回答

Teleporthttps://floating-ui.com/docs/vue

teleport 可以将 dom 挂载在 body 的其他位置,floating-ui/vue 用来计算位置参数,位置这一块自己写定位的话 有太多边界情况要处理。

了解一下vue2动态创建组件的方法吧,给个以前写的例子,也可以去github上逛逛

组件:

<template>
  <ul
    v-show="value"
    class="context-menu"
    :style="style"
    @contextmenu.prevent
    @click="onClick"
  >
    <li
      v-for="(i, index) in menuItems"
      :key="i.content"
      :data-index="index"
      class="context-menu-item"
    >
      {{ i.content }}
    </li>
  </ul>
</template>

<script>
/**
 * 右键菜单,用于页签栏,右键点击页签时弹出
 */

export default {
  name: 'ContextMenu',

  props: {
    // 是否显示,支持v-modal
    value: Boolean,
    // 菜单定义数组,{content: string 菜单文字, click: function 点击菜单时触发的函数}
    items: Array,
    // 菜单距离屏幕左侧的距离,单位px
    left: Number,
    // 菜单距离屏幕顶部的距离,单位px
    top: Number,
    // 菜单距离屏幕边缘的最小距离,单位px
    minDistance: { type: Number, default: 10 }
  },

  data() {
    this.willAutoAdaptLeft = false
    this.willAutoAdaptTop = false
    return {
      realLeft: '0px',
      realTop: '0px'
    }
  },

  computed: {
    style() {
      return { left: this.realLeft, top: this.realTop }
    },

    menuItems() {
      return this.items.filter(Boolean)
    }
  },

  watch: {
    value: {
      immediate: true,
      handler(v) {
        document.body[v ? 'addEventListener' : 'removeEventListener']('click', this.close)
        if (v) {
          this.willAutoAdaptLeft = true
          this.willAutoAdaptTop = true
          this.$nextTick(this.autoAdapt)
        }
      }
    },
    left: {
      immediate: true,
      handler: 'autoAdaptLeft'
    },
    top: {
      immediate: true,
      handler: 'autoAdaptTop'
    }
  },

  methods: {
    close() {
      this.$emit('input', false)
    },
    autoAdapt() {
      this.autoAdaptTop(this.top)
      this.autoAdaptLeft(this.left)
    },
    autoAdaptTop(v) {
      if (this.willAutoAdaptTop) {
        this.willAutoAdaptTop = false
        return
      }
      if (!this.value || v == null) return

      const elHeight = this.$el.offsetHeight
      const remainHeight = document.body.clientHeight - v - this.minDistance
      const over = elHeight - remainHeight
      const finalTop = over > 0 ? v - over : v

      this.realTop = `${finalTop}px`
    },
    autoAdaptLeft(v) {
      if (this.willAutoAdaptLeft) {
        this.willAutoAdaptLeft = false
        return
      }
      if (!this.value || v == null) return

      const elWidth = this.$el.offsetWidth
      const remainWidth = document.body.clientWidth - v - this.minDistance
      const over = elWidth - remainWidth
      const finalLeft = over > 0 ? v - over : v

      this.realLeft = `${finalLeft}px`
    },

    /**
     * 事件代理
     *
     * @param event {Event}
     */
    onClick(event) {
      if (!event.target.classList.contains('context-menu-item')) {
        return
      }

      const index = Number(event.target.dataset.index)
      const menuItem = this.menuItems[index]

      menuItem && menuItem.click()
    }
  },

  beforeDestroy() {
    document.body.removeEventListener('click', this.close)
  }
}
</script>

函数式调用:

import Vue from 'vue'
import ContextMenu from './index'

/**
 * 函数形式创建右键菜单
 * 注意!每次调用时都会创建一个新的实例,需要自行销毁上一次产生的实例
 *
 * @param items {{content:string,click:function}[]} 菜单数组
 * @param options {{left:number,top:number,minDistance?:number}} 配置项
 * @return {function} 关闭右键菜单的方法
 */
export default function(items, options) {
  const ctor = Vue.extend(ContextMenu)
  const instance = new ctor({ propsData: { value: true, items, ...options } })

  instance.$mount()
  instance.$once('input', value => {
    if (!value) {
      instance.$destroy()
      document.body.removeChild(instance.$el)
    }
  })
  document.body.appendChild(instance.$el)

  return () => instance.close()
}
撰写回答
你尚未登录,登录后可以
  • 和开发者交流问题的细节
  • 关注并接收问题和回答的更新提醒
  • 参与内容的编辑和改进,让解决方法与时俱进
推荐问题