3
本篇文章记录仿写一个el-tooltip组件细节,从而有助于大家更好理解饿了么ui对应组件具体工作细节。本文是elementui源码学习仿写系列的又一篇文章,后续空闲了会不断更新并仿写其他组件。源码在github上,大家可以拉下来,npm start运行跑起来,结合注释有助于更好的理解。github仓库地址如下:
https://github.com/shuirongsh...

前言

什么是编程

关于什么是编程这个问题,的确有很多答案。在很久以前,在笔者刚入行的时候,被告知了这样一句话:

编程就是:规则的学习,规则的使用,规则的理解、规则的自定义

有一定的道理...

背景介绍

我们在做组件的封装时,常常会遇到一些“弹框组件”,以饿了么UI为例,比如:el-tooltip组件el-popover组件el-popconfirm组件el-dropdown组件等,这类组件在操作的时候,常常会有一个弹框出现,对于这些弹框的触发条件(或悬浮、或点击)以及位置的控制(上方、下方、左侧、右侧)等,vue团队专门封装了一个vue-popper组件,通过props传参以及一些事件方法的方式,去控制以达到我们想要的效果

那么,vue-popper组件是如何实现的呢?底层原理是啥?是把popper.js这个很优秀的库做了一层封装

那么,popper.js是如何实现的呢?底层原理是啥?是通过js控制弹出框dom的位置

由于popper.js国内资料不多,所以大家可以直接使用vue-popper组件组件去做一些操作即可,毕竟其底层原理,也是prpper.js

  • el-tooltip组件是使用了vue-popper组件的规则
  • vue-popper组件是使用了popper.js库的规则
  • popper.js库是使用了js和dom的规则
  • 无限规则套娃...

附上传送门

prpper.js 官网:https://popper.js.org/、中文...

感兴趣的道友,可以空闲时间研究研究(像elementUIiviewBootstrapMaterial UI等都用到了proper.js)也是做的二次封装

另:prpper.js团队专门给react写了一套React Poppervue暂时没有,所以咱们就学习vue-popper

本篇文章着眼于,中层底层原理vue-popper组件,让我们开始学习吧

tooltip组件思考

什么是tooltip组件

  1. tooltip组件是用来做简单的文字附带说明(提示)的气泡框组件
  2. 一般交互是鼠标移入显示,鼠标移出消失
  3. tooltip组件一般不会做复杂的交互操作,以及承载过多的文本内容
  4. 可以理解为是dom元素title属性功能的具体补充

tooltip组件需求

  1. 暗黑模式tooltip,黑底白字
  2. 高亮模式tooltip,白底黑字
  3. tooltip组件的位置,在指向引用reference元素的那个方向,一般是上下左右,拓展共有12个方向
  4. tooltip的小三角形(一般是显示的)
  5. 可控制关闭开启,即符合条件hover展示,反之hover关闭
  6. 一般情况下tooltip都是单行内容,若内容过多,支持文字换行乃至自定义tooltip一些样式(支持插槽)
  7. 至于其他的需求如:tooltip显示展开的过渡动画、小箭头是否可以隐藏、以及偏移量offset、延迟出现消失等,一般情况下不会怎么更改,所以本文着眼于重点常见需求,来进行说明

在使用库或者一些基础组件之前,我们先尝试一下,手写一下

一个简单的tooltip的demo

主要是使用属性选择器去控制,四个方向的tooltip和三角形小箭头。

标签的whichPlacement属性值为"top"时,就让其在上方,为left时,就让其在左侧,其他方位同理

demo效果图

demo代码

