本文首发于:https://github.com/bigo-frontend/blog/ 欢迎关注、转载。
需求分析:
- 组件可设置弹出位置(placement),支持top、bottom
- 气泡弹窗弹出位置计算,边界计算,支持设置边界范围
- 支持点击弹窗元素之外的区域,弹窗关闭
- 支持弹窗内容自定义
- 效果预览:
实现方案
实现自定义组件, 我们一般都会想到Vue.extend
,vue.extend
相当于一个扩展实例构造器,用于创建一个具有初始化选项的Vue子类,在实例化时可以进行扩展选项,最后使用$mount方法绑定在元素上。
先写一个简单的demo
- 编写index.vue文件
<template>
<div
v-if="visible"
class="popover">
<div
ref="arrowDom"
:style="{left: `${arrowLeft}px`}"
class="popover__arrow"></div>
<div class="popover__content">
<div class="popover__content-p1">{{ txt }} </div>
</div>
</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">
<!--弹窗主体部分-->
<div
v-show="visible"
ref="popoverDom"
:style="positionStyle"
class="im-popover">
<div
ref="arrowDom"
:style="{left: `${arrowLeft}px`}"
class="im-popover__arrow"
></div>
<!--可自定义内容,默认插槽-->
<slot></slot>
</div>
<!--触发弹窗元素-->
<slot name="trigger"></slot>
</div>
</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>
<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前端,下期见。