7.1 简单轮子:VuePopover组件

大纲链接 §

[toc]


popover组件 需求

  • 可以激活
    • hover
    • click
  • 激活后弹出提示
    • 简单文本提示
    • 复杂内容
  • 两种形式
    • 按钮在popover组件中
    • 封装为指令,在按钮中使用

API设计


popover组件 UI

为什么需要单独的 popover 组件

1
2
3
4
<div id="app">
  <div v-if="x" class="xxx"></div>
  <button @click="x = true">click</button>
</div>
  • 需要让浮动消息出现在按钮正上方
  • 提供给用户以封装好样式的popover 组件,不用另外写样式

基本结构

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<!-- 组件包裹按钮 -->
<g-popover>
  <template slot="content">
    <div></div>
  </template>
    <button>click</button>
</g-popover>
<!-- 使用封装的指令 -->

<div ref="xxx"></div>
<button v-pop="xxx">click</button>

先实现VuePopover组件包裹按钮的方式

显示和隐藏提示消息

使用展示组件Popovers.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
<template>
  <div>
    <form>
      <fieldset>
        <legend>Popover</legend>
        <details open>
          <summary>组件包裹按钮</summary>
          <VuePopover>
            <template name="content">
              <div>popover内容</div>
            </template>
            <VueButton>点击</VueButton>
          </VuePopover>
          <VuePopover>
            <template name="content">
              <div>popover内容</div>
            </template>
            <VueButton>点击</VueButton>
          </VuePopover>
        </details>
      </fieldset>
    </form>
    <br>
  </div>
</template>

<script lang="ts">
import {Component, Vue} from 'vue-property-decorator';
import VuePopover from './popover/VuePopover.vue';
import VueButton from './button/VueButton.vue';

@Component({
  components: {VuePopover, VueButton}
})
export default class Popovers extends Vue {
  name = 'Popovers';
}
</script>

<style lang="scss" scoped>
.popover{
  margin-right: 20px;
}

</style>

VuePopover.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<template>
  <div class="popover" @click="togglePop">
    <slot name="content" v-if="visible"></slot>
    <slot></slot>
  </div>
</template>

<script lang="ts">
import {Component, Vue} from 'vue-property-decorator';

@Component
export default class VuePopover extends Vue {
  name = 'VuePopover';
  visible = false;

  togglePop() {
    this.visible = !this.visible;
  }
}
</script>

<style lang="scss" scoped>
.popover {
  display: inline-block;
  vertical-align: top;
}
</style>

  • slot上添加样式时无效的
  • 需要slot在外边再包裹一层div
  • v-if="visible"的控制逻辑页移至外层div
  • 如此就可以加样式,使得弹出消息框的div可以定位
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<template>
  <div class="popover" @click="togglePop">
    <div class="content-wrapper" v-if="visible">
      <slot name="content"></slot>
    </div>

    <slot></slot>
  </div>
</template>

<script lang="ts">
import {Component, Vue} from 'vue-property-decorator';

@Component
export default class VuePopover extends Vue {
  name = 'VuePopover';
  visible = false;

  togglePop() {
    this.visible = !this.visible;
  }
}
</script>

<style lang="scss" scoped>
.popover {
  display: inline-block;
  vertical-align: top;
  position: relative;

  .content-wrapper {
    position: absolute;
    bottom: 100%;
    left: 0;
    box-shadow: 0 0 3px rgba(0, 0, 0, 0.5);
    padding: 2px;
  }
}
</style>

目前存在的 bug

  • 未实现点击外边空白处,隐藏消息框

实现隐藏消息框

document.body上添加监听click事件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<template>
  <div class="popover" @click="togglePop">
    <div class="content-wrapper" v-if="visible">
      <slot name="content"></slot>
    </div>

    <slot></slot>
  </div>
</template>

<script lang="ts">
import {Component, Vue} from 'vue-property-decorator';

@Component
export default class VuePopover extends Vue {
  name = 'VuePopover';
  visible = false;

  togglePop() {
    this.visible = !this.visible;
    if (this.visible === true) {
      document.body.addEventListener('click', () => {
        this.visible = false;
      });
    }
  }
}
</script>

<style lang="scss" scoped>...</style>

  • document.body.addEventListener('click', () => { this.visible = false; });
  • 当点击外部,就隐藏消框

无法出现消息框 bug

  • 由于事件冒泡机制
  • 未在点击事件结束后就添加监听点击事件
  • 会连续触发点击切换与隐藏消息框两个执行逻辑因此导致了 bug

加两句log来测试

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<template>...</template>

<script lang="ts">
import {Component, Vue} from 'vue-property-decorator';

@Component
export default class VuePopover extends Vue {
  name = 'VuePopover';
  visible = false;

  togglePop() {
    this.visible = !this.visible;
    console.log('切换 visible');
    if (this.visible === true) {
      document.body.addEventListener('click', () => {
        this.visible = false;
        console.log('点击body就关闭popover');
      });
    }
  }
  
}
</script>

  • 连续触发 点击切换 与 隐藏消息框
  • 先把this.visible变成true,紧接着就把this.visible变成false
  • 弹出后立马关闭
  • 显然不符合预期的需求

使用异步this.$nextTick来控制执行顺序

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<template>
  <div class="popover" @click="togglePop">
    <div class="content-wrapper" v-if="visible">
      <slot name="content"></slot>
    </div>
    <slot></slot>
  </div>
</template>

<script lang="ts">
import {Component, Vue} from 'vue-property-decorator';

@Component
export default class VuePopover extends Vue {
  name = 'VuePopover';
  visible = false;

  togglePop() {
    this.visible = !this.visible;
    console.log('切换 visible');
    if (this.visible === true) {
      this.$nextTick(() => {
        document.body.addEventListener('click', () => {
          this.visible = false;
          console.log('点击body就关闭popover');
        });
      });
    }
  }

}
</script>
...

另一个 bug

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
...
<style lang="scss" scoped>
body {border: 1px solid red;}
.popover {
  display: inline-block;
  vertical-align: top;
  position: relative;

  .content-wrapper {
    position: absolute;
    bottom: 100%;
    left: 0;
    box-shadow: 0 0 3px rgba(0, 0, 0, 0.5);
    padding: 2px;
  }
}
</style>
...
  • body区域使用border标识出来
  • 当body未占满时,当点击下方非body区时,无法关闭消息框
  • 解决方法时不监听document.body,而直接监听document
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import {Component, Vue} from 'vue-property-decorator';

@Component
export default class VuePopover extends Vue {
  name = 'VuePopover';
  visible = false;

  togglePop() {
    this.visible = !this.visible;
    console.log('切换 visible');
    if (this.visible === true) {
      this.$nextTick(() => {
        document.addEventListener('click', () => {
          this.visible = false;
          console.log('点击body就关闭popover');
        });
      });
    }
  }

}

点三次点击的bug

  • 点击按钮,出现消息框
  • 点击外部空白,隐藏消息框
  • 再次点击按钮,消息框未出现

原因

  • 目前存在两个监听器,都在监听click事件
    • 第一个是按钮在监听用户点击
    • 第二个是document上在监听点击
    • 调用顺序是,先调用按钮的,在调用document
  • document上的监听事件未被移除掉,就在再次点击后添加了另一个点击事件监听
  • 造成监听器越积越多

需要及时地销毁监听器

  • this.visible = false;后就销毁监听器
  • 需要一个具名函数,并且注意this的指向

尝试使用function x() {}.bind(this)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
...
  togglePop() {
    this.visible = !this.visible;
    console.log('切换 visible');
    if (this.visible === true) {
      this.$nextTick(() => {
        document.addEventListener('click',
          function x() {
            this.visible = false;
            console.log('删除监听器');
            document.removeEventListener('click', x);
            console.log('点击body就关闭popover');
          }.bind(this));
      });
    }
  }
...
  • () => {}相当于function x() {}.bind(this)
  • 不改变this的指向
  • 尝试失败.bind每次执行返回一个新的函数(堆内存中)
  • 事件添加的回调函数和(堆内存中) 删除的不是同一个函数