复制粘贴即可使用

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
        body {
            box-sizing: border-box;
            padding: 60px 240px;
        }

        /* 设置基本样式 */
        .item {
            width: fit-content;
            box-sizing: border-box;
            padding: 12px;
            border: 2px solid #aaa;
            /* 搭配伪元素,用相对定位 */
            position: relative;
        }

        /* 使用伪元素创建tooltip */
        .item::after {
            /* 内容为 使用 tooltipContent的属性值 */
            content: attr(tooltipContent);
            position: absolute;
            background-color: #000;
            width: fit-content;
            height: auto;
            padding: 6px 12px;
            color: #fff;
            border-radius: 12px;
            /* 文字不换行 */
            word-break: keep-all;
            display: none;
        }

        /* 使用伪元素创建小三角形 */
        .item::before {
            content: "";
            position: absolute;
            border-width: 6px 6px 0 6px;
            border-style: solid;
            border-color: transparent;
            border-top-color: black;
            display: none;
        }

        /* 上下左右四个方位,使用css的属性选择器控制tooltip和小三角形 */
        /* 当whichPlacement的属性值为top时,做...样式 */
        /* 上方 */
        [whichPlacement='top']::after {
            left: 50%;
            transform: translateX(-50%);
            top: -100%;
        }

        [whichPlacement='top']::before {
            top: -26%;
            left: 50%;
            transform: translateX(-50%);
        }

        /* 下方 */
        /* 当whichPlacement的属性值为bottom时,做...样式 */
        /* 关于四个方向的小三角形,可以使用旋转更改即可 */
        [whichPlacement='bottom']::after {
            left: 50%;
            transform: translateX(-50%);
            bottom: -100%;
        }

        [whichPlacement='bottom']::before {
            bottom: -28%;
            left: 50%;
            transform: rotate(180deg);
        }

        /* 左侧 */
        /* 当whichPlacement的属性值为left时,做...样式 */
        [whichPlacement='left']::after {
            top: 50%;
            transform: translateY(-50%);
            right: 108%;
        }

        [whichPlacement='left']::before {
            top: 50%;
            transform: translateY(-50%) rotate(270deg);
            left: -10.5px;
        }

        /* 右侧 */
        /* 当whichPlacement的属性值为right时,做...样式 */
        [whichPlacement='right']::after {
            top: 50%;
            transform: translateY(-50%);
            left: 108%;
        }

        [whichPlacement='right']::before {
            top: 50%;
            transform: translateY(-50%) rotate(90deg);
            right: -10px;
        }

        .item:hover::after {
            display: block;
        }

        .item:hover::before {
            display: block;
        }
    </style>
</head>

<body>
    <div class="item" whichPlacement="top" tooltipContent="上方出现tooltip内容">悬浮上方</div>
    <br>
    <div class="item" whichPlacement="bottom" tooltipContent="tooltip内容在下方出现">悬浮下方</div>
    <br>
    <br>
    <br>
    <div class="item" whichPlacement="left" tooltipContent="左侧出现tooltip内容">悬浮左侧</div>
    <br>
    <div class="item" whichPlacement="right" tooltipContent="tooltip内容出现在右侧">悬浮右侧</div>
</body>

</html>

关于css属性选择器和attr()函数

上述代码中用到了属性选择器和attr()函数,这里简单的提一下

属性选择器

问:什么是属性选择器?

答1:通过选取带有指定标签属性的dom元素,进行样式的设置

答2:通过标签的属性名key和属性值value来匹配元素,从而进行样式的设置

问:举个例子呗

答:

  • [attr] 匹配所有具有attr属性的元素,不用管其值是什么,如:input[type]{ ... },意为:只要input标签中,包含type属性(忽略type属性值),都选中,并设置 ... 样式
  • [attr='val'] 匹配所有attr属性值等于val,完全精准匹配。如:input[type='text']{ ... },意为:只要input标签中,有type属性,且属性值为text,才去选中,并匹配 ... 样式
  • [attr^='val']匹配所有attr属性值以val开头的(上述demo案例中就用到了,只不过其属性是我们自定义的)。模糊匹配
  • [attr$='val'],同上类似,^=是以什么什么开头匹配,$=是以什么什么结尾匹配。模糊匹配
  • [attr*='val'],同上类似,*=是只要包含即可,也是模糊匹配
详见官方属性选择器介绍:https://www.w3school.com.cn/c...

attr()函数

attrattribute单词属性的缩写,顾名思义,所以这个东西和属性有关

  • css的函数attr()可获取被选中元素的属性值,并且在样式文件中使用。可用在伪元素里,在伪类元素里使用,它得到的是伪元素的原始元素的值。
  • attr()函数可以和任何css属性一起使用,但是除了content外,其余都还是试验性的,所以建议:除了搭配伪元素的content别的都不要用

如上述案例:

 <div class="item" tooltipContent="上方出现tooltip内容">悬浮上方</div>

.item::after {
    /* 使用选中标签的tooltipContent属性值作为content的内容 */
    content: attr(tooltipContent);
}
官方attr函数介绍:https://developer.mozilla.org...

为什么要说属性选择器呢?因为封装的代码中能够用到啊

使用vue-popper做组件的封装

安装

// CDN
<script src="https://unpkg.com/@ckienle/k-pop"></script>
// NPM
npm install vue-popperjs --save
// Yarn
yarn add vue-popperjs
// Bower
bower install vue-popperjs --save

