本文首发于:https://github.com/bigo-frontend/blog/ 欢迎关注、转载。

需求分析:

  • 组件可设置弹出位置(placement),支持top、bottom
  • 气泡弹窗弹出位置计算,边界计算,支持设置边界范围
  • 支持点击弹窗元素之外的区域,弹窗关闭
  • 支持弹窗内容自定义
  • 效果预览:

实现自定义组件, 我们一般都会想到 Vue.extend vue.extend 相当于一个扩展实例构造器,用于创建一个具有初始化选项的Vue子类,在实例化时可以进行扩展选项,最后使用$mount方法绑定在元素上。

先写一个简单的demo

  • 编写index.vue文件
<template>
    v-if="visible"
    class="popover">
      ref="arrowDom"
      :style="{left: `${arrowLeft}px`}"
      class="popover__arrow"></div>
    <div class="popover__content">
      <div class="popover__content-p1">{{ txt }} </div>
</template>
<script>
export default {
  name: 'Popover',
  props: {
    txt: {
      type: String,
      default: ''
  data: () => ({
    arrowLeft: 51,
    visible: false,
    positionStyle: { left: '15px', top: '69.5781px' }
  methods: {
    open() {
      this.visible = !this.visible;
</script>
<style lang="scss">
.popover {
  position: absolute;
</style>

注意:目前组件接收一个txt参数,且弹窗的位置信息有css固定,js只是提供了显示逻辑。气泡弹窗针对body元素绝对定位

  • 编写index.js。 点击按钮时,动态追加弹窗元素到body上
// index.js
import Vue from 'vue';
// 导入刚才我们写的index.vue
import DialogCompt from './index.vue';
let component;
// 保证只存在一个组件实例
const createComponents = function() {
  if (!component) {
    const DialogConstructor = Vue.extend(DialogCompt);
    component = new DialogConstructor({
      el: document.createElement('div')
    });
  return component;
const preview = (options) => {
  component = createComponents();
  document.body.appendChild(component.$el);
  component.txt = options.txt;
  component.open();
export default preview;
  • 项目中使用
import preview from './index';
methods: {
     // 组件调用
      peview({
        txt: '我是文字文字文字文字文字文字文字文字'
      });

经过上面三个步骤,一个简单的弹窗就出来啦,效果预览:

处理相关的点击交互

  • 点击弹窗本身,弹窗不消失
    可以判断当前点击的对象是否在弹窗范围内,通过Node.contains这个方法
  • 点击弹窗之外的元素,弹窗消失
    一般做法就是在弹窗显示之后,给document添加一个click事件,现在我们来修改上面的index.vue
<script>
methods: {
  documentEventHandler() {
    // 如果点击弹窗自身时,不触发隐藏逻辑
    if (!this.$el.contains(evt.target)) {
      this.close();
  close() {
    this.visible = false;
   // 弹窗关闭后记得移除click事件
    document.removeEventListener('click', this.documentEventHandler);
  open() {
      // ...
      this.$nextTick(() => {
        // 弹窗显示后,给document对象注册click事件
        document.addEventListener('click', this.documentEventHandler);
      // ...
</script>

效果预览:

position计算

  • 获取目标元素相关信息
    这个需要在调用组件对象的时候,把点击对象传组件内部
showPop(event) {
  // 组件调用,把event传到组件内部,方便组件内部获取目标元素各种尺寸信息
  review({
    event,
    txt1: '我是文字文字文字文字文字文字文字文字'
  });
  • 获取点击对象的相关尺寸信息
    通过传入的event对象,我们可以拿到当前的currentTarget: event.currentTarget; 然后使用Element.getBoundingClientRect()这个方法获取currentTarget的left、right、top、bottom信息
// index.js
const preview = (options) => {
  component = createComponents();
  const event = options.event;
  event.stopPropagation();
  const currentTarget = event.currentTarget;
  const { left, right, bottom, top } = currentTarget.getBoundingClientRect();
  component.targetPosition = { left, right, bottom, top };
  component.placement = options.placement;
  // other code
  • 计算弹窗的left、top值

根据上图标注,获取弹窗左上角的left、top值,思路如下:

  • 1.获取目标元素距离四边距屏幕左、右、上、下侧的边距
  • 2.按照弹窗水平对齐目标元素的思路,计算出left值,这里需要考虑左右边界问题
  • 3.根据弹窗自身的高度、箭头高度、间隙高度及目标元素上边距距离屏幕顶部、目标元素下边距距离屏幕顶部的高度来计算弹窗的top值,这里也需要考虑上下边界问题

根据上面的思路,我们可以这么干:
left = 目标元素距离屏幕右侧的距离 + 目标元素宽度 / 2 - 弹窗自身宽度 / 2

弹窗top值也是同样的道理:
显示在元素上方:
top = 目标元素上边距距离屏幕顶部的距离 - 弹窗自身宽度 - 箭头高度 - 偏移量

显示在元素下方:
top = 目标元素下边距距离屏幕顶部的距离 + 箭头高度 + 偏移量
下面我们来修改下index.vue,

props: {
  targetPosition: {
    type: Object,
    default: () => ({})
  // 弹窗显示的位置, 默认现在是触发元素底部
  placement: {
    type: String,
    default: 'bottom'
  // 弹窗距离屏幕左侧的最小距离
  minLeft: {
    type: Number,
    default: 30
  // 弹窗距离屏幕右侧的最小距离
  minRight: {
    type: Number,
    default: 30
  // 弹窗距离触发元素的间距, 默认8px
  offset: {
    type: Number,
    default: 8
data: () => ({
  arrowLeft: 0,
  positionStyle: { left: '-100%', top: '0' }
method: {
  open() {
    // ...
    this.$nextTick(() => {
      // 开始计算弹窗显示的位置
      this.calcPosition();
    // ...
  calcPosition() {
    // 拿到弹窗的宽、高信息
    const { width, height } = this.$el.getBoundingClientRect();
    const targetWidth = this.targetPosition.right - this.targetPosition.left;
    // 计算弹窗距屏幕左边距的位移
    let popLeft = this.targetPosition.left + targetWidth / 2 - width / 2;
    // 计算弹窗距屏幕右边距的最小位移
    const maxLeft = document.body.clientWidth - width - this.minRight * window.rem / 72;
    if (popLeft < this.minLeft * window.rem / 72) {
      popLeft = this.minLeft * window.rem / 72;
    } else if (popLeft > maxLeft) {
      popLeft = maxLeft;
    const arrowWidth = this.$refs.arrowDom.getBoundingClientRect().width;
    const arrowHeight = this.$refs.arrowDom.getBoundingClientRect().height;
    let popTop;
    if (this.placement === 'bottom') {
      // 显示在按钮底部
      popTop = this.targetPosition.bottom + this.offset * window.rem / 72 + arrowHeight;
    } else {
      // 显示在按钮顶部
      popTop = this.targetPosition.top - height - arrowHeight - this.offset * window.rem / 72;
    // 箭头是针对弹窗定位的, 所以记得以弹窗的左上角或者右下角来计算位移
    if (popLeft === this.minLeft * window.rem / 72) {
      this.arrowLeft = this.targetPosition.left - this.minLeft * window.rem / 72 + targetWidth / 2 - arrowWidth / 2;
    } else if (popLeft === maxLeft) {
      this.arrowLeft = this.targetPosition.left + targetWidth / 2 - popLeft - arrowWidth / 2;
    } else {
      this.arrowLeft = (popLeft + width - popLeft) / 2 - arrowWidth / 2;
    this.positionStyle = {
      left: `${popLeft}px`,
      top: `${popTop}px`

测试的时候发现,如果页面内容超出了一屏,页面发生滚动之后,点击按钮,弹窗的定位会出现偏移。这是为什么呢???
噢!!!原来我们之前只是计算了点击元素距离视口顶部的距离,但是弹窗是针对整个body定位的, 所以弹窗的真实top值还需要把页面滚动的距离算上。
document.documentElement.scrollTop就是我们要计算的,记得做下兼容,
可参考
那么此时元素的距离body顶部的距离应该是:
元素距离视口顶部的高度 + 容器滚动的高度

// index.js
const scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
this.positionStyle = {
  left: `${popLeft}px`,
  top: `${popTop + scrollTop}px`

现在滚动页面,点击按钮,预览弹窗位置显示正常啦。

编写支持自定义内容弹窗组件

上面的实现方式,只能实现固定模板内容,如果后期遇到其他气泡弹窗,但是内容不同的时候,这个组件就得修改才能用,所以还有一种实现方式:采用具名插槽来实现popover框架,template、css部分可由调用者自由定义。
那么理想情况下,用户可以这么调用(假如我们的组件是:im-popover):

<im-popover v-model="visible">
  <div><!--弹窗内容--></div>
  <button slot="trigger">触发元素</button>
</im-popover>

弹窗内容是默认插槽,按钮元素需要声明slot=“trigger”

  • 首先实现一个最基本的,我们点击trigger元素显示这个弹窗内容
<!--ImPopover.vue-->
<template>
  <div class="im-popover__box" @click="handleClick">
    <!--弹窗主体部分-->
      v-show="visible"
      ref="popoverDom"
      :style="positionStyle"
      class="im-popover">
        ref="arrowDom"
        :style="{left: `${arrowLeft}px`}"
        class="im-popover__arrow"
      <!--可自定义内容,默认插槽-->
      <slot></slot>
    <!--触发弹窗元素-->
    <slot name="trigger"></slot>
</template>
<style lang="scss">
.im-popover {
  z-index: 1000;
  position: absolute;
  max-width: 660px;
  &__arrow {
    // ...
  &__box {
    /* 这里需要设置成行内元素 */
    display: inline-block;
</style>
  • 编写对应的script脚本
    其实就是之前的index.js和index.vue的结合,不过需要把this.$el改成this.$refs.popoverDom
<script>
export default {
  name: 'ImPopover',
  props: {
    // ...
  methods: {
    open() {
      if (!this.visible) {
        // 把popover组件追加到body尾部
        this.appendContainer();
        // ...
      } else {
        this.close();
    appendContainer() {
      document.body.appendChild(this.$refs.popoverDom);
    handleClick(event) {
      // 阻止冒泡
      event.stopPropagation();
      this.open();
    // ...
</script>
  • im-popover组件调用
<template>
<im-popover placement="top">
  <div class="popover-content">
    <img class="image" src="https://static-web.likeevideo.com/as/indigo-static/test/diamond.png" alt="">
    <p class="txt">原始文字是人类用来紀錄特定事物、簡化圖像而成的書寫符號。 文字在发展早期都是图画形式的,有些是以形表意</p>
  <div slot="trigger" class="app__button app__button--small">自定义弹窗内容</div>
</im-popover>
</template>
<script>
import ImPopover from './ImPopOver';
export default {
  name: 'App',
  components: {
    ImPopover
</script>
<style lang="scss">
.popover-content {
  width: 468px;
  padding: 12px;
  box-sizing: border-box;
  display: flex;
  align-items: center;
  flex-direction: column;
  .image {
    width: 148px;
    height: auto;
    vertical-align: top;
  .txt {
    margin-top: 20px;
    font-size: 28px;
    color: #fff;
    text-align: center;
</style>

好了,组件的两种实现方法都讲完了,其实popover组件涉及的功能比较多,比如有弹窗触发方式的配置(例如click、hover)、弹窗内容是否支持滚动和点击、弹窗的显示与隐藏回调、弹窗动画等等功能。这个等到以后组件需要拓展时再考虑吧。
如果你看到这篇文章,希望对你了解popover组件开发原理有所帮助。

欢迎大家留言讨论,祝工作顺利、生活愉快!

我是bigo前端,下期见。

本文首发于:https://github.com/bigo-frontend/blog/ 欢迎关注、转载。需求分析:组件可设置弹出位置(placement),支持top、bottom气泡弹窗弹出位置计算,边界计算,支持设置边界范围支持点击弹窗元素之外的区域,弹窗关闭支持弹窗内容自定义效果预览:实现方案实现自定义组件, 我们一般都会想到Vue.extend,vue.extend相当于一个扩展实例构造器,用于创建一个具有初始化选项的Vue子类,在实例化时可以进行扩展选项,最后使用$mou. 支持返回键, 可以按浏览器返回按钮关闭popup 可以写出小复杂的过度动画, 比如磁贴按压效果[在popUpMenu可看到] 支持css动画库, 比如animation.css, 使用的时候自行添加依赖就好了 提供了几个比较好的popup组...
要修改 `Popup` 组件中 `title` 的样式,你可以将自定义的样式传递给 `Popup` 组件的 `title` 属性。 具体来说,你可以使用 `title` 属性的 `style` 属性来定义样式。例如: ```jsx <Popup title="这是一个标题" style={{ color: 'red', fontSize: '20px' }} 这是弹出框的内容。 </Popup> 上述代码将标题的字体颜色设置为红色,字体大小设置为 20 像素。 如果你想更细粒度地修改标题的样式,你可以使用 `title` 属性的 `className` 属性来添加一个自定义的 CSS 类名,然后在 CSS 样式表中定义该类名的样式。例如: ```jsx <Popup title="这是一个标题" className="custom-title" 这是弹出框的内容。 </Popup> 在 CSS 样式表中,你可以定义 `.custom-title` 类名的样式: ```css .custom-title { color: red; font-size: 20px; 这样,标题的字体颜色将会是红色,字体大小为 20 像素。