老老实实地声明const eventHandler = () => {...}

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
...
  togglePop() {
    this.visible = !this.visible;
    console.log('切换 visible');
    if (this.visible === true) {
      this.$nextTick(() => {
        const eventHandler = () => {
          this.visible = false;
          console.log('删除监听器');
          document.removeEventListener('click', eventHandler);
        };
        console.log('添加监听函数');
        document.addEventListener('click', eventHandler);
      });
    }
  }
...

还有几个 bug

  • 改变节点
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<template>
  <div class="popover" @click.stop="togglePop">
    <div class="content-wrapper" v-if="visible">
      <slot name="content"></slot>
    </div>
    <div class="trigger">
      <slot></slot>
    </div>
  </div>
</template>

<script lang="ts">
import {Component, Vue} from 'vue-property-decorator';

@Component
export default class VuePopover extends Vue {
  name = 'VuePopover';
  visible = false;

  togglePop() {
    this.visible = !this.visible;
    console.log('切换 visible');
    if (this.visible) {
      this.$nextTick(() => {
        console.log('声明回调函数');
        const eventHandler = () => {
          this.visible = false;
          console.log('document 隐藏 popover');
          console.log('删除监听器');
          document.removeEventListener('click', eventHandler);
        };
        console.log('添加监听函数');
        document.addEventListener('click', eventHandler);
      });
    } else {console.log('vm 隐藏 popover');}
    
  }

}
</script>

  • 点击多次按钮切换时,再点击document,会累积执行多个document.removeEventListener('click', eventHandler);
  • 当出现弹出框时,点击弹出框,此时弹出框不应该消失
  • 第一次点击显示popover,第二次点击按钮隐藏了两次popover
  • 需要阻止冒泡
  • 使用.stop事件修饰符<div class="popover" @click.stop="togglePop">
    • 阻止popover组件上的点击事件冒泡到外层节点上
    • 同时阻止消息框content-wrapper节点上的点击事件冒泡到外层节点上
    • <div class="content-wrapper" v-if="visible" @click.stop>...
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
...

  togglePop() {
    this.visible = !this.visible;

    const eventHandler = () => {
      this.visible = false;
      console.log('document 隐藏 popover');
      console.log('删除监听器');
      document.removeEventListener('click', eventHandler);

    };

    console.log('声明回调函数');
    console.log('切换 visible');

    if (this.visible) {
      this.$nextTick(() => {
        console.log('添加监听函数');
        document.addEventListener('click', eventHandler);

      });

    } else {
      console.log('vm 隐藏 popover');
      document.removeEventListener('click', eventHandler);
    }
    console.log('--------------');

  }

...

如果组件在一个有着overflow: hidden;的样式的节点中

  • 弹出消息框会被外层节点遮挡
  • 应该将popover组件的弹出消息框的节点放在</body>关闭标签前

@click.stop阻止冒泡带来的 bug

  • 用户使用popover组件时,无法使用监听组件外层点击事件
  • 打断了用户的事件传播链
  • 推翻使用@click.stop

解决popover的三个问题

  • overflow: hidden;
  • 重复切换按钮多次,点击一次document,关闭重复n次
  • 未取消监听 document

使用ref属性,获取到 消息框的节点,并打印在控制台

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<template>
  <div class="popover" @click="togglePop">
    <div ref="contentWrapper" class="content-wrapper" v-if="visible">
      <slot name="content"></slot>
    </div>
    <div class="trigger">
      <slot></slot>
    </div>
  </div>
</template>


<script lang="ts">
import {Component, Vue} from 'vue-property-decorator';

@Component
export default class VuePopover extends Vue {
  name = 'VuePopover';
  visible = false;

  mounted() {
    console.log(this.$refs.contentWrapper);
  }

}
</script>

  • 注意,当使用了条件渲染v-if时,未出现在文档流中的节点上的ref无法获取
  • 补充v-show V.S. v-if的知识点
    • v-show只改变节点的样式
    • v-if会改变节点存在于DOM树与否

获取this.$refs.contentWrapper之后,就可以将获取到的节点添加到document.body

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<template>
  <div class="popover" @click.stop="togglePop">
    <div ref="contentWrapper" class="content-wrapper" v-show="visible" @click.stop>
      <slot name="content"></slot>
    </div>
    <div class="trigger">
      <slot></slot>
    </div>
  </div>
</template>

<script lang="ts">
import {Component, Vue} from 'vue-property-decorator';

@Component
export default class VuePopover extends Vue {
  name = 'VuePopover';
  visible = false;

  togglePop() {
    this.visible = !this.visible;
  }

  mounted() {
    this.$nextTick(() => {
      document.body.appendChild(this.$refs.contentWrapper as Node);
    });
  }

}
</script>

  • 当改变 vue 中组件节点在文档中的位置时,并不影响组件的功能
  • 包括点击事件执行回调依然有效
  • 只是需要注意的是,
    • 如果vue文件的<style lang="scss" scoped>...标签中如果写了scoped属性,并且类样式互相嵌套
    • 则被改变位置的元素不再具有该样式
  • 解决方法是将该类样式提出到嵌套最外边
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
...
<style lang="scss" scoped>
.popover {
  display: inline-block;
  vertical-align: top;
  position: relative;
}

.content-wrapper {
  display: block;
  position: absolute;
  box-shadow: 0 0 3px rgba(0, 0, 0, 0.5);
  padding: 2px;
  transform: translateY(-100%);
}
</style>

不在组件挂载一完成时,就将消息框添节点加到document.body

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<template>
  <div class="popover" @click.stop="togglePop">
    <div ref="contentWrapper" class="content-wrapper" v-if="visible" @click.stop>
      <slot name="content"></slot>
    </div>
    <span class="trigger">
      <slot></slot>
    </span>
  </div>
</template>

<script lang="ts">
import {Component, Vue} from 'vue-property-decorator';

@Component
export default class VuePopover extends Vue {
  name = 'VuePopover';
  visible = false;

  togglePop() {
    this.visible = !this.visible;

    if (this.visible) {
      this.$nextTick(() => {
        document.body.appendChild(this.$refs.contentWrapper as Node);

      });
    }
  }

}
</script>

  • 而是等到当满足条件this.visible === true 显示时,再把消息框添节点加到document.body
  • 注意刚this.visible === true时,节点并未出现在文档中,需要使用this.$nextTick
  • 这样就不用担心被外层css样式设置的overflow: hidden;遮住
  • 接下来只需获取按钮元素的位置,据此改变定位样式,让节点出现在按钮上方

用JS获取按钮元素的位置

  • 同样地在slot上方加ref引用属性是无效的
  • 需要再包裹一层节点,在外层节点上添加ref引用
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<template>
  <div class="popover" @click.stop="togglePop">
    <div ref="contentWrapper" class="content-wrapper" v-if="visible" @click.stop>
      <slot name="content"></slot>
    </div>
    <span class="trigger" ref="trigger">
      <slot></slot>
    </span>
  </div>
</template>

<script lang="ts">
import {Component, Vue} from 'vue-property-decorator';

@Component
export default class VuePopover extends Vue {
  name = 'VuePopover';
  visible = false;

  togglePop() {
    this.visible = !this.visible;

    if (this.visible) {
      this.$nextTick(() => {
        document.body.appendChild(this.$refs.contentWrapper as Node);

      });
    }
  }

  mounted() {
    console.log(this.$refs.trigger);
  }

}
</script>

使用Element.getBoundingClientRect() API获取元素尺寸与位置

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
...
  togglePop() {
    this.visible = !this.visible;

    if (this.visible) {
      this.$nextTick(() => {
        document.body.appendChild(this.$refs.contentWrapper as Node);
        const {width, height, top, left}
          = this.$refs.trigger.getBoundingClientRect();
        console.log(width, height, top, left);
      });
    }
  }
...
  • 将得到的值赋给
    • (this.$refs.contentWrapper as any).style.left
    • (this.$refs.contentWrapper as any).style.top
  • 并且调整一下样式
    • transform: translateY(100%);

