一、PopupManager弹窗管理
为什么要对弹窗进行管理呢?这里所有的弹窗不单指 Modal,message、darwer 其实都算,因为我们的页面功能可以非常复杂,可能同时存在打开多个弹窗组件、嵌套弹窗的情况,为了满足我们的需求,所以需要对这些弹窗组件的层级关系进行管理!
1. 采用栈存储弹窗实例
由于可以同时打开多个弹窗,要保证最后打开的弹窗在最上面,因此采用栈结构来存储弹窗实例,符合先进后出的特点。每次调用方法 nextZIndex 递增 z-index,保证了最后打开的弹窗组件的 z-index 是最大的。
js
import Vue from 'vue';
import { addClass, removeClass } from 'element-ui/src/utils/dom';
// 当前弹窗的z-index
let zIndex;
// 存储所有弹窗实例 结构:{id1: 实例1, id2: 实例2}
const instances = {};
const PopupManager = {
// 通过ID获取指定弹窗实例
getInstance: function(id) {
return instances[id];
},
// 注册弹窗实例
register: function(id, instance) {
if (id && instance) {
instances[id] = instance;
}
},
// 根据ID删除某个弹窗实例
deregister: function(id) {
if (id) {
instances[id] = null;
delete instances[id];
}
},
// 得到下一个弹窗的z-index,使得后面打开的弹窗在最上面
nextZIndex: function() {
return PopupManager.zIndex++;
},
// 弹窗队列 结构:[{id: xxx, zIndex: xxxx, modalClass: xxx}]
modalStack: [],
// 处理遮罩层点击事件
doOnModalClick: function() {
// 找到队列中的最后一个
const topItem = PopupManager.modalStack[PopupManager.modalStack.length - 1];
if (!topItem) return;
// 获取该弹窗实例
const instance = PopupManager.getInstance(topItem.id);
// 如果属性closeOnClickModal为true
if (instance && instance.closeOnClickModal) {
// 执行关闭回调
instance.close();
}
},
};
2. 获取或者生成遮罩层元素
遮罩层是一个独立的元素,与弹窗组件的 HTML 结构是分离的,可将遮罩层元素挂载到任意的位置
js
let hasModal = false;
const getModal = function() {
let modalDom = PopupManager.modalDom;
// 如果有直接返回
if (modalDom) {
hasModal = true;
} else {
// 没有时创建一个DIV
hasModal = false;
modalDom = document.createElement('div');
PopupManager.modalDom = modalDom;
// 给遮罩层绑定点击事件
modalDom.addEventListener('click', function() {
PopupManager.doOnModalClick && PopupManager.doOnModalClick();
});
}
return modalDom;
};
3. 核心实例PopupManager
下面是打开弹窗的主要逻辑,主要针对遮罩层元素进行处理:添加类名、挂载元素、设置属性等
js
const PopupManager = {
.......
// 打开弹窗
openModal: function(id, zIndex, dom, modalClass) {
if (!id || zIndex === undefined) return;
// 获取弹窗队列
const modalStack = this.modalStack;
// 如果当前队列中已经存在该ID的弹窗,退出
for (let i = 0, j = modalStack.length; i < j; i++) {
const item = modalStack[i];
if (item.id === id) {
return;
}
}
// 获取/生成遮罩层元素
const modalDom = getModal();
// 给遮罩层添加类名
addClass(modalDom, 'v-modal');
if (modalClass) {
let classArr = modalClass.trim().split(/\s+/);
classArr.forEach(item => addClass(modalDom, item));
}
// 根据传入的参数将遮罩层挂载到不同的位置
if (dom && dom.parentNode && dom.parentNode.nodeType !== 11) {
dom.parentNode.appendChild(modalDom);
} else {
document.body.appendChild(modalDom);
}
// 给遮罩层设置z-index以及其他属性
if (zIndex) {
modalDom.style.zIndex = zIndex;
}
modalDom.tabIndex = 0;
modalDom.style.display = '';
// 将当前打开的弹窗塞进队列中
this.modalStack.push({ id: id, zIndex: zIndex, modalClass: modalClass });
},
};
以下是关闭弹窗的主要逻辑:通过指定的 ID,在栈中删掉该弹窗实例,移除类名,如果栈为空,那么还需要将遮罩层给移除掉,同时 PopupManager.modalDom = undefined。
js
const PopupManager = {
......
// 关闭弹窗
closeModal: function(id) {
// 获取弹窗队列
const modalStack = this.modalStack;
// 获取/生成遮罩层元素
const modalDom = 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) {
setTimeout(() => {
if (modalStack.length === 0) {
// 移除遮罩层
if (modalDom.parentNode) modalDom.parentNode.removeChild(modalDom);
modalDom.style.display = 'none';
PopupManager.modalDom = undefined;
}
}, 200);
}
}
};
4. 定义响应式变量zIndex
js
Object.defineProperty(PopupManager, 'zIndex', {
configurable: true,
get() {
if (!hasInitZIndex) {
// z-index可全局配置
zIndex = zIndex || (Vue.prototype.$ELEMENT || {}).zIndex || 2000;
hasInitZIndex = true;
}
return zIndex;
},
set(value) {
zIndex = value;
}
});
5. 按下ESC键关闭弹窗
js
window.addEventListener('keydown', function(event) {
if (event.keyCode === 27) {
const topPopup = getTopPopup();
if (topPopup && topPopup.closeOnPressEscape) {
topPopup.handleClose
? topPopup.handleClose()
: (topPopup.handleAction ? topPopup.handleAction('cancel') : topPopup.close());
}
}
});
二、popup混入mixins
代码所处的位置:src/utils/popup/index.js
这部分代码会混入每个弹窗组件中,主要实现的功能:
在页面挂载和卸载时调用 PopupManager
中的注册/删除实例的方法;在打开和关闭弹窗时调用 PopupManager
中对应的方法,外部组件传入的部分 props 属性、遮罩层、延迟弹窗的打开以及关闭等内容均在这段代码中进行处理。
js
import Vue from 'vue';
import merge from 'element-ui/src/utils/merge';
import PopupManager from 'element-ui/src/utils/popup/popup-manager';
import getScrollBarWidth from '../scrollbar-width';
import { getStyle, addClass, removeClass, hasClass } from '../dom';
// 用于生成id
let idSeed = 1;
// 滚动条的宽度
let scrollBarWidth;
// 暴露Vue配置项,相当于混入(mixin)
export default {
props: {
// 这里省略属性的类型以及默认值
visible, openDelay, closeDelay, zIndex, modal, modalClass,
modalAppendToBody, lockScroll, closeOnPressEscape, closeOnClickModal
},
beforeMount() {
// 生成弹窗ID
this._popupId = 'popup-' + idSeed++;
// 注册弹窗实例,将当前Vue实例 this 作为参数传入
PopupManager.register(this._popupId, this);
},
beforeDestroy() {
// 销毁该弹窗实例
PopupManager.deregister(this._popupId);
// 执行关闭弹窗的回调
PopupManager.closeModal(this._popupId);
// 重置body的样式
this.restoreBodyStyle();
},
data() {
return {
opened: false, // 弹窗是否已经打开
bodyPaddingRight: null, // 记录body的paddingRight
computedBodyPaddingRight: 0, // body最终的paddingRight属性
withoutHiddenClass: true, // 没有添加隐藏的类名
rendered: false // 是否已经渲染
};
},
watch: {
visible(val) {
if (val) {
// 如果正在打开
if (this._opening) return;
if (!this.rendered) {
this.rendered = true;
Vue.nextTick(() => {
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);
// 如果延迟打开弹窗,加个定时器_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.opened) return;
// 设置正在打开的状态为true
this._opening = true;
// 拿到当前dom
const dom = this.$el;
// modal: 是否需要遮罩层
const modal = props.modal;
const zIndex = props.zIndex;
// 设置z-index
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);
// 如果设置了在 Dialog 出现时将 body 滚动锁定
if (props.lockScroll) {
.......
}
}
// 设置定位
if (getComputedStyle(dom).position === 'static') {
dom.style.position = 'absolute';
}
// 设置z-index
dom.style.zIndex = PopupManager.nextZIndex();
// 已经打开
this.opened = true;
this.doAfterOpen();
},
// 打开弹窗后的操作
doAfterOpen() {
// 设置正在打开的状态为false
this._opening = false;
},
close() {
// 清除延迟打开弹窗的定时器
if (this._openTimer !== null) {
clearTimeout(this._openTimer);
this._openTimer = null;
}
clearTimeout(this._closeTimer);
const closeDelay = Number(this.closeDelay);
// 如果延迟打开弹窗,加个定时器_closeTimer
if (closeDelay > 0) {
this._closeTimer = setTimeout(() => {
this._closeTimer = null;
this.doClose();
}, closeDelay);
} else {
this.doClose();
}
},
doClose() {
// 设置正在关闭的状态为true
this._closing = true;
// 如果设置了在 Dialog 出现时将 body 滚动锁定
if (this.lockScroll) {
// 重置body的样式
setTimeout(this.restoreBodyStyle, 200);
}
this.opened = false;
this.doAfterClose();
},
// 关闭弹窗后的操作
doAfterClose() {
PopupManager.closeModal(this._popupId);
// 设置正在关闭的状态为false
this._closing = false;
},
.....
}
};
export {
PopupManager
};
下面是一些功能具体的实现细节:
1. 弹窗出现时锁定body滚动
在实现弹窗效果时通常我们都不希望浏览器还能继续滚动,特别是当弹窗里也有滚动条的时候,有双层滚动条的效果及体验都是极差的,锁定页面的原理:
-
设置标签的样式为overflow: hidden
-
给标签添加一个值为滚动条宽度的padding-right
Element-UI 的源码中将 body 滚动锁定的逻辑如下:
js
if (props.lockScroll) {
this.withoutHiddenClass = !hasClass(document.body, 'el-popup-parent--hidden');
// 如果body上没有隐藏的类名
if (this.withoutHiddenClass) {
// 记录了锁定滚动条之前body原来的padingRight,以便在解锁后恢复
this.bodyPaddingRight = document.body.style.paddingRight;
this.computedBodyPaddingRight = parseInt(getStyle(document.body, 'paddingRight'), 10);
}
// 滚动条的宽度
scrollBarWidth = getScrollBarWidth();
// 是否内容溢出
let bodyHasOverflow = document.documentElement.clientHeight < document.body.scrollHeight;
// 获取body元素在垂直方向的滚动属性
let bodyOverflowY = getStyle(document.body, 'overflowY');
if (scrollBarWidth > 0 && (bodyHasOverflow || bodyOverflowY === 'scroll') && this.withoutHiddenClass) {
// 计算body的paddingRight
document.body.style.paddingRight = this.computedBodyPaddingRight + scrollBarWidth + 'px';
}
// 给body添加类名
addClass(document.body, 'el-popup-parent--hidden');
}
样式:
js
.el-popup-parent--hidden {
overflow: hidden;
}
2. 获取滚动条的宽度
构造两个元素(父子关系),通过设置 overflow: scroll
使得父元素 offsetWidth 包括滚动条,子元素的不包括,两个宽度作差即可得到滚动条宽度:
js
let scrollBarWidth;
export default function() {
if (scrollBarWidth !== undefined) return scrollBarWidth;
// outer------父元素
const outer = document.createElement('div');
outer.className = 'el-scrollbar__wrap';
// 使得构造的元素不可见
outer.style.visibility = 'hidden';
outer.style.width = '100px';
outer.style.position = 'absolute';
outer.style.top = '-9999px';
document.body.appendChild(outer);
const widthNoScroll = outer.offsetWidth;
outer.style.overflow = 'scroll'; // 让父元素出现滚动条
// inner------子元素
const inner = document.createElement('div');
inner.style.width = '100%';
outer.appendChild(inner);
const widthWithScroll = inner.offsetWidth;
// 得到宽度后移除构造的元素
outer.parentNode.removeChild(outer);
scrollBarWidth = widthNoScroll - widthWithScroll;
return scrollBarWidth;
};
3. 弹窗关闭后重置body样式
关键:锁定 body 滚动条时记录了原来的padingRight,然后赋值即可
js
restoreBodyStyle() {
if (this.modal && this.withoutHiddenClass) {
// 给body设置为原来的paddingRight,并移除类名
document.body.style.paddingRight = this.bodyPaddingRight;
removeClass(document.body, 'el-popup-parent--hidden');
}
this.withoutHiddenClass = true;
}