# 如何实现一个Dialog弹出框

[toc]

我们首先需要考虑几个事情,弹出框由2部分组成,遮罩层和弹出框内容

  1. 需要一个遮罩层管理对象。

    这个管理对象有几个职责:

    1. 遮罩层的打开和关闭(添加一些基础渐入渐出动画)
    2. 遮罩层的单例管理,(一般多个弹出层嵌套也只需要一个遮罩DOM)
    3. 弹出框与遮罩层的映射关系(弹出框实例生成一个id映射弹出框实例本身,注册到管理对象)
    4. 响应遮罩层点击事件
    5. zIndex的统一管理
  2. 弹出框内容

    1. 只需要关注打开和关闭即可,还有就是一些钩子问题
    2. 剩下的就是样式问题
    3. 可以优化内容的延迟加载
    4. 实现拖拽功能

# 弹出层管理对象 popup-manager.js

/* 
弹出层的管理实例
1、主要管理遮罩层, 多个弹窗实例 也只要共用一个遮罩层即可
2、管理 弹出Vue实例栈,主要用于解决嵌套问题

*/

import { addClass, removeClass } from './utils';
import './dialog.css';

let hasModal = false; // 是否已经存在遮罩层, 已经存在的话就没必要再应用动画了
let hasInitZIndex = false;
let zIndex;

const instances = {}; // 映射 弹出层实例对象
const PopupManager = {
  modalFade: true, // 控制遮罩层 是否应用渐变动画 的开关
  modalStack: [], // 保存遮罩层的 栈对象, 里面的id可以映射到弹窗实例
  getInstance: function (id) {
    return instances[id];
  },
  // 获取当前的遮罩层Dom元素
  getModal() {
    let modalDom = PopupManager.modalDom;
    if (modalDom) {
      hasModal = true;
    } else {
      hasModal = false;
      modalDom = document.createElement('div');
      PopupManager.modalDom = modalDom;

      modalDom.addEventListener('touchmove', function (event) {
        event.preventDefault();
        event.stopPropagation();
      });
      modalDom.addEventListener('click', function () {
        PopupManager.doOnModalClick && PopupManager.doOnModalClick();
      });
    }
    return modalDom;
  },
  // 用于注册关联 modal id 和弹出层Vue实例的关系
  register: function (id, instance) {
    if (id && instance) {
      instances[id] = instance;
    }
  },
  deregister: function (id) {
    if (id) {
      instances[id] = null;
      delete instances[id];
    }
  },
  // 获取下一个的定位zIndex值,这是递增
  nextZIndex: function () {
    return PopupManager.zIndex++;
  },
  doOnModalClick: function () {
    // 响应遮罩层被点击的事件
    const topItem = PopupManager.modalStack[PopupManager.modalStack.length - 1];
    if (!topItem.id) {
      return;
    }
    const instance = instances[topItem.id];
    if (instance && instance.closeOnClickModal) {
      // 如果 弹窗实例 设置了点击遮罩层关闭, 那么调用实例的close方法
      if (typeof instance.close === 'function') {
        instance.close();
      }
    }
  },
  // 打开遮罩层
  // modalClass 自定义类名
  openModal: function (id, zIndex, dom, modalClass, modalFade) {
    if (!id || zIndex === undefined) return;
    this.modalFade = modalFade;

    const modalStack = this.modalStack;
    for (let i = 0, len = modalStack.length; i < len; i++) {
      // 如果遮罩层已经打开了, 就没必要再打开了
      if (modalStack[i] === id) {
        return;
      }
    }

    const modalDom = PopupManager.getModal();
    addClass(modalDom, 'v-modal');
    if (this.modalFade && !hasModal) {
      addClass(modalDom, 'v-modal-enter');
    }

    if (modalClass) {
      let classArr = modalClass.trim().split(/\s+/);
      classArr.forEach(item => addClass(modalDom, item));
    }
    setTimeout(() => {
      removeClass(modalDom, 'v-modal-enter');
    }, 200);

    // 如果传入的dom元素的父元素不是虚拟节点
    if (dom && dom.parentNode && dom.parentNode.nodeType !== 11) {
      dom.parentNode.appendChild(modalDom);
    } else {
      // 否则添加到Body, 一般都走这里
      document.body.appendChild(modalDom);
    }

    if (zIndex) {
      modalDom.style.zIndex = zIndex;
    }

    modalDom.tabIndex = 0;
    modalDom.style.display = '';

    this.modalStack.push({ id: id, zIndex: zIndex, modalClass: modalClass });
  },
  closeModal: function (id) {
    const modalStack = this.modalStack;
    const modalDom = PopupManager.getModal();

    if (modalStack.length > 0) {
      const topItem = modalStack[modalStack.length - 1];
      if (topItem.id === id) {
        if (topItem.modalClass) {
          let classArr = topItem.modalClass.trim().split(/\s+/);
          classArr.forEach(item => removeClass(modalDom, item));
        }

        modalStack.pop();
        if (modalStack.length > 0) {
          modalDom.style.zIndex = modalStack[modalStack.length - 1].zIndex;
        }
      } else {
        for (let i = modalStack.length - 1; i >= 0; i--) {
          if (modalStack[i].id === id) {
            modalStack.splice(i, 1);
            break;
          }
        }
      }
    }

    if (modalStack.length === 0) {
      if (this.modalFade) {
        addClass(modalDom, 'v-modal-leave');
      }
      setTimeout(() => {
        if (modalStack.length === 0) {
          if (modalDom.parentNode) modalDom.parentNode.removeChild(modalDom);
          modalDom.style.display = 'none';
          PopupManager.modalDom = undefined;
        }
        removeClass(modalDom, 'v-modal-leave');
      }, 200);
    }
  }
};