解决容器有overflow hidden的bug

测试poopover

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<div style="border: 10px solid blue; height: 1000px;"></div>
<div style="margin-left: 2000px; padding-top: 100px; padding-left: 100px;  border: 1px solid red; width: 1000px;">
<div style="overflow: hidden; border: 1px solid green; padding: 5px;">
  <VuePopover>
        <template slot="content">
          <div>popover内容</div>
        </template>
        <VueButton>点击</VueButton>
      </VuePopover>
</div>
</div>

VuePopover.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
<template>
  <div class="popover" @click.stop="togglePop">
    <div ref="contentWrapper" class="content-wrapper" v-if="visible" @click.stop>
      <slot name="content"></slot>
    </div>
    <span class="trigger" ref="trigger">
      <slot></slot>
    </span>
  </div>
</template>

<script lang="ts">
import {Component, Vue} from 'vue-property-decorator';

@Component
export default class VuePopover extends Vue {
  name = 'VuePopover';
  visible = false;

  togglePop() {
    this.visible = !this.visible;

    if (this.visible) {
      this.$nextTick(() => {
        document.body.appendChild(this.$refs.contentWrapper as Node);
        const {width, height, top, left}
          = (this.$refs.trigger as Element).getBoundingClientRect();
        (this.$refs.contentWrapper as any).style.left = `${left}px`;
        (this.$refs.contentWrapper as any).style.top = `${top}px`;
      });
    }
  }

}
</script>

<style lang="scss" scoped>
.popover {
  display: inline-block;
  vertical-align: top;
  position: relative;
}

.content-wrapper {
  position: absolute;
  box-shadow: 0 0 3px rgba(0, 0, 0, 0.5);
  padding: 2px;
  transform: translateY(100%);
}
</style>

  • 注意相对的位置距离是不同的
    • clientLeft是相对于屏幕可视区域
    • 而绝对定位是相对于body元素的左上坐标点的
  • 需要得到相对于可视区域的差值
    • document.documentElement.scrollHeight获取页面的可滚动总高
    • window.scrollY获取页面顶部至可视区域(视口)的顶部的高度,即文档在垂直方向已滚动的像素差值
  • 补上差值
    • (this.$refs.contentWrapper as any).style.top = `${top + window.scrollY}px`;
  • 注意scrollY的兼容性
  • 同理scrollX,出现X轴滚动条,相对位置发生变化
  • 需要补上一个差值
    • (this.$refs.contentWrapper as any).style.left = `${left + window.scrollX}px`;

搜索js get element offset relative to body 获取元素相对于body的偏移量(即偏移位置,包括top和left)

当用户点击按钮时,触发外层div的click事件的bug

会触发多余的关闭

  • 一次是由document引起的:document.removeEventListener('click', eventHandler);
  • 一次是 button 事件引起的

vue@2.5*vue@2.6*$nextTick不同


判断点击事件目标对象

目标对象是按钮,还是弹出消息框

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<template>
  <div class="popover" @click="togglePop">
    <div
      ref="contentWrapper"
      class="content-wrapper"
      v-if="visible"
      @click>
      <slot name="content"></slot>
    </div>
    <span class="triggerWrapper" ref="triggerWrapper">
      <slot>botton</slot>
    </span>
  </div>
</template>

<script lang="ts">
import {Component, Vue} from 'vue-property-decorator';

@Component
export default class VuePopover extends Vue {
  name = 'VuePopover';
  visible = false;

  // 点击按钮 执行的方法:切换显示/隐藏 popover
  togglePop(event: Event) {
  
    // 点击按钮部分 执行的逻辑
    if((this.$refs.triggerWrapper as HTMLElement)
    .contains(event.target as Node)) {
      console.log('x下面 按钮');
      // 切换显示/隐藏 popover
      this.visible = !this.visible;
      console.log(event.target);
      // 当 popover 显示时 执行的逻辑
      if (this.visible) {...}
    } else {
      // 点击popover部分 执行的逻辑
      console.log('x上面 弹出消息框');
    }

  }

}
</script>

  • 实现点击按钮切换,点击消息框不切换的逻辑分开
  • 判断点击的目标对象 event.target
    • 是否包含在this.$refs.triggerWrapper
    • 即是否点击了按钮this.$refs.triggerWrapper.contains(event.target)
  • 当 弹出框 显示
    • 将弹出框节点放到 body 子节点的最后
    • 改变弹出框样式,使其出现在相对按钮合适的位置

当 popover 显示时 执行的逻辑

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
<template>
  <div class="popover" @click="togglePop">
    <div
      ref="contentWrapper"
      class="content-wrapper"
      v-if="visible">
      <slot name="content"></slot>
    </div>
    <span class="triggerWrapper" ref="triggerWrapper">
      <slot>button</slot>
    </span>
  </div>
</template>

<script lang="ts">
import {Component, Vue} from 'vue-property-decorator';

@Component
export default class VuePopover extends Vue {
  name = 'VuePopover';
  visible = false;
  requestAnimationFrameId = 0;

  // 点击按钮 执行的方法:切换显示/隐藏 popover
  togglePop(event: Event) {

    // 点击按钮部分 执行的逻辑(如果点击了按钮)
    if ((this.$refs.triggerWrapper as HTMLElement)
      .contains(event.target as Node)) {

      console.log('x下面');
      // 切换显示/隐藏 popover
      this.visible = !this.visible;

      // 当 popover 显示时 执行的逻辑
      // 显示 弹出框,将弹出框节点放到 body 子节点的最后
      // 改变弹出框样式,使其出现在相对按钮合适的位置
      if (this.visible) {

        this.$nextTick(() => {
          // 获取 弹出消息框节点 的引用,放到 body 子节点的最后
          document.body.appendChild(this.$refs.contentWrapper as Node);

          // 获取 按钮元素 左上顶点的位置坐标 top, left
          const {top, left}
            = (this.$refs.triggerWrapper as Element)
            .getBoundingClientRect();
          // 设置 弹出消息框节点 的行内样式,使其定位到 按钮元素 上方
          (this.$refs.contentWrapper as any).style.left = `${left + window.scrollX}px`;
          (this.$refs.contentWrapper as any).style.top = `${top + window.scrollY}px`;

          // 定义一个点击事件的回调函数
          const eventHandler = () => {
            this.visible = false;
            document.removeEventListener('click', eventHandler);
            window?.cancelAnimationFrame(this.requestAnimationFrameId);
          };

          // 在文档上 添加 点击事件 的监听
          this.requestAnimationFrameId = requestAnimationFrame(() => {
            console.log('在文档上 添加 点击事件 的监听');
            document.addEventListener('click', eventHandler);
          });

        });

      }

    } else {
      // 点击popover部分 执行的逻辑
      console.log('x上面');
    }

  }

}
</script>

  • 点击按钮 执行的方法:切换显示/隐藏 popover
    • 点击按钮部分 执行的逻辑
      • 切换显示/隐藏 popover this.visible = !this.visible;
      • 当 popover 显示时 执行的逻辑, 必须为异步
        • 显示 弹出框,将弹出框节点放到 body 子节点的最后 document.body.appendChild(this.$refs.contentWrapper as Node);
        • 改变弹出框样式,使其出现在相对按钮合适的位置
          • 获取 按钮元素 左上顶点的位置坐标 top, left
          • 设置 弹出消息框节点 的行内样式,使其定位到 按钮元素 上方
        • 定义一个点击文档事件的回调函数 eventHandler
          • 如果点击了document就隐藏 popover弹出框 this.visible = false;
          • 移除 事件监听 document.removeEventListener('click', eventHandler);
        • 在文档上 添加 点击事件 的监听,必须为异步
          • this.requestAnimationFrameId = requestAnimationFrame(() => {...});
          • document.addEventListener('click', eventHandler);
      • 当 popover 隐藏时 执行的逻辑
    • 点击popover部分 执行的逻辑
  • 重构 抽象提出positionPop listenToDocument方法
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
<template>
  <div class="popover" @click="togglePop">
    <div
      ref="contentWrapper"
      class="content-wrapper"
      v-if="visible">
      <slot name="content"></slot>
    </div>
    <span class="triggerWrapper" ref="triggerWrapper">
      <slot>button</slot>
    </span>
  </div>
