Skip to content

vue3实现一个可以拖动的弹窗组件, 实现这个组件可以学习到什么?

背景是我们有一个老的项目jsp,他里面有一套样式规范,之前好像是使用的Bootstrap UI

本次开发部分模块以iframe的方式嵌套进去,为了更好的维护以及迭代,我们本次技术栈使用vue3

为了满足UI统一需要定制一个弹窗组件,这个弹窗组件需要支持拖动,这个拖动还有点不太一样,是拖动了一个跟弹窗同等大小黑色的框 移动,等松开鼠标的时候再移动原弹窗的位置。具体样式如下:

示意图



以及这个项目的二次确认弹窗,以及成功的提示也是长这个样子支持拖动,如下就是多个图标:

示意图

组件的设计 pro-modal

先说下<pro-modal />组件需要支持到什么以及需要提供给用户什么样的API,我觉得一个弹窗组件咱们直接去参考element-ui或者antd ui就行了。具体如下:

API:

  • title 弹窗标题
  • visible 控制显示隐藏
  • width 设置宽度支持数字、像素、百分比等
  • isFullModal 是否全屏
  • isWindowOnly 是否限制在屏幕适口内拖动
  • closable 是否显示右上角关闭按钮

Slot:

  • default body
  • header 定制头部左上角
  • suffix 定制头部右上角
  • footer 定制底部

用法:

vue
<template>
  <ProModal v-model:visible="visible" title="AAAAA"> 1111 </ProModal>
</template>

<script setup>
import { ref } from "vue";
import ProModal from "@/components/pro-modal/index.vue";
const visible = ref(false);
</script>

以上就是本次<pro-modal /> 组件的设计以及使用方法

基于 pro-modal 组件开发一个二次确认提示框 pro-confirm

拖拽等功能已经是pro-modal的基础能力,pro-confirm需要支持到以下用法:

用法一:

vue
<template>
  <ProConfirm
    content="确定删除吗?"
    :type="CONFIRM_MAP.SUCCESS"
    :confirm="onConfirm"
    :cancel="onCancel"
  >
    <a-button type="primary"> 删除 </a-button>
  </ProConfirm>
</template>

<script setup>
import { CONFIRM_MAP, ProConfirm } from "@/components/pro-confirm/index.js";

const onConfirm = () => {};

const onCancel = () => {};
</script>

用法二:

vue
<template>
  <a-button type="primary" @click="onClick"> 保存 </a-button>
  <a-button type="primary" @click="onDeleteClick"> 删除 </a-button>
</template>

<script setup>
import { Confirm } from "@/components/pro-confirm/index.js";

const onClick = () => {
  Confirm.success("新增成功");
  /**
   *
   * Confirm.success({
   *  content: '新增成功'
   * })
   */
};

const onDeleteClick = () => {
  Confirm.warning({
    content: "确认删除吗?",
    confirm() {},
    cancel() {},
  });
};
</script>

用法其实就是参考二次确认弹窗以及信息提示弹窗组件的实现,行那么接下来咱们来实现下相关具体功能。

pro-modal 实现的具体细节

处理width字段 设置宽度支持数字、像素、百分比等,匹配到数值就增加个px。如下:

js
const widthStyle = computed(() => {
  if (/^\d*$/.test(props.width)) {
    return `width: ${props.width}px`;
  }

  return `width: ${props.width}`;
});

由于咱们的弹窗是支持拖动的,那么为了让弹窗内容居中,就不能使用margin: 0 auto;,有或者tarnsform: translateX(-50%)手段了,必须要使用定位的left才好去做拖动逻辑。

其实就是把屏幕的宽度比做一个大矩形,弹窗中的内容比做小矩形,现在需要把小矩形居中就使用 (大矩形宽度 - 小矩形宽度)/ 2 就得到*居中8的left值了。代码如下:

js
watch(
  () => props.visible,
  (value) => {
    if (value) {
      nextTick(() => {
        // getBoundingClientRect 获取到内容的宽度
        const { width: conatinerWidth } =
          containerRef.value?.getBoundingClientRect() || {
            width: 0,
            height: 0,
          };
        const maxWidth = window.innerWidth; // 获取到屏幕的宽度
        currentLeft.value = (maxWidth - conatinerWidth) / 2; // 得到left 值 (这里是有计算属性的,赋值后就会使用left),这里说明下我的top值初始化是100px,我看好多弹窗组件都是默认100px

        onBindEvent();
      });
    }
  },
  {
    immediate: true,
  },
);

