Vue中通过组件实现一个可调用的、等待用户选择的、可以全局使用的菜单

366 阅读1分钟

前言:

最近遇到个需求,需要实现一个用户可选择的菜单,这个菜单是在拖拽放置后弹出,让用户进行选择,决定放置的位置,可以通过点击视口其他位置关闭菜单

先看看怎么用的:

根据menuSelected决定后面如何操作

const menuSelected = await this.callMenu({
  pos: { x: e.clientX, y: e.clientY }
});

第一步,菜单组件实现:

这一步非常简单,直接按照平常实现组件即可,但是事件处理方法先不需要写里面内容:

<template>
  <ul
    class="menu-list"
    :style="{
      left: pos.x + 'px',
      top: pos.y + 'px',
    }"
  >
    <template v-for="option of menuOptions">
      <li
        v-else
        class="menu-item"
        :key="option.name"
        @mousedown="handleMenuSelect(option)"
      >
        <svg-icon
          :icon-class="(option.icon || option.name.toLowerCase()) + '-icon'"
        ></svg-icon>
        <span class="name">{{ option.name }}</span>
        <span class="short-cut">{{ option.shortCut }}</span>
      </li>
    </template>
  </ul>
</template>
<script>
export default {
    data: {
        menuOptions: [
        {
          name: MENU_OPTIONS.REPLACE,
          shortCut: ""
        },
        {
          name: MENU_OPTIONS.COPY,
          shortCut: "⌘C"
        },
        ... // 其他菜单内容,这里省略
      ]
    },
    methods: {
        handleMenuSelect(){}  // 这里方法写空的,占位防止报错,具体内容不在这里写
    }
}
</script>

第二步:

创建一个js文件,引入这个组件,继承他的构造器:

import MenuList from "./MenuList.vue";

const MenuListCtor = Vue.extend(MenuList);

第三步:

挂载一个方法到Vue的原型链上,为了让实例可以直接调用,另外让这个方法返回一个promise(考虑promise的resolve的触发节点和reject的触发节点):

Vue.prototype.callMenu = function({ hover, pos }) {
  return new Promise((resolve, reject) => {
    
  });
};

第四步:

生成这个继承后组件的实例,并为其绑定之前SFC内的handleMeuSelect方法

    const instance = new MenuListCtor();
    instance.hover = hover;
    instance.pos = pos;
    instance.handleMeuSelect = ({ name }) => resolve(name) // 覆盖了之前SFC内的方法
    

第五步:

创建一个未被插入的dom节点(通过$mount方法),并挂载到body上:

    const { $el } = instance.$mount();
    document.body.appendChild($el);
    $el.addEventListener("click", e => e.stopPropagation()); // 防止点击穿透,触发到document的隐藏菜单事件

第六步:

处理菜单的关闭,绑定到body上:

    function eventHandler() {
      instance.$destroy();
      $el.parentNode?.removeChild($el);
      reject();
    }
    document.body.addEventListener("mousedown", eventHandler, { once: true });

美化:

加入缩放自动关闭菜单和超出视口的ui优化,提升体验

    if (window.innerHeight - pos.y < pos.y) {
      pos.y = pos.y - $el.offsetHeight - 20;
      if (pos.y < 0) {
        $el.style.height = $el.offsetHeight + pos.y + "px";
        pos.y = 0;
      }
    } else {
      if (pos.y + $el.offsetHeight > window.innerHeight) {
        $el.style.height = window.innerHeight - pos.y - 20 + "px";
      }
    }

window.addEventListener("resize", eventHandler, { once: true });

完整代码:

还加入了重复判断(通过closed变量)有一些优化处理,之前没介绍

import Vue from "vue";

import MenuList from "./MenuList.vue";

const MenuListCtor = Vue.extend(MenuList);

let closed = true;

Vue.prototype.callMenu = function({ hover, pos }) {
  return new Promise((resolve, reject) => {
    // 防止重复处理:
    if (!closed) {
      return reject();
    }
    closed = false;
    
    // 组件处理
    const instance = new MenuListCtor();
    instance.hover = hover;
    instance.pos = pos;
    instance.handleClick = ({ name }) => resolve(name);
      
    // DOM处理
    const { $el } = instance.$mount();
    document.body.appendChild($el);
    $el.addEventListener("click", e => e.stopPropagation());
    
    // 美化样式
    if (window.innerHeight - pos.y < pos.y) {
      pos.y = pos.y - $el.offsetHeight - 20;
      if (pos.y < 0) {
        $el.style.height = $el.offsetHeight + pos.y + "px";
        pos.y = 0;
      }
    } else {
      if (pos.y + $el.offsetHeight > window.innerHeight) {
        $el.style.height = window.innerHeight - pos.y - 20 + "px";
      }
    }

    // 事件处理
    document.body.addEventListener("mousedown", eventHandler, { once: true });
    window.addEventListener("resize", eventHandler, { once: true });
    
    function eventHandler() {
      instance.$destroy();
      $el.parentNode?.removeChild($el);
      closed = true;
      reject();
    }
  });
};