</template>

<script lang="ts">
import {Component, Vue} from 'vue-property-decorator';

@Component
export default class VuePopover extends Vue {
  name = 'VuePopover';
  visible = false;
  id = 0;

  positionPop() {
    // 获取 弹出消息框节点 的引用,放到 body 子节点的最后
    console.log('this.$refs.contentWrapper: ', this.$refs.contentWrapper);
    document.body.appendChild(this.$refs.contentWrapper as Node);

    // 获取 按钮元素 左上顶点的位置坐标 top, left
    const {top, left}
      = (this.$refs.triggerWrapper as Element)
      .getBoundingClientRect();
    // 设置 弹出消息框节点 的行内样式,使其定位到 按钮元素 上方
    (this.$refs.contentWrapper as any).style.top = `${top + window.scrollY}px`;
    (this.$refs.contentWrapper as any).style.left = `${left + window.scrollX}px`;

  }

  listenToDocument() {
    // 定义一个点击事件的回调函数
    const closeHandler = (e: Event) => {
      // 如果点击的目标对象 不存在于 包裹弹出框的div中
      if (!((this.$refs.contentWrapper as Element)?.contains(e.target as Node))) {

        this.visible = false;
        console.log(this.visible, '立即关闭了弹出框');
        document.removeEventListener('click', closeHandler);
        console.log('移除事件监听');
      }
    };

    // 在文档上 添加 点击事件 的监听
    console.log('在文档上 添加 点击事件 的监听');
    document.addEventListener('click', closeHandler);
  }

  // 点击按钮 执行的方法:切换显示/隐藏 popover
  togglePop(event: Event) {

    // 点击按钮部分 执行的逻辑
    if ((this.$refs.triggerWrapper as HTMLElement)
      ?.contains(event.target as Node)) {
      // 切换显示/隐藏 popover
      this.visible = !this.visible;
      console.log('切换显示/隐藏 popover', this.visible);

      // 当 popover 显示时 执行的逻辑
      // 显示 弹出框,将弹出框节点放到 body 子节点的最后
      // 改变弹出框样式,使其出现在相对按钮合适的位置
      if (this.visible) {
        this.$nextTick(() => {
          this.positionPop();
          this.listenToDocument();
        });

      }

    } else {
      // 点击popover部分 执行的逻辑
      console.log('点击popover部分 执行的逻辑');
    }

  }

}
</script>

<style lang="scss" scoped>
.popover {
  display: inline-block;
  vertical-align: top;
  position: relative;

  .content-wrapper {
    background-color: white;
  }

  .triggerWrapper {
  }
}

.content-wrapper {
  display: block;
  position: absolute;
  box-shadow: 0 0 3px rgba(0, 0, 0, 0.5);
  padding: 2px;
  transform: translateY(-100%);
}
</style>

在不阻止冒泡的条件下,判断事件的回调函数的目标对象

  • 重构 抽象提出closeHandler closeEvent方法
  • 如果目标对象 存在于 包裹弹出框的div中,则什么也不做
  • 如果目标对象 不存在于 包裹弹出框的div中,则
    • 隐藏 popover弹出框 this.visible = false;
    • 移除 事件监听 document.removeEventListener('click', eventHandler);
  • this.listenToDocument()还是会在点击事件冒泡完成之前执行
    • 必须使用setTimeout来延迟,使 添加监听在 点击事件冒泡 之后 执行
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
<template>
  <div class="popover" @click="togglePop">
    <div
      ref="contentWrapper"
      class="content-wrapper"
      v-if="visible">
      <slot name="content"></slot>
    </div>
    <span class="triggerWrapper" ref="triggerWrapper">
      <slot>button</slot>
    </span>
  </div>
</template>

<script lang="ts">
import {Component, Vue, Watch} from 'vue-property-decorator';

@Component
export default class VuePopover extends Vue {
  name = 'VuePopover';
  visible = false;

  positionPop() {
    // 获取 弹出消息框节点 的引用,放到 body 子节点的最后
    console.log('this.$refs.contentWrapper: ', this.$refs.contentWrapper);
    document.body.appendChild(this.$refs.contentWrapper as Node);

    // 获取 按钮元素 左上顶点的位置坐标 top, left
    const {top, left}
      = (this.$refs.triggerWrapper as Element)
      .getBoundingClientRect();
    // 设置 弹出消息框节点 的行内样式,使其定位到 按钮元素 上方
    (this.$refs.contentWrapper as any).style.top = `${top + window.scrollY}px`;
    (this.$refs.contentWrapper as any).style.left = `${left + window.scrollX}px`;

  }

  closeEvent() {
    // 关闭 弹出框
    this.visible = false;
    console.log(this.visible, '立即关闭了弹出框');
    console.log('移除事件监听');
    document.removeEventListener('click', this.closeHandler);
  }

  // 定义一个点击事件的回调函数
  closeHandler(e: Event) {
    const hasPopover = ((this.$refs.contentWrapper as Element)
      ?.contains(e.target as Node));

    // 如果 点击的目标对象 不存在于 包裹弹出框的div中
    if (!hasPopover) {
      this.closeEvent();
    }
    
  }

  listenToDocument() {
    // 在文档上 添加 点击事件 的监听
    console.log('在文档上 添加 点击事件 的监听');
    document.addEventListener('click', this.closeHandler);
  }

  // 点击按钮 执行的方法:切换显示/隐藏 popover
  togglePop(event: Event) {
    // 点击按钮部分 执行的逻辑
    if ((this.$refs.triggerWrapper as HTMLElement)
      ?.contains(event.target as Node)) {
      // 切换显示/隐藏 popover
      this.visible = !this.visible;
      console.log('切换显示/隐藏 popover', this.visible);

      // 当 popover 显示时 执行的逻辑
      // 显示 弹出框,将弹出框节点放到 body 子节点的最后
      // 改变弹出框样式,使其出现在相对按钮合适的位置
      /*
      if (this.visible) {
        this.$nextTick(() => {
          // 将 弹出框 放到body 里
          this.positionPop();
        });
      }*/

    } else {
      // 点击popover部分 执行的逻辑
      console.log('点击popover部分 执行的逻辑');
    }

  }

  @Watch('visible')
  onVisibleChange(newValue: boolean) {
    // 当 popover 显示时 执行的逻辑
    // 显示 弹出框,将弹出框节点放到 body 子节点的最后
    // 改变弹出框样式,使其出现在相对按钮合适的位置
    if (newValue) {
      console.log('打开状态');
      this.$nextTick(() => {
        // 将 弹出框 放到body 里
        this.positionPop();

        // 使 添加监听在 点击事件冒泡 之后 执行
        setTimeout(() => {
          // 给 document 添加 click 事件监听
          this.listenToDocument();
        });
      });
    } else {
      // 当 popover 隐藏时 执行的逻辑
      console.log('关闭');
    }
  }

}
</script>

<style lang="scss" scoped>...</style>

  • @Watch('visible')监听数据this.visible的变化
  • visible重构改成isVisible

解决重复叠加监听与重复多次移除监听的bug

是否可以在created时,执行this.listenToDocument()

  • 不可
    • 当页面中有多个popover组件时,每次创建就添加监听会造成不必要的性能浪费
  • 而监听事件正确的做法是 即用即添,用完即毁
    • 当点击弹出一个消息框,就添加一个监听
    • 当消息框关闭时,立即清除

收拢关闭操作的入口

  • 高内聚
  • 抽象方法
  • 将多个表达相同逻辑的代码抽象成一个方法,在处理该逻辑的时候调用这个方法
  • 实例代码的closeEvent(),将收尾的逻辑聚拢到一个方法中,有两个操作
    • 关闭显示this.isVisible = false;
    • 移除监听this.rmListenerToDocument();
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
<template>
  <div ref="popover"
       class="popover"
       @click="togglePop">
    <div ref="contentWrapper"
         class="content-wrapper"
         v-if="isVisible">
      <slot name="content"></slot>
    </div>
    <div class="triggerWrapper" ref="triggerWrapper">
      <slot>button</slot>
    </div>
  </div>
