深入源码分析ElementUi中的Dialog弹框问题

2,510 阅读3分钟

前言:技术栈是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,长这样

image.png

  • 点击按钮打开弹框

image.png

  • 点击弹框中的按钮,跳转到b.vue

image.png

  • 点击b.vue中按钮打开弹框

image.png

  • 重点来了,此时关闭b.vue中的弹框,你会发现遮罩没有消失

image.png

  • 并且返回到a.vue中后,a.vue中的遮罩消失了

image.png

以上就是该问题的复现过程,下面我们来看下问题具体出在哪。

二、问题分析

该问题的一个重要的前提条件就是路由加了缓存,然后一个重要现象就是在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,而是添加一个配置属性。

image.png

至此就是这篇文章的全部内容,如有错误之处还请各位大佬指正。