前言:技术栈是vue的大帅哔和大漂亮们想必都用过 ElementUi 这个组件库,这是一个不错的开源库,但什么都不是完美的,存在一些隐性bug也很正常,话不多说,下面我们根据一个特定场景来复现一下。
一、场景复现
1、新建一个vue项目,在app.vue中加上缓存组件
app.vue文件:
<template>
<div id="app">
<keep-alive>
<router-view/>
</keep-alive>
</div>
</template>
2、创建a.vue组件和b.vue组件,对应路由/a和/b
a.vue:
<template>
<div>
<div>这是a页面</div>
<el-button @click="openDialog()" type="primary">打开a页面的弹框</el-button>
<el-dialog
title="提示"
:visible.sync="dialogVisible"
:modal-append-to-body="false"
width="30%">
<span>这是一段信息</span>
<el-button @click="toPage()" type="primary">跳转到b页面</el-button>
</el-dialog>
</div>
</template>
<script>
export default{
name:'a',
data(){
return{
dialogVisible: false,
}
},
methods:{
openDialog(){
this.dialogVisible = true;
},
toPage(){
this.$router.push({
path: '/b'
})
}
}
}
</script>
b.vue:
<template>
<div>
<div>这是b页面</div>
<el-button @click="openDialog()">打开b页面中的弹框</el-button>
<el-dialog
title="提示"
:visible.sync="dialogVisible"
:modal-append-to-body="false"
width="30%">
<span>这是一段信息</span>
</el-dialog>
</div>
</template>
<script>
export default{
name:'b',
data(){
return{
dialogVisible: false
}
},
methods:{
openDialog(){
this.dialogVisible = true
}
}
}
</script>
3、进行一系列操作
- 首先进入a.vue,长这样
- 点击按钮打开弹框
- 点击弹框中的按钮,跳转到b.vue
- 点击b.vue中按钮打开弹框
- 重点来了,此时关闭b.vue中的弹框,你会发现遮罩没有消失
- 并且返回到a.vue中后,a.vue中的遮罩消失了
以上就是该问题的复现过程,下面我们来看下问题具体出在哪。
二、问题分析
该问题的一个重要的前提条件就是路由加了缓存,然后一个重要现象就是在b页面点击关闭弹框,a页面的遮罩消失了,b页面本身的遮罩没有消失。由此有的小伙伴可能推测,是不是加了缓存后,这遮罩串了啊,关闭b页面的弹框,遮罩其实是关的a页面?确实,只看现象的话,大家都会这么想,下面我们来看下源码中是怎么写的。
有关Dialog遮罩的源码我copy了一份如下:
import Vue from 'vue';
import { addClass, removeClass } from 'element-ui/src/utils/dom';
let hasModal = false;
let hasInitZIndex = false;
let zIndex;
const getModal = function() {
if (Vue.prototype.$isServer) return;
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;
};
const instances = {};
const PopupManager = {
modalFade: true,
getInstance: function(id) {
return instances[id];
},
register: function(id, instance) {
if (id && instance) {
instances[id] = instance;
}
},
deregister: function(id) {
if (id) {
instances[id] = null;
delete instances[id];
}
},
nextZIndex: function() {
return PopupManager.zIndex++;
},
modalStack: [],
doOnModalClick: function() {
const topItem = PopupManager.modalStack[PopupManager.modalStack.length - 1];
if (!topItem) return;
const instance = PopupManager.getInstance(topItem.id);
if (instance && instance.closeOnClickModal) {
instance.close();
}
},
openModal: function(id, zIndex, dom, modalClass, modalFade) {
if (Vue.prototype.$isServer) return;
if (!id || zIndex === undefined) return;
this.modalFade = modalFade;
const modalStack = this.modalStack;
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 (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);
if (dom && dom.parentNode && dom.parentNode.nodeType !== 11) {
dom.parentNode.appendChild(modalDom);
} else {
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 = 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 || (Vue.prototype.$ELEMENT || {}).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;
}
};
if (!Vue.prototype.$isServer) {
// handle `esc` key when the popup is shown
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());
}
}
});
}
export default PopupManager;
下面我们来简单的分析下:
当打开弹框时,肯定就调用 openModal 这个方法,然后我们重点来看这个函数 getModal ,顾名思义,就是创建遮罩的函数:
const getModal = function() {
if (Vue.prototype.$isServer) return;
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;
};
其中有一个对 PopupManager.modalDom 的判断,如果存在复用,不存在就创建。
到这里,大家肯定心里有谱了,ElementUi的Dialog的遮罩其实是复用的一个元素,这样其实是合理的,减少创建dom,提升性能。
然后我们通过代码也不难发现,当一个页面组件中出现多个弹框的时候,由于是共用一个遮罩,所以需要一个栈 modalStack 来保存表示对应遮罩的对象。
这样,当我一开始进来,打开第一个弹框,第一次没有遮罩,先创建一个并复制给 PopupManager.modalDom ,然后改变这个遮罩的 zIndex ,然后将这个遮罩的信息push进 modalStack
this.modalStack.push({ id: id, zIndex: zIndex, modalClass: modalClass });
然后我跳转到b页面,再次打开弹框,由于添加了路由缓存,这时之前的遮罩还在,就开始复用,改变 zIndex 的值,使遮罩层级向上增,同时再次push进 modalStack 。
接下来我关闭弹框的时候,根据 modalStack 的length来判断并走不同的逻辑。当不是0时证明关闭的不是最后一个弹框,就将遮罩dom的 zIndex 设置成上一个的。是0时,证明最后一个弹框关闭了,清除遮罩dom。
经过上面的简要分析,我们找到了问题所在,添加路由缓存后,我们在不同页面组件打开弹框,这些弹框用的是一个组件,我打开和关闭弹框时只是改变的遮罩层级,所以就出现了上文场景的那个问题。
三、解决问题
原因找到了,那么解决就很简单了。我们给弹框加一个属性 independent-modal ,来规定弹框是否启用自己独立的遮罩,true 使使用独立遮罩,默认为 false
<el-dialog
title="提示"
:visible.sync="dialogVisible"
:modal-append-to-body="false"
:independent-modal="true"
width="30%">
<span>这是一段信息</span>
<el-button @click="toPage()" type="primary">跳转到b页面</el-button>
</el-dialog>
这里具体代码就不做过多赘述了,实现比较简单。此文章编写时ElementUi的版本是2.15.3,我已经 Pull requests 了,但我感觉作者采用的可能性很小,因为这不是修复一个小bug,而是添加一个配置属性。
至此就是这篇文章的全部内容,如有错误之处还请各位大佬指正。