</template>

<script lang="ts">
import {Component, Vue, Watch} from 'vue-property-decorator';

@Component
export default class VuePopover extends Vue {
  name = 'VuePopover';
  isVisible = false;

  // 定位 popover 显示位置
  positionPop() {
    // 获取 弹出消息框节点 的引用,放到 body 子节点的最后
    document.body.appendChild(this.$refs?.contentWrapper as Node);

    // 获取 按钮元素 左上顶点的位置坐标 top, left
    const {top, left}
      = (this.$refs.triggerWrapper as Element)
      .getBoundingClientRect();
    // 设置 弹出消息框节点 的行内样式,使其定位到 按钮元素 上方
    (this.$refs.contentWrapper as any).style.top = `${top + window.scrollY}px`;
    (this.$refs.contentWrapper as any).style.left = `${left + window.scrollX}px`;

  }

  // 定义一个点击事件的回调函数
  closeHandler(e: Event) {
    // 点击的目标对象 是否存在于 popover 包裹div中
    const hasPopover = ((this.$refs.contentWrapper as Element)
      ?.contains(e.target as Node));
    // 如果 点击的目标对象 不存在于 包裹弹出框的div中 即 点击了document
    if (!hasPopover) {
      this.closeEvent();
    } else if (this.$refs.popover &&
      (hasPopover || this.$refs.popover === e.target)) {
      return;
    }
  }

  listenToDocument() {
    document.addEventListener('click', this.closeHandler);
  }

  rmListenerToDocument() {
    document.removeEventListener('click', this.closeHandler);
  }

  // 关闭 弹出框 销毁事件监听
  closeEvent() {
    this.isVisible = false;
    this.rmListenerToDocument();
  }

  openEvent() {
    this.isVisible = true;
    this.onShowPopover();
  }

  // 点击按钮 执行的方法:切换显示/隐藏 popover
  togglePop(event: Event) {
    // 点击按钮部分 执行的逻辑
    if ((this.$refs.triggerWrapper as HTMLElement)
      ?.contains(event.target as Node)) {
      // 切换显示/隐藏 popover
      this.isVisible = !this.isVisible;
    } else {
      // 点击popover部分 执行的逻辑
    }

  }

  onShowPopover() {
    this.$nextTick(() => {
      // 显示 弹出框,将弹出框节点放到 body 子节点的最后
      // 改变弹出框样式,使其出现在相对按钮合适的位置
      this.positionPop();

      // 使 添加监听在 点击事件冒泡 之后 异步执行
      setTimeout(() => {
        // 给 document 添加 click 事件监听
        this.listenToDocument();
      });
    });
  }

  // 监听 this.isVisible 状态变化 执行对应的逻辑
  @Watch('isVisible')
  onVisibleChange(newValue: boolean) {
    if (newValue) {
      // 当 popover 显示时 执行的逻辑
      this.onShowPopover();
    } else {
      // 当 popover 隐藏时 执行的逻辑
      return;
    }
  }

}
</script>

<style lang="scss" scoped>
.popover {
  display: inline-block;
  vertical-align: top;
  position: relative;

  .triggerWrapper {
  }
}

.content-wrapper {
  display: block;
  position: absolute;
  box-shadow: 0 0 3px rgba(0, 0, 0, 0.5);
  padding: 2px;
  transform: translateY(-100%);
  background-color: white;
}
</style>

  • 设置触发部分<span class="triggerWrapper" ref="triggerWrapper">...的样式
    • 将默认行内显示的<span>标签添加样式display: inline-block;

小结 popover 的三个问题

  • overflow: hidden;body.appendChild解决
    • 处理window.scrollXwindow.scrollY滚动偏移量
  • 关闭重复n次,分开处理逻辑
    • 职责明确
    • document只管外面
    • popover只管里面
  • 未取消监听 document,由收拢close的逻辑解决

添加支持四个方位功能

添加消息框的三角指向样式

添加消息框的四个方向 的外部数据

可以看到,去掉了transitionmargin-top属性的popover和按钮的左上角是对齐的

区分位置样式

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
<template>...</template>

<script lang="ts">
import {Component, Prop, Vue, Watch} from 'vue-property-decorator';

@Component
export default class VuePopover extends Vue {
  name = 'VuePopover';
  isVisible = false;

  @Prop({
    type: String,
    default: 'top',
    validator(value: string): boolean {
      return ['top', 'bottom', 'left', 'right'].includes(value);
    }
  }) position!: string;

  // 定位 popover 显示位置
  // 'top', 'bottom', 'left', 'right'
  positionPop() {
    // 获取 弹出消息框节点 的引用,放到 body 子节点的最后
    document.body.appendChild(this.$refs.contentWrapper as Node);

    const {contentWrapper, triggerWrapper} = this.$refs;
    // 获取 按钮元素 左上顶点的位置坐标 top, left
    const {width, height, top, left}
      = (triggerWrapper as HTMLElement)
      .getBoundingClientRect();
    const {height: popHeight} = (contentWrapper as HTMLElement)
      .getBoundingClientRect();

    // 需要表驱动编程重构
    if (this.position === 'top') {
      // 设置 弹出消息框节点 的行内样式,使其定位到 按钮元素 上方
      (contentWrapper as HTMLElement).style.top = `${top + window.scrollY}px`;
      (contentWrapper as HTMLElement).style.left = `${left + window.scrollX}px`;
    } else if (this.position === 'bottom') {
      // 设置 弹出消息框节点 的行内样式,使其定位到 按钮元素 下方
      (contentWrapper as HTMLElement).style.top = `${top + height + window.scrollY}px`;
      (contentWrapper as HTMLElement).style.left = `${left + window.scrollX}px`;
    } else if (this.position === 'left') {
      // 设置 弹出消息框节点 的行内样式,使其定位到 按钮元素 左侧
      (contentWrapper as HTMLElement).style.left = `${left + window.scrollX}px`;
      // popover高度居中 对齐于 按钮高度,按钮于popover高度差值的1/2
      (contentWrapper as HTMLElement).style.top =
        `${top + Math.abs(height - popHeight) / 2 + window.scrollY}px`;
    } else if (this.position === 'right') {
      // 设置 弹出消息框节点 的行内样式,使其定位到 按钮元素 右侧
      (contentWrapper as HTMLElement).style.left = `${left + width + window.scrollX}px`;
      (contentWrapper as HTMLElement).style.top =
        `${top + Math.abs(height - popHeight) / 2 + window.scrollY}px`;
    }

  }

  // 定义一个点击事件的回调函数
  closeHandler(e: Event) {
    // 点击的目标对象 是否存在于 popover 包裹div中
    const hasPopover = ((this.$refs.contentWrapper as Element)
      ?.contains(e.target as Node));
    // 如果 点击的目标对象 不存在于 包裹弹出框的div中 即 点击了document
    if (!hasPopover) {
      this.closeEvent();
    } else {return;}
  }

  listenToDocument() {
    document.addEventListener('click', this.closeHandler);
  }

  rmListenerToDocument() {
    document.removeEventListener('click', this.closeHandler);
  }

  // 关闭 弹出框 销毁事件监听
  closeEvent() {
    this.isVisible = false;
    this.rmListenerToDocument();
  }

  openEvent() {
    this.isVisible = true;
    this.onShowPopover();
  }

  // 点击按钮 执行的方法:切换显示/隐藏 popover
  togglePop(event: Event) {
    // 点击按钮部分 执行的逻辑
    if ((this.$refs.triggerWrapper as HTMLElement)
      ?.contains(event.target as Node)) {
      // 切换显示/隐藏 popover
      this.isVisible = !this.isVisible;
    } else {
      // 点击popover部分 执行的逻辑
    }

  }

