第十四集-从零开始实现一套pc端vue的ui组件库-Popover弹出框

第十四集: 从零开始实现一套pc端vue的ui组件库( Popover弹出框 )

1. 本集定位

Popover组件不同于alert这种霸道总裁, 它更倾向于辅助显示某些未显示完整的内容, toast组件与其相比更偏向’提示’, Popover更偏向于’展示’, 但属于一种’轻展示’, 毕竟不会出现’蒙层’等效果.
别看它小小的, 它里面的门道还不少, 最主要的就是他的定位问题, 比如说它设定为出现在元素上方, 可是元素自己已经在最顶上了, 此时就需要给他’换个方位’展示了, 关于这个定位的计算方式还可以在其他组件上应用, 比如下一集要写的’日期组件’, 还有就是这个弹出框的消失时机, 本人更推荐只要滚动就清除它, 每次计算他的位置所消耗的性能很高的, 因为每次都会触发重排与重绘, 话不多说本次我们就一起来搞一搞这个小东西.????

效果展示

2. 需求分析

  1. 可配置触发的形式, 比如’点击’与’悬停’.
  2. 可定义组件出现的位置, ‘上左’、’上中’、’上右’等等情况吧…
  3. 本组件可能会被大批量的使用, 性能优化方面需要重点考虑.
  4. 及时移除相关事件

3. 基础的搭建

vue-cc-ui/src/components/Popover/index.js

export { default } from './main/index';

vue-cc-ui/src/components/Popover/main/popover.vue

<template>
// 老套路, 父级
  <div class="cc-popover" ref='popover'>
// 内容区域
      <div class="cc-popover__content" ref='content'>
      // 这里分了两层是为了解决一会遇到的问题的
        <div class="cc-popover__box">
          <slot name="content"> 请输入内容</slot>
        </div>
      </div>
     // 这个是被包裹的元素;
     // 要用我们的popover标签起来才有效;
      <slot />
  </div>
