Element-UI源码剖析(二)—— PopupManager的具体实现

一、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滚动

在实现弹窗效果时通常我们都不希望浏览器还能继续滚动,特别是当弹窗里也有滚动条的时候,有双层滚动条的效果及体验都是极差的,锁定页面的原理:

  1. 设置标签的样式为overflow: hidden

  2. 给标签添加一个值为滚动条宽度的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;
}
相关推荐
小小竹子几秒前
前端vue-实现富文本组件
前端·vue.js·富文本
小白小白从不日白9 分钟前
react hooks--useReducer
前端·javascript·react.js
下雪天的夏风22 分钟前
TS - tsconfig.json 和 tsconfig.node.json 的关系,如何在TS 中使用 JS 不报错
前端·javascript·typescript
青稞儿27 分钟前
面试题高频之token无感刷新(vue3+node.js)
vue.js·node.js
diygwcom33 分钟前
electron-updater实现electron全量版本更新
前端·javascript·electron
Hello-Mr.Wang1 小时前
vue3中开发引导页的方法
开发语言·前端·javascript
程序员凡尘1 小时前
完美解决 Array 方法 (map/filter/reduce) 不按预期工作 的正确解决方法,亲测有效!!!
前端·javascript·vue.js
编程零零七5 小时前
Python数据分析工具(三):pymssql的用法
开发语言·前端·数据库·python·oracle·数据分析·pymssql
(⊙o⊙)~哦7 小时前
JavaScript substring() 方法
前端
无心使然云中漫步7 小时前
GIS OGC之WMTS地图服务,通过Capabilities XML描述文档,获取matrixIds,origin,计算resolutions
前端·javascript