  onShowPopover() {
    this.$nextTick(() => {
      // 显示 弹出框,将弹出框节点放到 body 子节点的最后
      // 改变弹出框样式,使其出现在相对按钮合适的位置
      this.positionPop();

      // 使 添加监听在 点击事件冒泡 之后 异步执行
      setTimeout(() => {
        // 给 document 添加 click 事件监听
        this.listenToDocument();
      });
    });
  }

  // 监听 this.isVisible 状态变化 执行对应的逻辑
  @Watch('isVisible')
  onVisibleChange(newValue: boolean) {
    if (newValue) {
      // 当 popover 显示时 执行的逻辑
      this.onShowPopover();
    } else {
      // 当 popover 隐藏时 执行的逻辑
      return;
    }
  }

}
</script>

<style lang="scss" scoped>
$border-color: #333;
$border-radius: 4px;
.popover {
  display: inline-block;
  vertical-align: top;
  position: relative;

  .triggerWrapper {
    display: inline-block;
  }
}

.content-wrapper {
  display: block;
  position: absolute;
  border: 1px solid $border-color;
  border-radius: $border-radius;
  //box-shadow: 0 0 3px rgba(0, 0, 0, 0.5);
  filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));
  padding: 0.5em 1em;
  background-color: white;
  margin-top: -10px;
  max-width: 20em;
  word-break: break-all;

  &::before, &::after {
    content: '';
    display: block;
    position: absolute;
    width: 0;
    height: 0;
    border: 10px solid transparent;
  }

  &.position-top {
    transform: translateY(-100%);
    margin-top: -10px;

    &::before, &::after {
      left: 10px;
    }

    &::before {
      top: 100%;
      border-top-color: #333;
    }

    &::after {
      top: calc(100% - 1px);
      border-top-color: white;
    }
  }

  &.position-bottom {
    margin-top: 10px;

    &::before, &::after {
      left: 10px;
    }

    &::before {
      bottom: 100%;
      border-bottom-color: #333;
    }

    &::after {
      bottom: calc(100% - 1px);
      border-bottom-color: white;
    }
  }

  &.position-left {
    transform: translateX(-100%);
    margin-left: -10px;

    &::before, &::after {
      top: 50%;
      transform: translateY(-50%);
    }

    &::before {
      left: 100%;
      border-left-color: #333;
    }

    &::after {
      left: calc(100% - 1px);
      border-left-color: white;
    }

  }

  &.position-right {
    margin-left: 10px;

    &::before, &::after {
      top: 50%;
      transform: translateY(-50%);
    }

    &::before {
      right: 100%;
      border-right-color: #333;
    }

    &::after {
      right: calc(100% - 1px);
      border-right-color: white;
    }
  }

}
</style>

使用表驱动编程重构

  • 创建一张表const positionList = {...} 分别对应四个位置
    • 'position-top': {...},
    • 'position-bottom': {...},
    • 'position-left': {...},
    • 'position-right': {...},
  • 设置每个位置的topleft属性
  • 提取重复代码
    • (contentWrapper as HTMLElement).style.top = ...
    • (contentWrapper as HTMLElement).style.left = ...
  • 去除if的判断,简化逻辑为一一对应

VuePopover.vue

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
<template>
  <div ref="popover"
       class="popover"
       @click="togglePop">
    <div ref="contentWrapper"
         class="content-wrapper"
         v-if="isVisible"
         :class="{[`position-${position}`]: true}">
      <slot name="content"></slot>
    </div>
    <span class="triggerWrapper" ref="triggerWrapper">
      <slot>button</slot>
    </span>
  </div>
</template>

<script lang="ts">
import {Component, Prop, Vue, Watch} from 'vue-property-decorator';

@Component
export default class VuePopover extends Vue {
  name = 'VuePopover';
  isVisible = false;

  @Prop({
    type: String,
    default: 'top',
    validator(value: string): boolean {
      return ['top', 'bottom', 'left', 'right'].includes(value);
    }
  }) position!: 'top' | 'bottom' | 'left' | 'right';

  // 定位 popover 显示位置
  // 'top', 'bottom', 'left', 'right'
  positionPop() {
    // 获取 弹出消息框节点 的引用,放到 body 子节点的最后
    document.body.appendChild(this.$refs.contentWrapper as Node);

    // 获取 popover元素 按钮元素
    const {contentWrapper, triggerWrapper} = this.$refs;
    // 获取 按钮元素 左上顶点的位置坐标 width, height, top, left
    const {width, height, top, left}
      = (triggerWrapper as HTMLElement)
      .getBoundingClientRect();
    // 获取 popover元素 height
    const {height: popHeight} = (contentWrapper as HTMLElement)
      .getBoundingClientRect();

    // 表驱动编程
    const positionList = {
      'position-top': {
        top: top + window.scrollY,
        left: left + window.scrollX
      },
      'position-bottom': {
        top: top + height + window.scrollY,
        left: left + window.scrollX
      },
      'position-left': {
        // popover高度居中 对齐于 按钮高度,按钮于popover高度差值的1/2
        top: top + Math.abs(height - popHeight) / 2 + window.scrollY,
        left: left + window.scrollX
      },
      'position-right': {
        top: top + Math.abs(height - popHeight) / 2 + window.scrollY,
        left: left + width + window.scrollX
      },
    };

    (contentWrapper as HTMLElement).style.top =
      positionList[`position-${this.position}` as positionListString].top + `px`;
    (contentWrapper as HTMLElement).style.left =
      positionList[`position-${this.position}` as positionListString].left + `px`;

  }

  // 定义一个点击事件的回调函数
  closeHandler(e: Event) {
    // 点击的目标对象 是否存在于 popover 包裹div中
    const hasPopover = ((this.$refs.contentWrapper as Element)
      ?.contains(e.target as Node));
    // 如果 点击的目标对象 不存在于 包裹弹出框的div中 即 点击了document
    if (!hasPopover) {
      this.closeEvent();
    } else {return;}
  }

  listenToDocument() {
    document.addEventListener('click', this.closeHandler);
  }

  rmListenerToDocument() {
    document.removeEventListener('click', this.closeHandler);
  }

  // 关闭 弹出框 销毁事件监听
  closeEvent() {
    this.isVisible = false;
    this.rmListenerToDocument();
  }

  openEvent() {
    this.isVisible = true;
    this.onShowPopover();
  }

  // 点击按钮 执行的方法:切换显示/隐藏 popover
  togglePop(event: Event) {
    // 点击按钮部分 执行的逻辑
    if ((this.$refs.triggerWrapper as HTMLElement)
      ?.contains(event.target as Node)) {
      // 切换显示/隐藏 popover
      this.isVisible = !this.isVisible;
    } else {
      // 点击popover部分 执行的逻辑
    }

  }

  onShowPopover() {
    this.$nextTick(() => {
      // 显示 弹出框,将弹出框节点放到 body 子节点的最后
      // 改变弹出框样式,使其出现在相对按钮合适的位置
      this.positionPop();

      // 使 添加监听在 点击事件冒泡 之后 异步执行
      setTimeout(() => {
        // 给 document 添加 click 事件监听
        this.listenToDocument();
      });
    });
  }

  // 监听 this.isVisible 状态变化 执行对应的逻辑
  @Watch('isVisible')
  onVisibleChange(newValue: boolean) {
    if (newValue) {
      // 当 popover 显示时 执行的逻辑
      this.onShowPopover();
    } else {
      // 当 popover 隐藏时 执行的逻辑
      return;
    }
  }

}
</script>

<style lang="scss" scoped>
@import './VuePopover.scss';

.popover {
  display: inline-block;
  vertical-align: top;
  position: relative;

  .triggerWrapper {
    display: inline-block;
  }

}