</template>
export default {
  name: "ccPopover",
  props: {
    // 事件类型用户自己传, 本次只支持两种模式
    trigger: {
      type: String,
      default: "hover",
      // 这里为了扩展所以这样写
      // 只有两种情况可以优化为只要不是click就默认给hover
      validator: value => ["click", "hover"].indexOf(value) > -1
    },
    placement: {
    // 方位我们定位的范围是, 每个方向都有'开始','中间','结束'三种情况
      type: String,
      default: "right-middle",
      validator(value) {
        let dator = /^(top|bottom|left|right)(-start|-end|-middle)?$/g.test(
          value
        );
        return dator;
      }
    }
  },

初始化项目的一些操作
通过用户的输入, 来给dom添加监听事件
下面的on 方法 其实是借鉴了element-ui的写法, 有所收获.

  mounted() {
    this.$nextTick(() => {
    // 获取到当前用户定义的事件类型
      let trigger = this.trigger,
      // 本次选择操作dom
        popover = this.$refs.popover;
      if (trigger === "hover") {
        // hover当然要监听 进入与离开的事件拉
        on(popover, "mouseenter", this.handleMouseEnter);
        on(popover, "mouseleave", this.handleMouseLeave);
      } else if (trigger === "click") {
        on(popover, "click", this.handlClick);
      }
    });
  },

on方法的封装
element还判断了是不是服务器环境等操作, 我们这里只选取了浏览器端相关的代码.

vue-cc-ui/src/assets/js/utils.js

// 添加事件, element-ui判断是不是服务器环境
export function on(element, event, handler) {
  if (element && event && handler) {
    element.addEventListener(event, handler, false);
  }
}
// 移除事件
export function off(element, event, handler) {
  if (element && event) {
    element.removeEventListener(event, handler, false);
  }
}

4. 从点击事件说起

假设用户传入的事件类型是’click’, mounted里面的操作已经绑定了相应的事件’handlClick’,接下来的任务是:

  1. 让提示框出现或者消失.
  2. 如果是出现, 计算要出现在什么位置.
  3. 如果是出现, 为document绑定事件, 用于隐藏这个popover.

思路概述

  1. this.init变量来配合v-if, 这样保证组件在没有被使用过的情况下, 永远不会渲染出来.
  2. 涉及到频繁点击时, v-show就要登场了, this.show控制v-show, 所以这两个指令可以来一次亲密配合.
  3. 事件不要绑定在body上, 有一种可能就是用户body没有完全包裹内容, 比如不设高度.
handlClick() {
      // 不管怎么样只要触发一次, 这个值就会把v-if永远置成true;
      this.init = true;
      // 在他本身被css属性隐藏的时候
      if (this.$refs.content && this.$refs.content.style.display === "none") {
        // 必须这样强制写, 
        // 否则与之后的代码配合时, 有bug无法消失
        this.$refs.content.style.display = "block";
        this.show = true;
      } else {
      // 除了第一次之外, 之后都只是变换这个this.show的'真假'
        this.show = !this.show;
      }
      // 不要监听body, 因为可能height不是100%;
      // 这个document其实也可以由用户指定
      // 放入让popover消失的函数, 这样方便之后的移除事件操作
      this.show && document.addEventListener("click", this.close);
    },

点击消失事件

 close(e) {
    // 肯定要判断事件源到底是不是咱们的popover组件
      if (this.isPopover(e)) {
        this.show = false;
        // 点击完就可以移除了, 下次操作再绑定就可以
        // 因为如果往document绑定太多事件, 会非常卡, 非常卡
        document.removeEventListener("click", this.close);
      }
    },

isPopover

  1. 这个负责判断点击的元素是不是popover组件
  2. 点击popover弹出层里面的元素, 也算是点击popover, 因为用户可能会通过slot传入一些结构, 这种情况不能关闭.
isPopover(e) {
      let dom = e.target,
        popover = this.$refs.popover,
        content = this.$refs.content;
        // 1: 点击popover包裹的元素, 关闭popover
        // 2: 点击popover内容区元素, 不关闭popover
      return !(popover.contains(dom) || content.contains(dom));
    },

上面讲述了具体的出现与消失的逻辑, 接下来我们来让他真正的出现在屏幕上

watch: {
   // 我们会监控v-if的情况, 第一次渲染的时候才做这里的操作, 而且只执行一次
    init() {
      this.$nextTick(() => {
        let trigger = this.trigger,
          dom = this.$refs.content,
          content = this.$refs.content;
          // 这里有人会有疑问, 这什么鬼写法
          // 这里是因为append操作属于剪切, 所以不会出现两个元素
          // 其实这个元素出现之后就一直存在与页面上了, 除非销毁本组件
          // 组件销毁的时候, 我们会document.body.removeChild(content);
        document.body.appendChild(dom);
        if (trigger === "hover") {
          on(content, "mouseenter", this.handleMouseEnter);
          on(content, "mouseleave", this.handleMouseLeave);
        }
      });
    },
    // 这个才是每次显示隐藏都会触发的方法
    show() {
    // 判断只有显示提示框的时候才回去计算位置
      if (this.show) {
        this.$nextTick(() => {
          let { popover, content } = this.$refs,
            { left, top, options } = getPopoverposition(
              popover,
              content,
              this.placement
            );
          // 有了坐标, 就可以很开心的定位了
          this.left = left;
          this.top = top;
          // 这个配置是决定 '小三角' 的位置的
          this.options = options;
        });
      }
    }
  },

5. 重点问题, 获取显示的位置 getPopoverposition

思路

  1. 先实验是否可以按照用户传进来的坐标进行展示.
  2. 如果不可以按照用户传进来的坐标进行展示, 循环所有展示方式, 查看是否有可用的方案.
  3. 获取dom坐标会引起重排重绘, 所以获取坐标的工作我们只做一次.

vue-cc-ui/src/assets/js/vue-popper.js

// 受到vue源码实例化vue部分的启发, 有了如下写法.
// CONTANT 常数: 物体距离目标的间隙距离, 单位px;
function getPopoverPosition(popover, content, direction,CONTANT ) {
   // 这个show本次用不到, 为以后的组件做准备
  let result = { show: true };
  // 1: 让这个函数去初始化'参与运算的所有参数';
  // 把处理好的值, 付给result对象
  getOptions(result, popover, content, direction,CONTANT );
  // 2: 拿到屏幕的偏移
  let { left, top } = getScrollOffset();
  // 3: return出去的坐标, 一定是针对当前可视区域的
  result.left += left;
  result.top += top;
  return result;
}

先把所有可能做成列表, 也许有人有疑问, 为什么不把list这个组for循环生成, 那是因为for循环也是需要性能的, 这样直接下来可以减少运算, 所以很多没必要的运算尽量不要写

const list = [
  'top-end',
  'left-end',
  'top-start',
  'right-end',
  'top-middle',
  'bottom-end',
  'left-start',
  'right-start',
  'left-middle',
  'right-middle',
  'bottom-start',
  'bottom-middle'
];

getOptions 初始化运算所需参数

function getOptions(result, popover, content, direction,CONTANT = 10) {
 // 1: 可能会反复的调用, 所以来个深复制
  let myList = list.concat(),
    client = popover.getBoundingClientRect();// 获取popover的可视区距离
 // 2: 每次使用一种模式, 就把这个模式从list中干掉, 这样直到数组为空, 就是所有可能性都尝试过了
  myList.splice(list.indexOf(direction), 1);
 // 3: 把参数整理好, 传给处理函数
  getDirection(result, {
    myList,
    direction,
    CONTANT,
    top: client.top,
    left: client.left,
    popoverWidth: popover.offsetWidth,
    contentWidth: content.offsetWidth,
    popoverHeight: popover.offsetHeight,
    contentHeight: content.offsetHeight
  });
}

getDirection
代码有点多, 但是逻辑很简单, 我来说一下思路

  1. 比如用户传入的是’top-end’ 拆分为 top 与 end 字段
  2. 也就是要出现在目标元素的上方,靠右边.
  3. 针对end–> result.left = 目标元素左侧距离可视区 + 目标元素宽度 – 弹出框宽度;
  4. 针对top–> result.top = 目标元素上方距离可视区 – 弹出框高度 – 两者间距距离;
  5. 没有什么复杂逻辑, 就是单纯的算术
function getDirection(result, options) {
  let {
    top,
    left,
    CONTANT,
    direction,
    contentWidth,
    popoverWidth,
    contentHeight,
    popoverHeight
  } = options;
  result.options = options;
  let main = direction.split('-')[0],
    around = direction.split('-')[1];
  if (main === 'top' || main === 'bottom') {
    if (around === 'start') {
      result.left = left;
    } else if (around === 'end') {
      result.left = left + popoverWidth - contentWidth;
    } else if (around === 'middle') {
      result.left = left + popoverWidth / 2 - contentWidth / 2;
    }
    if (main === 'top') {
      result.top = top - contentHeight - CONTANT;
    } else {
      result.top = top + popoverHeight + CONTANT;
    }
  } else if (main === 'left' || main === 'right') {
    if (around === 'start') {
      result.top = top;
    } else if (around === 'end') {
      result.top = top + popoverHeight - contentHeight;
    } else if (around === 'middle') {
      result.top = top + popoverHeight / 2 - contentHeight / 2;
    }
    if (main === 'left') {
      result.left = left - contentWidth - CONTANT;
    } else {
      result.left = left + popoverWidth + CONTANT;
    }
  }

  testDirection(result, options);
}

testDirection 检验算出来的值是否能够出现在用户的视野里面
思路

  1. 算出弹出框的四个角, 是否都在可视区之内, 是否有显示不全的.
  2. 比如说left为负数, 肯定有被遮挡的地方.
  3. 如果不符合要求, 就继续循环list里面的类型, 重新算定位的left与top.
  4. 如果循环到最后都没有合适的, 那就用最后一个方案.

function testDirection(result, options) {
  let { left, top } = result,
    width = document.documentElement.clientWidth,
    height = document.documentElement.clientHeight;
  if (
    top < 0 ||
    left < 0 ||
    top + options.contentHeight > height ||
    left + options.contentWidth > width
  ) {
    // 还有可以循环的
    if (options.myList.length) {
      options.direction = options.myList.shift();
      getDirection(result, options);
    } else {
      // 实在不行就在父级身上
      result.left = options.left;
      result.right = options.right;
    }
  } else {
    result.show = true;
  }
}

dom结构上要相应的加上对应的样式
这里的click一定不可以用stop修饰符, 会干扰用户的正常操作.
这里我们加上一个动画, 看起来渐隐渐现的有点美感.

<div class="cc-popover"
       ref='popover'>
    
    <!-- 不可以使用stop 会阻止用户的操作 -->
    <transition name='fade'>
      <div v-if="init"
           ref='content'
           v-show='show'
           class="cc-popover__content"
           :class="options.direction"
           :style="{ // 这里就是控制定位的关键
               top:top+'px',
               left:left+'px'
           }">
        <div class="cc-popover__box">
          <slot name="content"> 请输入内容</slot>
        </div>
      </div>
    </transition>
    <slot />
  </div>

