# 如何实现一个Dialog弹出框
[toc]
我们首先需要考虑几个事情,弹出框由2部分组成,遮罩层和弹出框内容
需要一个遮罩层管理对象。
这个管理对象有几个职责:
- 遮罩层的打开和关闭(添加一些基础渐入渐出动画)
- 遮罩层的单例管理,(一般多个弹出层嵌套也只需要一个遮罩DOM)
- 弹出框与遮罩层的映射关系(弹出框实例生成一个id映射弹出框实例本身,注册到管理对象)
- 响应遮罩层点击事件
- zIndex的统一管理
弹出框内容
- 只需要关注打开和关闭即可,还有就是一些钩子问题
- 剩下的就是样式问题
- 可以优化内容的延迟加载
- 实现拖拽功能
# 弹出层管理对象 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>