.content-wrapper {
  display: block;
  position: absolute;
  border: 1px solid $border-color;
  border-radius: $border-radius;
  //box-shadow: 0 0 3px rgba(0, 0, 0, 0.5);
  filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));
  padding: 0.5em 1em;
  background-color: white;
  margin-top: -10px;
  max-width: 20em;
  word-break: break-all;

  // 方向指向尖角基础样式
  &::before, &::after {
    content: '';
    display: block;
    position: absolute;
    width: 0;
    height: 0;
    border: .65em solid transparent;
  }

  // 方向指向尖角样式
  $position-list: 'top', 'bottom', 'left', 'right';
  @each $name in $position-list {
    &.position-#{$name} {
      //noinspection SassScssUnresolvedPlaceholderSelector
      @extend %position-#{$name}-default;

      &::before, &::after {
        //noinspection SassScssUnresolvedPlaceholderSelector
        @extend %position-#{$name}-common;
      }

      &::before {
        //noinspection SassScssUnresolvedPlaceholderSelector
        @extend %position-#{$name}-before;
      }

      &::after {
        //noinspection SassScssUnresolvedPlaceholderSelector
        @extend %position-#{$name}-after;
      }

    }

  }


}

</style>

抽出部分VuePopover.scss

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
$border-color: #333;
$border-radius: 4px;

%position-top-default {
  transform: translateY(-100%);
  margin-top: -10px;
}

%position-top-common {
  left: 10px;
  // 防止popover的border 遮盖button 导致hover重复触发
  border-bottom: none;
}

%position-top-before {
  top: 100%;
  border-top-color: #333;
}

%position-top-after {
  top: calc(100% - 1px);
  border-top-color: white;
}

%position-bottom-default {
  margin-top: 10px;
}

%position-bottom-common {
  left: 10px;
  border-top: none;
}

%position-bottom-before {
  bottom: 100%;
  border-bottom-color: #333;
}

%position-bottom-after {
  bottom: calc(100% - 1px);
  border-bottom-color: white;
}

%position-left-default {
  transform: translateX(-100%);
  margin-left: -10px;
}

%position-left-common {
  top: 50%;
  transform: translateY(-50%);
  border-right: none;
}

%position-left-before {
  left: 100%;
  border-left-color: #333;
}

%position-left-after {
  left: calc(100% - 1px);
  border-left-color: white;
}

%position-right-default {
  margin-left: 10px;
}

%position-right-common {
  top: 50%;
  transform: translateY(-50%);
  border-left: none;
}

%position-right-before {
  right: 100%;
  border-right-color: #333;
}

%position-right-after {
  right: calc(100% - 1px);
  border-right-color: white;
}

动态监听事件vue2.6+

  • 动态绑定事件
    • @[event]="eventFn"
    • v-on="{[popUp]: [openEvent, clearTimer], [popDown]: startTimer}"
  • 一次绑定多个事件
    • v-on="{mouseover: this.clearTimer, mouseout: this.startTimer}"
    • 可使用计算属性简化为v-on="multiEvent"
    • 不可使用缩写@
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
<template>
  <div ref="popover"
       class="popover"
       v-on="{[popUp]: [openEvent, clearTimer], [popDown]: startTimer}"
       @click="togglePop">
    <div ref="contentWrapper"
         class="content-wrapper"
         v-if="isVisible"
         v-on="multiEvent"
         :class="{[`position-${position}`]: true}">
      <slot name="content" :closeEvent="closeEvent"></slot>
    </div>
    <span class="triggerWrapper"
          ref="triggerWrapper"
          v-on="multiEvent">
      <slot>button</slot>
    </span>
  </div>
</template>

<script lang="ts">
import {Component, Prop, Vue, Watch} from 'vue-property-decorator';

@Component
export default class VuePopover extends Vue {
  name = 'VuePopover';
  isVisible = false;
  timer: number | null = null;

  // prop: [autoCloseDelay, position, trigger]
  @Prop({...}) autoCloseDelay!: false | number;
  @Prop({...}) position!: 'top' | 'bottom' | 'left' | 'right';

  clearTimer() {
    clearTimeout(this.timer || undefined);
  }

  startTimer() {
    if (this.autoCloseDelay) {
      this.timer = setTimeout(() => {
        this.closeEvent();
      }, this.autoCloseDelay);
    }
  }

  get multiEvent() {
    return {mouseover: this.clearTimer, mouseout: this.startTimer};
  }

  get popUp() {
    return this.trigger === 'hover'
      ? 'mouseenter'
      : '';
  }

  get popDown() {
    return this.trigger === 'hover'
      ? 'mouseleave'
      : '';
  }

  // 定位 popover 显示位置
  positionPop() {...}

  // 定义一个点击关闭事件的回调函数
  closeHandler(e: Event) {...}

  listenToDocument() {...}

  rmListenerToDocument() {...}

  // 关闭 弹出框 销毁事件监听
  closeEvent() {...}

  openEvent() {...}

  // 点击按钮 执行的方法:切换显示/隐藏 popover
  togglePop(event: Event) {...}

  onShowPopover() {...}

  // 监听 this.isVisible 状态变化 执行对应的逻辑
  @Watch('isVisible')
  onVisibleChange(newValue: boolean) {...}

}
</script>
...


添加支持可选的clickhover两种触发popover方式

Popovers.vue添加外部数据trigger 默认为hover

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<template>
  <div>
    <form>
      <fieldset>
        <legend>Popover</legend>
        <details open>
          <summary>Popover 可click/hover</summary>
          <VuePopover trigger="click">
            <template #content>
              <div>popover内容</div>
            </template>
            <VueButton>鼠标点击触发popover</VueButton>
          </VuePopover>
          <VuePopover trigger="hover">
            <template slot="content">
              <div>popover内容</div>
            </template>
            <VueButton>鼠标悬停触发popover</VueButton>
          </VuePopover>
        </details>
      </fieldset>
    </form>
    <br>
  </div>
</template>

<script lang="ts">
import {Component, Vue} from 'vue-property-decorator';
import VuePopover from './popover/VuePopover.vue';
import VueButton from './button/VueButton.vue';

@Component({
  components: {VuePopover, VueButton}
})
export default class Popovers extends Vue {
  name = 'Popovers';
}
</script>

<style lang="scss" scoped>
.popover {
  margin-top: 50px;
  margin-right: 20px;
}

</style>

VuePopover.vue

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
<template>
  <div ref="popover"
       class="popover"
       v-on="{[popUp]: [openEvent, clearTimer], [popDown]: startTimer}"
       @click="togglePop">
    <div ref="contentWrapper"
         class="content-wrapper"
         v-if="isVisible"
         v-on="multiEvent"
         :class="{[`position-${position}`]: true}">
      <slot name="content" :closeEvent="closeEvent"></slot>
    </div>
    <span class="triggerWrapper"
          ref="triggerWrapper"
          v-on="multiEvent">
      <slot>button</slot>
    </span>
  </div>
</template>

<script lang="ts">
import {Component, Prop, Vue, Watch} from 'vue-property-decorator';

@Component
export default class VuePopover extends Vue {
  name = 'VuePopover';
  isVisible = false;
  timer: number | null = null;

  // prop: [autoCloseDelay, position, trigger]
  @Prop({
    type: [Number, Boolean], default: 580, validator(value: false | number): boolean {
      return (value === false) || (value > 0);
    }
  }) autoCloseDelay!: false | number;
  @Prop({
    type: String,
    default: 'top',
    validator(value: string): boolean {
      return ['top', 'bottom', 'left', 'right'].includes(value);
    }
  }) position!: 'top' | 'bottom' | 'left' | 'right';
  @Prop({
    type: String,
    default: 'hover',
    validator(value: string): boolean {
      return ['hover', 'click'].includes(value);
    }
  }) trigger!: 'hover' | 'click';

  clearTimer() {
    clearTimeout(this.timer || undefined);
  }

  startTimer() {
    if (this.autoCloseDelay) {
      this.timer = setTimeout(() => {
        this.closeEvent();
      }, this.autoCloseDelay);
    }
  }

  get multiEvent() {
    return {mouseover: this.clearTimer, mouseout: this.startTimer};
  }

  get popUp() {
    return this.trigger === 'hover'
      ? 'mouseenter'
      : '';
  }

  get popDown() {
    return this.trigger === 'hover'
      ? 'mouseleave'
      : '';
  }

