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

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

1. 本集定位

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

效果展现
图片描述vue

2. 需求分析

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

3. 基础的搭建

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

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

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

<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的写法, 有所收获.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);
    },

点击消失事件app

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
我的技术博客(组件的官网):技术博客

相关文章
相关标签/搜索