Object.defineProperty(PopupManager, 'zIndex', {
  configurable: true,
  get() {
    if (!hasInitZIndex) {
      zIndex = zIndex || 2000;
      hasInitZIndex = true;
    }
    return zIndex;
  },
  set(value) {
    zIndex = value;
  }
});

// 获取最顶上的实例
const getTopPopup = function () {
  // if (Vue.prototype.$isServer) return;
  if (PopupManager.modalStack.length > 0) {
    const topPopup = PopupManager.modalStack[PopupManager.modalStack.length - 1];
    if (!topPopup) return;
    const instance = PopupManager.getInstance(topPopup.id);

    return instance;
  }
};

// handle `esc` key when the popup is shown
window.addEventListener('keydown', function (event) {
  if (event.keyCode === 27) {
    const topPopup = getTopPopup();

    if (topPopup && topPopup.closeOnPressEscape) {
      // 兼容写法
      if (topPopup.handleClose) {
        topPopup.handleClose();
      } else if (topPopup.handleAction) {
        topPopup.handleAction('cancel');
      } else {
        topPopup.close();
      }
    }
  }
});

export default PopupManager;

# 主CSS-dialog.css

.v-modal-enter {
  -webkit-animation: v-modal-in 0.2s ease;
  animation: v-modal-in 0.2s ease;
}
.v-modal-leave {
  -webkit-animation: v-modal-out 0.2s ease forwards;
  animation: v-modal-out 0.2s ease forwards;
}
@-webkit-keyframes v-modal-in {
  0% {
    opacity: 0;
  }
}
@keyframes v-modal-in {
  0% {
    opacity: 0;
  }
}
@-webkit-keyframes v-modal-out {
  100% {
    opacity: 0;
  }
}
@keyframes v-modal-out {
  100% {
    opacity: 0;
  }
}
.v-modal {
  position: fixed;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  opacity: 0.5;
  background: #000;
}
.el-popup-parent--hidden {
  overflow: hidden;
}
.xd-dialog {
  position: absolute;
  margin: 0 auto;
  background: #fff;
  border-radius: 2px;
  -webkit-box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
  -webkit-box-sizing: border-box;
  box-sizing: border-box;
  width: 50%;
  top: 50%;
  left: 50%;
  transform: translate3d(-50%, -50%, 0);
}
.xd-dialog.is-fullscreen {
  width: 100%;
  margin-top: 0;
  margin-bottom: 0;
  height: 100%;
  overflow: auto;
  top: 0;
  left: 0;
  position: relative;
  transform: none;
}
.xd-dialog__wrapper {
  position: fixed;
  top: 0;
  right: 0;
  bottom: 0;
  left: 0;
  overflow: auto;
  margin: 0;
}
.xd-dialog__header {
  padding: 20px 20px 10px;
}
.xd-dialog__headerbtn {
  position: absolute;
  top: 20px;
  right: 20px;
  padding: 0;
  background: 0 0;
  border: none;
  outline: 0;
  cursor: pointer;
  font-size: 16px;
}
.xd-dialog__headerbtn .xd-dialog__close {
  color: #909399;
}
.xd-dialog__headerbtn:focus .xd-dialog__close,
.xd-dialog__headerbtn:hover .xd-dialog__close {
  color: #409eff;
}
.xd-dialog__title {
  line-height: 24px;
  font-size: 18px;
  color: #303133;
}
.xd-dialog__body {
  padding: 30px 20px;
  color: #606266;
  font-size: 14px;
  word-break: break-all;
}
.xd-dialog__footer {
  padding: 10px 20px 20px;
  text-align: right;
  -webkit-box-sizing: border-box;
  box-sizing: border-box;
}
.xd-dialog--center {
  text-align: center;
}
.xd-dialog--center .xd-dialog__body {
  text-align: initial;
  padding: 25px 25px 30px;
}
.xd-dialog--center .xd-dialog__footer {
  text-align: inherit;
}
.dialog-fade-enter-active {
  -webkit-animation: dialog-fade-in 0.3s;
  animation: dialog-fade-in 0.3s;
}
.dialog-fade-leave-active {
  -webkit-animation: dialog-fade-out 0.3s;
  animation: dialog-fade-out 0.3s;
}
@-webkit-keyframes dialog-fade-in {
  0% {
    -webkit-transform: translate3d(0, -20px, 0);
    transform: translate3d(0, -20px, 0);
    opacity: 0;
  }
  100% {
    -webkit-transform: translate3d(0, 0, 0);
    transform: translate3d(0, 0, 0);
    opacity: 1;
  }
}
@keyframes dialog-fade-in {
  0% {
    -webkit-transform: translate3d(0, -20px, 0);
    transform: translate3d(0, -20px, 0);
    opacity: 0;
  }
  100% {
    -webkit-transform: translate3d(0, 0, 0);
    transform: translate3d(0, 0, 0);
    opacity: 1;
  }
}
@-webkit-keyframes dialog-fade-out {
  0% {
    -webkit-transform: translate3d(0, 0, 0);
    transform: translate3d(0, 0, 0);
    opacity: 1;
  }
  100% {
    -webkit-transform: translate3d(0, -20px, 0);
    transform: translate3d(0, -20px, 0);
    opacity: 0;
  }
}
@keyframes dialog-fade-out {
  0% {
    -webkit-transform: translate3d(0, 0, 0);
    transform: translate3d(0, 0, 0);
    opacity: 1;
  }
  100% {
    -webkit-transform: translate3d(0, -20px, 0);
    transform: translate3d(0, -20px, 0);
    opacity: 0;
  }
}