  // 定位 popover 显示位置
  positionPop() {
    // 获取 弹出消息框节点 的引用,放到 body 子节点的最后
    document.body.appendChild(this.$refs.contentWrapper as Node);

    // 获取 popover元素 按钮元素
    const {contentWrapper, triggerWrapper} = this.$refs;
    // 获取 按钮元素 左上顶点的位置坐标 width, height, top, left
    const {
      width: buttonWidth,
      height: buttonHeight,
      top: buttonTop,
      left: buttonLeft
    } = (triggerWrapper as HTMLElement)
      .getBoundingClientRect();
    // 获取 popover元素 height
    const {height: popHeight} = (contentWrapper as HTMLElement)
      .getBoundingClientRect();

    // 表驱动编程
    const positionList = {
      'position-top': {
        top: buttonTop + window.scrollY,
        left: buttonLeft + window.scrollX
      },
      'position-bottom': {
        top: buttonTop + buttonHeight + window.scrollY,
        left: buttonLeft + window.scrollX
      },
      'position-left': {
        // popover高度居中 对齐于 按钮高度,按钮于popover高度差值的1/2
        top: buttonTop + Math.abs(buttonHeight - popHeight) / 2 + window.scrollY,
        left: buttonLeft + window.scrollX
      },
      'position-right': {
        top: buttonTop + Math.abs(buttonHeight - popHeight) / 2 + window.scrollY,
        left: buttonLeft + buttonWidth + window.scrollX
      },
    };

    (contentWrapper as HTMLElement).style.top =
      positionList[`position-${this.position}` as positionListString].top + `px`;
    (contentWrapper as HTMLElement).style.left =
      positionList[`position-${this.position}` as positionListString].left + `px`;

  }

  // 定义一个点击关闭事件的回调函数
  closeHandler(e: Event) {
    // 点击的目标对象 是否存在于 popover 包裹div中
    const hasPopover = ((this.$refs.contentWrapper as Element)
      ?.contains(e.target as Node));
    // 如果 点击的目标对象 不存在于 包裹弹出框的div中 即 点击了document
    if (!hasPopover) {
      this.closeEvent();
    } else {return;}
  }

  listenToDocument() {
    document.addEventListener('click', this.closeHandler);
  }

  rmListenerToDocument() {
    document.removeEventListener('click', this.closeHandler);
  }

  // 关闭 弹出框 销毁事件监听
  closeEvent() {
    this.isVisible = false;
    this.rmListenerToDocument();
  }

  openEvent() {
    this.isVisible = true;
    this.onShowPopover();
  }

  // 点击按钮 执行的方法:切换显示/隐藏 popover
  togglePop(event: Event) {
    // 点击按钮部分 执行的逻辑
    if ((this.$refs.triggerWrapper as HTMLElement)
      ?.contains(event.target as Node)) {
      // 切换显示/隐藏 popover
      this.isVisible = !this.isVisible;
    } else {
      // 点击popover部分 执行的逻辑
    }

  }

  onShowPopover() {
    this.$nextTick(() => {
      // 显示 弹出框,将弹出框节点放到 body 子节点的最后
      // 改变弹出框样式,使其出现在相对按钮合适的位置
      this.positionPop();

      // 使 添加监听在 点击事件冒泡 之后 异步执行
      setTimeout(() => {
        // 给 document 添加 click 事件监听
        this.listenToDocument();
      });
    });
  }

  // 监听 this.isVisible 状态变化 执行对应的逻辑
  @Watch('isVisible')
  onVisibleChange(newValue: boolean) {
    if (newValue) {
      // 当 popover 显示时 执行的逻辑
      this.onShowPopover();
    } else {
      // 当 popover 隐藏时 执行的逻辑
      return;
    }
  }

}
</script>

<style lang="scss" scoped>...</style>


在弹出框中加上按钮,并且传递属性参数

在弹出框加上关闭按钮,并将组件中的关闭方法传给按钮,使按钮可以调用组件传来的 API

使用具名插槽

  • 在组件的slot标签中,提供属性name,用来定义具名插槽
    • 不带 name 的匿名插槽 <slot> 出口会带有隐含的name属性“default”
  • 使用组件时,即向具名插槽提供内容时,在template标签上使用 v-slot 指令
    • v-slot:xxx 的参数的形式提供其名称xxx
    • <template> 元素中的所有内容都将会被传入对应name属性插槽
    • 组件标签内,带有 v-slot 的 标签 之外的内容都会被视为默认插槽的内容
    • 即与<template v-slot:default>...</template>等效

使用 作用域插槽 ,实现传递组件的方法到<slot>

  • 在组件的slot标签中,在slot上绑定组件的数据
    • <slot v-bind:data="data">...后备内容...</slot> 匿名插槽
      • 缩写<slot :data="data">...后备内容...</slot>
    • <slot name="xxx" :data="data">...后备内容...</slot> 具名插槽
    • <slot> 元素上绑定的 attribute 被称为 插槽 prop
  • 使用组件时,写在组件内的template标签中使用带值的 v-slot 来定义提供的 插槽 prop 的名字
    • <template v-slot:default="slotProps">...</template> 匿名插槽
      • 缩写<template #default="slotProps">...</template>
    • <template #xxx="slotProps">...</template> 具名插槽
    • 插槽内容(可以为一个子组件)能够访问组件中才有的数据
    • 即向插槽传值
  • 独占默认插槽的缩写语法不能和具名插槽混用,因为它会导致作用域不明确,会导致警告
  • 模板内容中编译规则
    • 父级组件模板里的所有内容都是在父级作用域中编译的
    • 子组件模板里的所有内容都是在子作用域中编译的

例如

  • 父组件可以访问到数据 faData,提供的内容是在父级渲染的
  • 插槽中的子组件无法直接访问 faData
  • 为了让 faData 在父级的插槽内容在子组件中可用
    • 可以将 faData 作为 元素的一个 attribute 属性绑定
    • <slot :faData="faData"></slot>上绑定属性
  • 绑定在 元素上的 attribute 被称为 插槽 prop
  • 父级作用域中,可使用带值的 v-slot 来定义提供的插槽 prop 的名字
    • <template v-slot:default="anySlotPropsNameLikefaData">...</template>

使用 ES2015 解构来传入具体的插槽 prop

  • <template v-slot:xxx="{ data }">...
  • 写在<template></template>中,可以缩写具名插槽
    • v-slot:xxx缩写为#xxx
    • ...<template #xxx></template>...
    • 默认插槽...<comp #default="{ data }"</comp>...
  • 可以从外层组件获取数据

VuePopover.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
<template>
  <div ref="popover"
       class="popover"
       v-on="{[popUp]: [openEvent, clearTimer], [popDown]: startTimer}"
       @click="togglePop">
    <div ref="contentWrapper"
         class="content-wrapper"
         v-if="isVisible"
         v-on="multiEvent"
         :class="{[`position-${position}`]: true}">
      <slot name="content" :closeEvent="closeEvent"></slot>
    </div>
    <span class="triggerWrapper"
          ref="triggerWrapper"
          v-on="multiEvent">
      <slot>button</slot>
    </span>
  </div>
</template>
...
  • <slot name="content" :closeEvent="closeEvent"></slot>
  • 在具名插槽content中绑定数据:closeEvent="closeEvent"

Popovers.vue 展示组件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
<template>
  <div>
    <form>
      <fieldset>
        <legend>Popover</legend>
        <details open>
          <summary>Popover 带关闭按钮</summary>
          <VuePopover trigger="hover">
            <template #content="{closeEvent}">
              <div>popover使用 slotScope 传参给slot</div>
              <VueButton @click="closeEvent">关闭</VueButton>
            </template>
            <VueButton>提供slot事件回调closeEvent</VueButton>
          </VuePopover>
        </details>
      </fieldset>
    </form>
    <br>
  </div>
</template>
...
  • <template #content="{closeEvent}">...</template>
    • 解构数据#content="{closeEvent}"
    • 使用解构出的方法<VueButton @click="closeEvent">关闭</VueButton>

参考


封装为指令



参考