拖动黑色边框的样式也是需要计算的,它的宽度、高度、left、Top值初始化都是以弹窗内容基础去做的,然后在根据拖动的距离动态更新它的left、top值。具体如下:

js
const borderStyle = computed(() => {
  const { width: conatinerWidth, height: containerHeight } =
    containerRef.value?.getBoundingClientRect() || {
      width: 0,
      height: 0,
    };
  // diff 其实就是拖动的距离 动态加减即可实现拖动效果

  return `
      width: ${conatinerWidth}px;
      height: ${containerHeight}px; 
      left: ${currentLeft.value + diffX.value}px;  
      top: ${currentTop.value + diffY.value}px; 
    `;
});

会有场景需要弹窗嵌套弹窗的场景,那么就需要根据加载的顺序,动态生成权重z-index: 666;,在有弹窗存在就权重1。具体如下:

js
const DEFAULT_INDEX = 698;

const indexList = [];

// 增加
export const incrementalZIndex = () => {
  const zIndex = Math.max(...indexList, DEFAULT_INDEX) + 1;
  indexList.push(zIndex);
  return zIndex;
};

// 在取消弹窗调用删除
export const decreaseZIndex = (zIndex) => {
  const i = indexList.findIndex((item) => item === zIndex);

  if (i !== -1) {
    indexList.splice(i, 1);
  }
};

需要计算只能在当前屏幕内拖动,其实范围就是限制下定位的Left、Top, 最小值肯定是left: 0; top: 0;, 最大值一定是left: 屏幕的宽 - 弹窗内容的宽; top: 屏幕的高 - 弹窗内容的高。 如果有滚动条,需要把滚动条的宽度给计算进去。都在useDrag函数里面统一放下面了

js
import { onUnmounted, ref } from "vue";
import { getScrollBarWidth } from "./scroll-bar";

export const useDrag = (containerRef, draggableRef, options = {}) => {
  const {
    isWindowOnly = true,
    margin = 0,
    onEndCallback = () => {},
  } = options || {};

  const scopeValue = ref({});
  const isDragging = ref(false);
  const startX = ref(0);
  const startY = ref(0);
  const diffX = ref(0);
  const diffY = ref(0);

  const startDrag = ({ clientX: x1, clientY: y1 }) => {
    scopeValue.value = getScopeValue(); // 检测范围
    document.onselectstart = () => false;
    startX.value = x1;
    startY.value = y1;
    isDragging.value = true;
  };

  const moveDrag = ({ clientX: x2, clientY: y2 }) => {
    if (isDragging.value) {
      if (isWindowOnly) {
        // 判断检测要在范围内
        const { maxX, maxY, x, y } = scopeValue.value;

        const vx = x2 - startX.value + x;
        const vy = y2 - startY.value + y;

        let dx = x2 - startX.value;
        let dy = y2 - startY.value;

        if (vx < 0) {
          dx = -x;
        }
        if (vy < 0) {
          dy = -y;
        }

        if (vx > maxX) {
          dx = maxX - x;
        }

        if (vy > maxY) {
          dy = maxY - y;
        }

        diffX.value = dx;
        diffY.value = dy;
      } else {
        diffX.value = x2 - startX.value;
        diffY.value = y2 - startY.value;
      }
    }
  };

  const endDrag = () => {
    document.onselectstart = () => true;
    onEndCallback(diffX.value, diffY.value);
    diffX.value = 0;
    diffY.value = 0;
    isDragging.value = false;
  };

  const getScopeValue = () => {
    const {
      width: conatinerWidth,
      height: containerHeight,
      x,
      y,
    } = containerRef.value?.getBoundingClientRect() || {
      width: 0,
      height: 0,
    };
    const maxWidth = window.innerWidth;
    const maxHeight = window.innerHeight;
    const isScroll = document.body.scrollHeight > window.innerHeight;

    return {
      minX: 0,
      minY: 0,
      maxX:
        maxWidth -
        conatinerWidth -
        margin -
        (isScroll ? getScrollBarWidth() : 0),
      maxY: maxHeight - containerHeight - margin,
      x,
      y,
    };
  };

  const onBindEvent = () => {
    if (draggableRef.value) {
      draggableRef.value?.removeEventListener("mousedown", startDrag);
      document?.removeEventListener("mousemove", moveDrag);
      document?.removeEventListener("mouseup", endDrag);
      draggableRef.value.addEventListener("mousedown", startDrag, {
        capture: true,
      });
      document.addEventListener("mousemove", moveDrag);
      document.addEventListener("mouseup", endDrag);
    }
  };

  onUnmounted(() => {
    draggableRef.value?.removeEventListener("mousedown", startDrag);
    document?.removeEventListener("mousemove", moveDrag);
    document?.removeEventListener("mouseup", endDrag);
  });

  return {
    onBindEvent,
    isDragging,
    diffX,
    diffY,
  };
};