# 通用mixin

import merge from './merge.js';
import PopupManager from '@/components/popup/popup-manager.js';
import getScrollBarWidth from './scrollbar-width.js';
import { getStyle, addClass, removeClass, hasClass } from './utils.js';

let idSeed = 1;

let scrollBarWidth;

export default {
  props: {
    visible: {
      type: Boolean,
      default: false
    },
    openDelay: {},
    closeDelay: {},
    zIndex: {},
    modal: {
      type: Boolean,
      default: false
    },
    modalFade: {
      type: Boolean,
      default: true
    },
    modalClass: {},
    modalAppendToBody: {
      type: Boolean,
      default: false
    },
    lockScroll: {
      type: Boolean,
      default: true
    },
    closeOnPressEscape: {
      type: Boolean,
      default: false
    },
    closeOnClickModal: {
      type: Boolean,
      default: false
    }
  },

  beforeMount() {
    this._popupId = 'popup-' + idSeed++;
    PopupManager.register(this._popupId, this);
  },

  beforeDestroy() {
    PopupManager.deregister(this._popupId);
    PopupManager.closeModal(this._popupId);

    this.restoreBodyStyle();
  },

  data() {
    return {
      opened: false,
      bodyPaddingRight: null,
      computedBodyPaddingRight: 0,
      withoutHiddenClass: true,
      rendered: false
    };
  },

  watch: {
    visible(val) {
      console.log(2);
      if (val) {
        if (this._opening) return;
        if (!this.rendered) {
          this.rendered = true;
          setTimeout(() => {
            this.open();
          });
        } else {
          this.open();
        }
      } else {
        this.close();
      }
    }
  },

  methods: {
    open(options) {
      if (!this.rendered) {
        this.rendered = true;
      }

      const props = merge({}, this.$props || this, options);

      if (this._closeTimer) {
        clearTimeout(this._closeTimer);
        this._closeTimer = null;
      }
      clearTimeout(this._openTimer);

      const openDelay = Number(props.openDelay);
      if (openDelay > 0) {
        this._openTimer = setTimeout(() => {
          this._openTimer = null;
          this.doOpen(props);
        }, openDelay);
      } else {
        this.doOpen(props);
      }
    },

    doOpen(props) {
      if (this.$isServer) return;
      if (this.willOpen && !this.willOpen()) return;
      if (this.opened) return;

      this._opening = true;

      const dom = this.$el;

      const modal = props.modal;

      const zIndex = props.zIndex;
      if (zIndex) {
        PopupManager.zIndex = zIndex;
      }

      if (modal) {
        if (this._closing) {
          PopupManager.closeModal(this._popupId);
          this._closing = false;
        }
        PopupManager.openModal(
          this._popupId,
          PopupManager.nextZIndex(),
          this.modalAppendToBody ? undefined : dom,
          props.modalClass,
          props.modalFade
        );
        if (props.lockScroll) {
          this.withoutHiddenClass = !hasClass(document.body, 'el-popup-parent--hidden');
          if (this.withoutHiddenClass) {
            this.bodyPaddingRight = document.body.style.paddingRight;
            this.computedBodyPaddingRight = parseInt(getStyle(document.body, 'paddingRight'), 10);
          }
          scrollBarWidth = getScrollBarWidth();
          let bodyHasOverflow = document.documentElement.clientHeight < document.body.scrollHeight;
          let bodyOverflowY = getStyle(document.body, 'overflowY');
          if (
            scrollBarWidth > 0 &&
            (bodyHasOverflow || bodyOverflowY === 'scroll') &&
            this.withoutHiddenClass
          ) {
            document.body.style.paddingRight =
              this.computedBodyPaddingRight + scrollBarWidth + 'px';
          }
          addClass(document.body, 'el-popup-parent--hidden');
        }
      }

      if (getComputedStyle(dom).position === 'static') {
        dom.style.position = 'absolute';
      }

      dom.style.zIndex = PopupManager.nextZIndex();
      this.opened = true;

      this.onOpen && this.onOpen();

      this.doAfterOpen();
    },

    doAfterOpen() {
      this._opening = false;
    },

    close() {
      if (this.willClose && !this.willClose()) return;

      if (this._openTimer !== null) {
        clearTimeout(this._openTimer);
        this._openTimer = null;
      }
      clearTimeout(this._closeTimer);

      const closeDelay = Number(this.closeDelay);

      if (closeDelay > 0) {
        this._closeTimer = setTimeout(() => {
          this._closeTimer = null;
          this.doClose();
        }, closeDelay);
      } else {
        this.doClose();
      }
    },

    doClose() {
      this._closing = true;

      this.onClose && this.onClose();

      if (this.lockScroll) {
        setTimeout(this.restoreBodyStyle, 200);
      }

      this.opened = false;

      this.doAfterClose();
    },

    doAfterClose() {
      PopupManager.closeModal(this._popupId);
      this._closing = false;
    },

    restoreBodyStyle() {
      if (this.modal && this.withoutHiddenClass) {
        document.body.style.paddingRight = this.bodyPaddingRight;
        removeClass(document.body, 'el-popup-parent--hidden');
      }
      this.withoutHiddenClass = true;
    }
  }
};