官方案例demo

<template>
  <popper
    trigger="clickToOpen"
    :options="{
      placement: 'top',
      modifiers: { offset: { offset: '0,10px' } }
    }">
    <div class="popper">
      Popper Content
    </div>

    <button slot="reference">
      Reference Element
    </button>
  </popper>
</template>

<script>
  import Popper from 'vue-popperjs';
  import 'vue-popperjs/dist/vue-popper.css';

  export default {
    components: {
      'popper': Popper
    },
  }
</script>

官方demo效果图

笔者的二次封装效果图

使用之代码

下方代码较多,建议打开编辑器,复制粘贴代码,跑起来,阅读之

<template>
  <div class="showTooltip">
    <h3>暗色模式</h3>
    <br />
    <div class="darkMode">
      <div class="topBox">
        <my-tooltip placement="top-start" content="top-start">
          <span class="topReferenceDom">上方左侧上方左侧</span>
        </my-tooltip>
        <my-tooltip placement="top" content="top">
          <span class="topReferenceDom">上方中间</span>
        </my-tooltip>
        <my-tooltip placement="top-end" content="top-end">
          <span class="topReferenceDom">上方右侧上方右侧</span>
        </my-tooltip>
      </div>
      <div class="leftAndRightBox">
        <div class="leftBox">
          <my-tooltip placement="left-start" content="left-start">
            <div class="leftReferenceDom">左侧上方</div>
          </my-tooltip>
          <my-tooltip placement="left" content="left">
            <div class="leftReferenceDom">左侧中间</div>
          </my-tooltip>
          <my-tooltip placement="left-end" content="left-end">
            <div class="leftReferenceDom">左侧下方</div>
          </my-tooltip>
        </div>
        <div class="rightBox">
          <my-tooltip placement="right-start" content="right-start">
            <div class="rightReferenceDom">右侧上方</div>
          </my-tooltip>
          <my-tooltip placement="right" content="right">
            <div class="rightReferenceDom">右侧中间</div>
          </my-tooltip>
          <my-tooltip placement="right-end" content="right-end">
            <div class="rightReferenceDom">右侧下方</div>
          </my-tooltip>
        </div>
      </div>
      <div class="bottomBox">
        <my-tooltip placement="bottom-start" content="bottom-start">
          <span class="bottomReferenceDom">下方左侧下方左侧</span>
        </my-tooltip>
        <my-tooltip placement="bottom" content="bottom">
          <span class="bottomReferenceDom">下方中间</span>
        </my-tooltip>
        <my-tooltip placement="bottom-end" content="bottom-end">
          <span class="bottomReferenceDom">下方右侧下方右侧</span>
        </my-tooltip>
      </div>
    </div>
    <br />
    <h3>亮色模式</h3>
    <br />
    <div class="lightMode">
      <div class="topBox">
        <my-tooltip light placement="top-start" content="top-start">
          <span class="topReferenceDom">上方左侧上方左侧</span>
        </my-tooltip>
        <my-tooltip light placement="top" content="top">
          <span class="topReferenceDom">上方中间</span>
        </my-tooltip>
        <my-tooltip light placement="top-end" content="top-end">
          <span class="topReferenceDom">上方右侧上方右侧</span>
        </my-tooltip>
      </div>
      <div class="leftAndRightBox">
        <div class="leftBox">
          <my-tooltip light placement="left-start" content="left-start">
            <div class="leftReferenceDom">左侧上方</div>
          </my-tooltip>
          <my-tooltip light placement="left" content="left">
            <div class="leftReferenceDom">左侧中间</div>
          </my-tooltip>
          <my-tooltip light placement="left-end" content="left-end">
            <div class="leftReferenceDom">左侧下方</div>
          </my-tooltip>
        </div>
        <div class="rightBox">
          <my-tooltip light placement="right-start" content="right-start">
            <div class="rightReferenceDom">右侧上方</div>
          </my-tooltip>
          <my-tooltip light placement="right" content="right">
            <div class="rightReferenceDom">右侧中间</div>
          </my-tooltip>
          <my-tooltip light placement="right-end" content="right-end">
            <div class="rightReferenceDom">右侧下方</div>
          </my-tooltip>
        </div>
      </div>
      <div class="bottomBox">
        <my-tooltip light placement="bottom-start" content="bottom-start">
          <span class="bottomReferenceDom">下方左侧下方左侧</span>
        </my-tooltip>
        <my-tooltip light placement="bottom" content="bottom">
          <span class="bottomReferenceDom">下方中间</span>
        </my-tooltip>
        <my-tooltip light placement="bottom-end" content="bottom-end">
          <span class="bottomReferenceDom">下方右侧下方右侧</span>
        </my-tooltip>
      </div>
    </div>
    <br />
    <h3>可禁用</h3>
    <br />
    <my-tooltip :disabled="disabled" placement="top" content="disabled属性禁用">
      <span class="item">悬浮出现</span>
    </my-tooltip>
    &nbsp;&nbsp;&nbsp;
    <button @click="disabled = !disabled">点击启用或禁用</button>
    <br />
    <br />
    <h3>当tooltip内容多的时候,使用content插槽</h3>
    <br />
    <my-tooltip placement="top">
      <span slot="content">
        <div class="selfContent">
          内容过多时,使用插槽更便于控制样式,比如换行
        </div>
      </span>
      <span class="item">悬浮出现</span>
    </my-tooltip>
    <br />
    <br />
  </div>