pro-confirm 实现的一些细节

支持嵌套点击出现确认框实现如下:

vue
<template>
  <div @click="onClick" class="pro-confirm">
    <slot></slot>
    <ProModal
      wrapClassName="pro-confirm__warp31415925"
      :title="title"
      :icon="icon"
      :width="260"
      :closable="false"
      :visible="confirmVisible"
    >
      <div class="pro-confirm__content">
        <span class="pro-confirm__icon" :style="typeComputed"></span>
        <span> {{ content }}</span>
      </div>

      <template #footer>
        <a-button size="small" @click="onConfirm">{{ okText }}</a-button>
        <a-button size="small" @click="onCancel" v-if="showCancel">{{
          cancelText
        }}</a-button>
      </template>
    </ProModal>
  </div>
</template>

<script>
const onClick = () => {
  if (props.disabled) return;
  confirmVisible.value = true; // 更改状态 关闭
};
</script>

支持函数调用的方式使用组件(核心):

js
import { createApp } from "vue";
import ProConfirm from "./index.vue";

const mountNode = document.createElement("div");
document.body.appendChild(mountNode);
const modalInstance = createApp(ProConfirm, {
  visible: true,
  ...currentOptions,
  confirm() {
    currentOptions.confirm();
    mountNode.remove();
  },
  cancel() {
    currentOptions.cancel();
    mountNode.remove();
  },
});
modalInstance.mount(mountNode); // 挂载

完整的方法支持success、error、warning:

js
import { createApp } from "vue";

import ProConfirm from "./index.vue";
import { CONFIRM_MAP } from "./options";

const DEFAULT_OPTIONS = {
  title: "提示信息",
  type: CONFIRM_MAP.SUCCESS,
  content: "",
  cancelText: "取消",
  okText: "确定",
  showCancel: true,
  confirm: () => {},
  cancel: () => {},
};

const Confirm = (options) => {
  const currentOptions = {
    ...DEFAULT_OPTIONS,
    ...options,
  };
  if (currentOptions.type === CONFIRM_MAP.SUCCESS) {
    Confirm.success();
  }
  if (currentOptions.type === CONFIRM_MAP.ERROR) {
    Confirm.error();
  }
  if (currentOptions.type === CONFIRM_MAP.WARNING) {
    Confirm.warning();
  }
};

const confirmInstance = (options) => {
  const currentOptions = {
    ...DEFAULT_OPTIONS,
    ...options,
  };

  const mountNode = document.createElement("div");
  document.body.appendChild(mountNode);
  const modalInstance = createApp(ProConfirm, {
    visible: true,
    ...currentOptions,
    confirm() {
      currentOptions.confirm();
      mountNode.remove();
    },
    cancel() {
      currentOptions.cancel();
      mountNode.remove();
    },
  });
  modalInstance.mount(mountNode);
};

const paramsParse = (options, type) => {
  let currentOptions = {
    type,
  };

  if (typeof options === "string") {
    currentOptions.content = options;
    currentOptions.showCancel = false;
  } else {
    currentOptions = {
      ...options,
      type,
    };
  }

  return currentOptions;
};

Confirm.success = (options = DEFAULT_OPTIONS) => {
  confirmInstance(paramsParse(options, CONFIRM_MAP.SUCCESS));
};

Confirm.error = (options = DEFAULT_OPTIONS) => {
  confirmInstance(paramsParse(options, CONFIRM_MAP.ERROR));
};

Confirm.warning = (options = DEFAULT_OPTIONS) => {
  confirmInstance(paramsParse(options, CONFIRM_MAP.WARNING));
};

// Confirm.success({
//   content: '新增成功'
// })

// Confirm.success('新增成功')
export { ProConfirm, CONFIRM_MAP, Confirm };

总结

实现一个可拖拽的弹窗组件以及二次确认弹窗,还是有好多细节可学习的:

  • width Api兼容
  • 居中算法
  • 权重z-index: 666;递增
  • 屏幕内拖动范围限制
  • 实现二次确认弹窗click利用事件冒泡
  • 使用createApp实现函数式APIsuccess、error、warning、Confirm