export { PopupManager };

# dialog组件-component.vue

<template>
  <transition name="dialog-fade"
              @after-enter="afterEnter"
              @after-leave="afterLeave">
    <div v-show="visible"
         class="xd-dialog__wrapper"
         ref="dialogWrapper"
         @click.self="handleWrapperClick">
      <div :class="['xd-dialog', { 'is-fullscreen': fullscreen, 'xd-dialog--center': center }, customClass]"
           aria-modal="true"
           :aria-label="title || 'dialog'"
           :key="key"
           :style="style"
           ref="dialog">
        <div class="xd-dialog__header"
             ref="dialogHeader">
          <slot name="title">
            <span class="xd-dialog__title">{{ title }}</span>
          </slot>
          <button type="button"
                  class="xd-dialog__headerbtn"
                  aria-label="Close"
                  v-if="showClose"
                  @click="handleClose">
            <!-- <i class="xd-dialog__close el-icon el-icon-close"></i> -->
            X
          </button>
        </div>
        <div class="xd-dialog__body"
             v-if="rendered">
          <slot></slot>
        </div>
        <div class="xd-dialog__footer"
             v-if="$slots.footer">
          <slot name="footer"></slot>
        </div>
      </div>
    </div>
  </transition>
</template>
<script>
import Popup from '@/components/popup/index.js';
import { makeMoveable } from './moveable';
export default {
  name: 'XdDialog',
  mixins: [Popup],
  // components: {},
  props: {
    title: {
      type: String,
      default: ''
    },

    modal: {
      type: Boolean,
      default: true
    },

    modalAppendToBody: {
      type: Boolean,
      default: true
    },

    appendToBody: {
      type: Boolean,
      default: false
    },

    lockScroll: {
      type: Boolean,
      default: true
    },

    closeOnClickModal: {
      type: Boolean,
      default: true
    },

    closeOnPressEscape: {
      type: Boolean,
      default: true
    },

    showClose: {
      type: Boolean,
      default: true
    },

    width: String,

    fullscreen: Boolean,

    customClass: {
      type: String,
      default: ''
    },

    top: {
      type: String,
      default: ''
    },
    left: {
      type: String,
      default: ''
    },
    beforeClose: Function,
    center: {
      type: Boolean,
      default: false
    },

    destroyOnClose: Boolean,
    canDrag: {
      type: Boolean,
      default: false
    }
  },
  data() {
    return {
      closed: false,
      key: 0
    };
  },
  computed: {
    style() {
      let style = {};
      if (!this.fullscreen) {
        if (this.top) {
          style.top = this.top;
        }
        if (this.left) {
          style.left = this.left;
        }
        if (this.width) {
          style.width = this.width;
        }
        if (this.top || this.left) {
          style.transform = 'none';
        }
      }
      return style;
    }
  },
  // filters: {},
  watch: {
    visible(val) {
      console.log(1);
      if (val) {
        this.closed = false;
        this.$emit('open');
        this.$el.addEventListener('scroll', this.updatePopper);
        this.$nextTick(() => {
          this.$refs.dialog.scrollTop = 0;
        });
        if (this.appendToBody) {
          document.body.appendChild(this.$el);
        }
      } else {
        this.$el.removeEventListener('scroll', this.updatePopper);
        if (!this.closed) this.$emit('close');
        if (this.destroyOnClose) {
          this.$nextTick(() => {
            this.key++;
          });
        }
      }
    }
  },
  // created() {},
  mounted() {
    if (this.visible) {
      this.rendered = true;
      this.open();
      if (this.appendToBody) {
        document.body.appendChild(this.$el);
      }
    }

    if (this.canDrag && !this.fullscreen) {
      const dialogHeader = this.$refs['dialogHeader'];
      const dialog = this.$refs['dialog'];
      const dialogWrapper = this.$refs['dialogWrapper'];

      makeMoveable(dialogHeader, dialog, dialogWrapper, false);
    }
  },
  // beforeRouteEnter(to, from, next) { next(vm => {}) },
  // beforeRouteUpdate(to, from, next) {},
  // beforeRouteLeave(to, from, next) {},
  beforeDestroy() {
    if (this.appendToBody && this.$el && this.$el.parentNode) {
      this.$el.parentNode.removeChild(this.$el);
    }
  },
  // destroyed() {},
  methods: {
    afterEnter() {
      this.$emit('opened');
    },
    afterLeave() {
      this.$emit('closed');
    },
    handleWrapperClick() {
      if (!this.closeOnClickModal) return;
      this.handleClose();
    },
    handleClose() {
      if (typeof this.beforeClose === 'function') {
        this.beforeClose(this.hide);
      } else {
        this.hide();
      }
    },
    hide(cancel) {
      if (cancel !== false) {
        this.$emit('update:visible', false);
        this.$emit('close');
        this.closed = true;
      }
    }
  }
};
</script>

# 用法

<XdDialog title="提示"
          :visible="dialogVisible"
          :closeOnClickModal="false"
          left="20px"
          top="30px"
          :canDrag="true"
          @close="dialogVisible = false"
          fullscreen>
  <h4>asdasd</h4>
  <div slot="footer">
    <button @click="dialogVisible = false">关闭</button>
  </div>
</XdDialog>
上次更新: 1/22/2025, 9:39:13 AM