6. hover状态

上面在watch里面也有体现了, 与click的区别就是, 绑定的事件不同
这里消失有200毫秒的延迟, 是因为用户离开目标元素,可能是为了移入popover弹出框

 // 移入
    handleMouseEnter() {
      clearTimeout(this.time);
      this.init = true;
      this.show = true;
    },
    // 移出
    handleMouseLeave() {
      clearTimeout(this.time);
      this.time = setTimeout(() => {
        this.show = false;
      }, 200);
    }

7. 定义’清除指令’与收尾工作

vue-cc-ui/src/components/Popover/main/index.js
思路

  1. 挂载$clearPopover 命令, 执行效果是隐藏屏幕上所有的popover提示框
  2. 之前工作遇到这样一个情况, 有两张表单, 定位在一起, 一个在上面一个在下面,结果切换的时候,上一份表单的popover在第二份上面, 由此我根据需要一个全局的清理方法 .
  3. 监听window的滚动事件, 每次滚动把所有的popover都隐藏.
  4. 自定义指令 v-scroll-clear-popover, 放在某个元素上, 就会监听这个元素的滚动事件, 从而隐藏popover弹出框.
  5. 当然了, 这些监听滚动的方法, 都做了节流, 400毫秒触发一次
import Popover from './popover.vue';
import prevent from '@/assets/js/prevent';
Popover.install = function(Vue) {
  Vue.component(Popover.name, Popover);
  Vue.prototype.$clearPopover = function() {
    let ary = document.getElementsByClassName('cc-popover__content');
    for (let i = 0; i < ary.length; i++) {
        ary[i].style.display = 'none';
    }
  };
  // 监听指令
  window.addEventListener('scroll',()=>{
    prevent(1,() => {
      Vue.prototype.$clearPopover()
    },400);
  },false)
   
  Vue.directive('scroll-clear-popover', {
    bind: el => {
      el.addEventListener('scroll', ()=>{
        prevent(1,() => {
          Vue.prototype.$clearPopover()
        },400);
      }, false);
    }
  });
};

export default Popover;

不要小看这个, 如果没有这个收尾工作, 也许内存都爆了.
移除所有事件, 删除dom元素

  beforeDestroy() {
    let { popover, content } = this.$refs;
    off(content, "mouseleave", this.handleMouseLeave);
    off(popover, "mouseleave", this.handleMouseLeave);
    off(content, "mouseenter", this.handleMouseEnter);
    off(popover, "mouseenter", this.handleMouseEnter);
    off(document, "click", this.close);
    document.body.removeChild(content);
  }

展示一下最终效果

end

大家都可以一起交流, 共同学习,共同进步, 早日实现自我价值!!
下一集聊聊’日历组件’

工程github地址:github
个人技术博客(组件的官网):技术博客

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理