</template>

<script>
export default {
  data() {
    return {
      disabled: false,
    };
  },
};
</script>

<style lang='less' scoped>
.showTooltip {
  width: 100%;
  height: 100%;
  box-sizing: border-box;
  padding: 60px;
  padding-top: 0;
  padding-bottom: 120px;
  .topBox {
    .topReferenceDom {
      border: 1px solid #999;
      box-sizing: border-box;
      padding: 4px 8px;
      border-radius: 4px;
      width: 60px;
      text-align: center;
      margin-right: 6px;
    }
  }
  .leftAndRightBox {
    width: 100%;
    display: flex;
    padding-right: 120px;
    .leftBox {
      margin-right: 250px;
    }
    .leftReferenceDom,
    .rightReferenceDom {
      width: 72px;
      height: 60px;
      line-height: 60px;
      text-align: center;
      border: 1px solid #999;
      box-sizing: border-box;
      margin: 12px 0;
    }
  }
  .bottomBox {
    .bottomReferenceDom {
      border: 1px solid #999;
      box-sizing: border-box;
      padding: 4px 8px;
      border-radius: 4px;
      width: 60px;
      text-align: center;
      margin-right: 6px;
    }
  }
  .item {
    border: 1px solid #333;
    padding: 4px;
  }
}
.selfContent {
  width: 120px;
  color: #baf;
  font-weight: 700;
}
</style>

mytooltip封装代码

<template>
  <!-- 
    1. :appendToBody="true"是否把位置加到body外层标签上
        饿了么UI和antD是true,iview和vuetifyjs是false
    2. trigger属性触发方式,常用hover悬浮触发、clickToOpen鼠标点击触发
    3. :visibleArrow="true"默认显示三角形小箭头,但是可以修改
        也可以使用伪元素自定义其对应样式,这样更加自由灵活一些
    4. :options="{ ... } 其实就是popper.js的配置项,可看对应官方文档
    5. placement: placement 即为tooltip出现的位置,有12个位置,即:placementArr
    6. modifiers: { ... } 此修饰符配置对象主要是控制定位的相关参数
    7. offset即偏移量在原有位置上进行移动微调,这里暂时不设置了,直接使用
        给.popper加上外边距即可margin: 12px !important;
    8. computeStyle.gpuAcceleration = false 关闭css3的transform定位,因为要自定义
    9. preventOverflow.boundariesElement = 'window' 防止popper元素定位到边界外
        如:当左侧距离不够用的时候,即使设置placement='left'但是tooltip依旧会在右侧
    10. <div class="popper" /> 此标签是tooltip的容器,所以我们可以设置对应想要的样式
    11. rootClass="selfSetRootClass"搭配transition="fade"实现淡入淡出过渡效果
    12. slot="reference"命名插槽是触发tooltip打开/关闭的dom元素
    13. disabled是否关闭这个tooltip
  -->
  <popper
    :appendToBody="true"
    trigger="hover"
    :visibleArrow="true"
    :options="{
      placement: placement,
      modifiers: {
        offset: {
          offset: 0,
        },
        computeStyle: {
          gpuAcceleration: false,
        },
        preventOverflow: {
          boundariesElement: 'window',
        },
      },
    }"
    rootClass="selfSetRootClass"
    transition="fade"
    :disabled="disabled"
  >
    <!-- 内容过多的时候,建议使用content插槽,便于自定义样式 -->
    <div
      v-if="$slots.content"
      :class="{ isLightPopper: light }"
      ref="popperRef"
      class="popper"
    >
      <slot name="content"></slot>
    </div>
    <!-- 内容少的话,直接content属性 -->
    <div
      v-else
      :class="{ isLightPopper: light }"
      ref="popperRef"
      class="popper"
    >
      {{ content }}
    </div>
    <!-- 把外界传递的普通插槽当做具名插槽传递给子组件使用 -->
    <slot slot="reference"></slot>
  </popper>
