实现一个Vue右键菜单指令

最近在实现可视化页面编辑器的时候,遇见了需要实现自定义右键菜单的场景,因此本文主要整理了如何在Vue项目中通过自定义指令封装一个声明式的右键菜单的小工具。

<!--more-->

整个项目已经发布到 npm:vue-contextmenu,对应源码位于github

1. 右键菜单组件

<template>
    <div v-show="visible" class="context-menu">
        <div
            v-for="(item, index) in list"
            :key="index"
            class="context-menu-item"
            @click.stop="clickHandler(item)"
        >
            {{ item.text }}
        </div>
    </div>
</template>

<script>
    export default {
        name: "ContextMenu",
        props: {
            visible: {
                type: Boolean,
                default: false,
            },
            list: {
                type: Array,
                default: () => {
                    return [];
                },
            },
        },
        methods: {
            close() {
                this.$emit("hide");
            },
            clickHandler(item) {
                if (typeof item.onClick === "function") {
                    item.onClick();
                }
                this.close();
            },
        },
    };
</script>

<style scoped lang="scss">
    .context-menu {
        position: absolute;
        z-index: 999;
        background-color: #ecf0f1;
        border-radius: 10px;
        overflow: hidden;
        box-shadow: 0 3px 6px 0 rgba(51, 51, 51, 0.2);

        &-item {
            line-height: 40px;
            padding: 0 10px;
            font-size: 14px;
            &:hover {
                background-color: #007aff;
                color: #fff;
                cursor: pointer;
            }
        }
    }
</style>

其中单个菜单项数据结构类似于

{
  text: '菜单项目1',
  onClick: () => {
    console.log('todo')
  }
}

考虑到还有 2 级或多级菜单,可以扩展一个诸如subMenu的字段保存子菜单。

2. contextmenu 指令

2.1. 单例

在绝大部分场景下,右键菜单应该是一个单例,因此可以通过一个 Vue 实例利用手动$mount 挂载到页面上,然后控制展示和隐藏该节点来实现,无须实例化多个菜单

为了避免在模块中直接引入外部 Vue,我们将其封装成构造参数传入,同时将菜单组件也作为第二个参数传入,方便后续替换自定义菜单组件

export default function init(Vue, MenuComponent) {
    function newInstance() {
        const Instance = new Vue({
            data() {
                return {
                    list: [],
                    style: {},
                    visible: false,
                };
            },
            mounted() {
                document.addEventListener("click", this.hide);
                document.addEventListener("contextmenu", this.hide);
            },
            beforeDestroy() {
                document.removeEventListener("click", this.hide);
                document.removeEventListener("contextmenu", this.hide);
            },
            methods: {
                show(list, style) {
                    this.list = list;
                    this.style = style;
                    this.visible = true;
                },
                hide() {
                    this.visible = false;
                },
            },
            render(h) {
                return h(MenuComponent, {
                    style: this.style,
                    props: {
                        visible: this.visible,
                        list: this.list,
                    },
                    on: {
                        hide: this.hide,
                    },
                });
            },
        });
        const el = Instance.$mount();
        document.body.appendChild(el.$el);
        return el;
    }

    let instance;

    return function getInstance() {
        if (!instance) {
            instance = newInstance();
        }
        return instance;
    };
}

2.2. 自定义指令

为了方便在不同的元素上展示不同的菜单,用指令来封装菜单的显示,比直接引入菜单组件在模板中展示更加灵活。因此采用自定义指令的方式来封装整个插件。

在指令的bind钩子中,主要监听 dom 节点的 contextmenu 事件,然后将菜单组件展示在指定位置。

const contextMenu = {
    bind(el, binding) {
        const instance = getInstance();

        el.addEventListener("contextmenu", function (e) {
            const { menuList, onShow } = binding.value;
            if (typeof onShow === "function") {
                onShow();
            }

            // 在指定位置展示
            const oX = e.clientX;
            const oY = e.clientY;
            instance.show(menuList, {
                left: oX + "px",
                top: oY + "px",
            });
            e.preventDefault();
            e.stopPropagation();
        });
    },
};

最后向外暴露一个插件接口

export default {
    install(Vue, {name = 'contextmenu', menuComponent = Menu} = {}) {
        const getInstance = init(Vue, menuComponent)
        const contextMenu = {...}
        Vue.directive(name, contextMenu)
    }
}

这里暴露了两个配置参数方便扩展

  • name,指令名称
  • menuComponent,自定义菜单组件

3. 在组件中使用

全局注册插件后,就可以在组件中通过指令使用了

<template>
  <div id="app">
    <button v-contextmenu="{menuList, onShow}">右键菜单</button>
  </div>
</template>

<script>

export default {
  name: 'App',
  computed: {
    menuList() {
      return [
        {
          text: '菜单1',
          onClick: () => {
            console.log(1)
          }
        },
        {
          text: '菜单2', onClick: () => {
            console.log(2)
          }
        }
      ]
    },
  },
  methods:{
    onShow(){
      // init
    }
  }
}
</script>

支持binding传值包括

  • menuList,菜单列表,格式如下
    • text 菜单名称
    • onClick 点击事件
  • onShow,钩子函数,在菜单展示时触发

这样就实现了通过声明式的指令配置不同地方的右键菜单,并执行对应逻辑。

4. 小结

本文介绍了使用自定义指令实现一个易用的右键菜单,其实现思路比较简单

  • 监听右键菜单事件oncontextmenu,获取鼠标点击位置
  • 在对应位置展示菜单组件
  • 渲染菜单

相关代码已经放在github上了,后续有时间会支持多级菜单等新特性。

ReactHooks原理及简单实现前端埋点数据收集及上报方案