</template>

<script>
// 基于vue-popperjs的二次封装
import popper from "vue-popperjs"; // vue-popperjs基于popper.js二次封装
import "vue-popperjs/dist/vue-popper.css";
// 总共12个位置
const placementArr = [
  "top-start",
  "top",
  "top-end",
  "left-start",
  "left",
  "left-end",
  "right-start",
  "right",
  "right-end",
  "bottom-start",
  "bottom",
  "bottom-end",
];
export default {
  name: "myTooltip",
  components: { popper }, // 注册并使用vue-popperjs插件组件
  props: {
    // 12个tooltip位置
    placement: {
      type: String,
      default: "top-start", // 默认
      validator(val) {
        return placementArr.includes(val); // 位置校验函数
      },
    },
    // 内容(同内容插槽,不过内容插槽的权重高一些)
    content: {
      type: String,
      default: "",
    },
    // 是否是亮色模式,默认是暗色模式
    light: {
      type: Boolean,
      default: false,
    },
    // 是否禁用即关掉tooltip
    disabled: {
      type: Boolean,
      default: false,
    },
  },
};
</script>

<style lang="less">
// 覆盖部分默认的样式(不用加/deep/ )
.popper {
  box-sizing: border-box;
  padding: 6px 12px;
  border-radius: 3px;
  color: #fff;
  background-color: #333;
  border: none;
}
// 设置一个tootip的外边距(也可以使用offset)
.popper[x-placement^="top"] {
  margin-bottom: 12px !important;
}
.popper[x-placement^="bottom"] {
  margin-top: 12px !important;
}
.popper[x-placement^="left"] {
  margin-right: 12px !important;
}
.popper[x-placement^="right"] {
  margin-left: 12px !important;
}
// 覆盖原有的默认三角形背景色样式
.popper[x-placement^="top"] .popper__arrow {
  border-color: #333 transparent transparent transparent;
}
.popper[x-placement^="bottom"] .popper__arrow {
  border-color: transparent transparent #333 transparent;
}
.popper[x-placement^="right"] .popper__arrow {
  border-color: transparent #333 transparent transparent;
}
.popper[x-placement^="left"] .popper__arrow {
  border-color: transparent transparent transparent #333;
}
// 加上过渡效果(搭配transition="fade")
.selfSetRootClass {
  transition: all 0.6s;
}
.fade-enter,
.fade-leave-to {
  opacity: 0;
}
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.6s;
}
// 亮色模式样式
.isLightPopper {
  color: #333;
  background-color: #fff;
  filter: drop-shadow(0, 2px, 12px, 0, rgba(0, 0, 0, 0.24));
  box-shadow: 0, 2px, 12px, 0, rgba(0, 0, 0, 0.24);
}
.isLightPopper[x-placement^="top"] .popper__arrow {
  border-color: #fff transparent transparent transparent;
}
.isLightPopper[x-placement^="bottom"] .popper__arrow {
  border-color: transparent transparent #fff transparent;
}
.isLightPopper[x-placement^="right"] .popper__arrow {
  border-color: transparent #fff transparent transparent;
}
.isLightPopper[x-placement^="left"] .popper__arrow {
  border-color: transparent transparent transparent #fff;
}
</style>

总结

因为mytooltip组件,需要使用到的vue-popper属性和方法并不多,所以大家可以仿照笔者的方式,去看一下vue-popper组件的代码,然后结合自己公司的业务需求,去封装一些适合自己公司的弹框组件

vue-popperhttps://github.com/RobinCK/vu...

当然,时间较为充裕的可以看一下popper.js这个库

关于vue-popper组件的其他二次封装的应用,如封装el-popover组件el-popconfirm组件el-dropdown组件等,笔者会陆续更新的。不同的组件用到vue-popper不同的属性和方法

墙裂建议大家,看完以后,自己手写一下。只是看一遍,学习效果不太好

水冗水孚
1.1k 声望588 粉丝

每一个不曾起舞的日子,都是对生命的辜负