前言
因为公司项目用 无界微前端框架 集成之后,Element-UI 中的 el-tooltip、el-popover 等气泡系列组件弹出位置会发生偏移,出于好奇本人看了 el-tooltip、el-popover 的部分源码,决定自己实现一个类似 el-popover 的组件
前置知识/准备
Node.appendChild 方法
(摘自 MDN)
所以我们可以把气泡元素从组件内部挂载到 document.body 中
js
<template>
<span>
<div ref="popper" class="popover">这是popper弹出框</div>
<span class="reference-wrapper">
<span>这是触发popper的元素</span>
</span>
</span>
</template>
<script>
export default {
mounted(){
const popper = this.$refs.popper
document.body.appendChild(popper) // $refs.popper 元素会从 span 元素内部移动到
// document.body.children 中
// 这允许我们将气泡元素 $refs.popper 挂载到 document.body 上
},
}
组件样式
组件所有样式如下,建议提前复制到 .vue 文件的 style 标签中,文章后面将会省略
js
.popover {
position: absolute;
background: #fff;
min-width: 150px;
border-radius: 4px;
border: 1px solid #ebeef5;
padding: 12px;
z-index: 2000;
color: #606266;
line-height: 1.4;
text-align: justify;
font-size: 14px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
word-break: break-all;
z-index: 9999;
}
.popover__title {
color: #303133;
font-size: 16px;
line-height: 1;
margin-bottom: 12px;
}
.popper__arrow {
position: absolute;
width: 8px;
height: 8px;
}
.popover .popper__arrow::after {
content: '';
position: absolute;
width: 8px;
height: 8px;
background-color: #fff;
border: 1px solid #e4e7ed;
transform: rotate(45deg);
}
.popover[data-popper-placement^=bottom] .popper__arrow {
top: -4px;
}
.popover[data-popper-placement^=bottom] .popper__arrow::after {
border-right-color: transparent;
border-bottom-color: transparent;
border-top-left-radius: 2px;
}
.popover[data-popper-placement^=top] .popper__arrow {
bottom: -4px;
}
.popover[data-popper-placement^=top] .popper__arrow::after {
border-left-color: transparent;
border-top-color: transparent;
border-bottom-right-radius: 2px;
}
.popover[data-popper-placement^=left] .popper__arrow {
right: -4px;
}
.popover[data-popper-placement^=left] .popper__arrow::after {
border-left-color: transparent;
border-bottom-color: transparent;
border-top-right-radius: 2px;
}
.popover[data-popper-placement^=right] .popper__arrow {
left: -4px;
}
.popover[data-popper-placement^=right] .popper__arrow::after {
border-right-color: transparent;
border-top-color: transparent;
border-bottom-left-radius: 2px;
}
用 render 函数替代 template 模板编写组件结构
组件的所有 props、method、slot 等都将会与 el-popover 保持一致。 为了更灵活的为 reference 插槽元素在不同的 trigger
(气泡触发方式) 下绑定气泡触发事件回调函数(比如 click、hover 等),需要用 render
函数代替模板来编写组件结构
(后面 reference 统一指代的是触发气泡框的媒介元素, popper 统一指代的是气泡框元素)。
js
<script>
export default {
name: "MyPopover",
props: {
content: String,
title: String
},
data() {
return {
showPopper: true // 控制气泡元素显隐
}
},
render(h) {
const referenceSlot = this.$slots.reference // reference 插槽内容
// 如果没有传入 reference 插槽内容,则不渲染组件
if (!Array.isArray(referenceSlot) || referenceSlot.length === 0) return null
/**
将传入 reference 插槽的第一个元素节点(非文本节点)作为 reference
错误示范(直接传入文本节点,组件将不渲染任何元素):
<MyPopover>
<template v-slot:reference>
123 // 注: 文本节点 '123' 不能作为 reference
</template> // 必须得是 <el-button>click me</el-button> 或 <span>reference</span> 这种元素节点才行
</MyPopover>
*/
let referenceVnode, vnode
for (let i = 0, l = referenceSlot.length; i < l; i++) {
vnode = referenceSlot[i]
// 虚拟 DOM 有 tag 值,是元素节点
if (vnode.tag) {
referenceVnode = vnode
break
}
}
if (!referenceVnode) return null
/** 组件结构概览,不过我们这里用 render 函数实现
<span>
<div class="popover" ref="popper" v-show="showPopper">
<div v-if="title" class="title">{{ title }}</div>
<slot>{{ content }}</slot>
</div>
<span class="reference-wrapper" ref="wrapper">
<slot name="reference"></slot>
</span>
</span>
*/
return h("span", [
h(
"div",
{
ref: "popper",
class: ["popover"],
// v-show 控制气泡显隐
directives: [
{
name: "show",
value: this.showPopper
}
]
},
[
// popover 标题
this.title ? h("div", {class: ["popover__title"]}, this.title) : null,
// popper 内容
this.$slots.default || this.content
]
),
h("span", {class: ["reference-wrapper"], ref: "wrapper"}, [referenceVnode])
])
},
}
</script>
页面效果:

父组件 <template> 结构:
js
<my-popover title="这是一个标题">
<p>这天气好热,快吃点甜甜的西瓜吧~</p>
<template v-slot:reference>
<el-button type="primary">click me</el-button>
</template>
</my-popover>
为 reference 在不同 trigger 下添加事件回调函数
我们这里暂时只考虑 trigger
为 click
这种情况:
js
// 先将 showPopper 默认值设为 false,更方便我们观察气泡从隐藏到显示的效果
data() {
return {
showPopper: false // 控制气泡元素显隐
}
},
methods: {
/** 点击 reference 切换气泡显隐*/
doToggle() {
this.showPopper = !this.showPopper
},
/** 点击页面其他地方隐藏气泡*/
handleDocumentClick(e) {
let reference
(reference = this.$refs.wrapper) && (reference = reference.children) && (reference = reference[0])
const popper = this.$refs.popper
if(!reference || !popper) {
return this.showPopper = false
}
/**如果点击元素是 reference 或 popper 的子元素,则不隐藏气泡 */
if(reference.contains(e.target) || popper.contains(e.target)) return
/**关闭气泡 */
this.showPopper = false
}
},
完善 render 方法,同时在组件销毁时清理点击事件:
js
render(h) {
... // 前面这部分代码保持不变
if (!referenceVnode) return null
/**
referenceVnode 是我们传入 reference 插槽的第一个元素节点的虚拟 DOM(vnode)
Vue 会将绑定在普通 html元素或组件上的 .native 事件回调函数添加在 vnode.data.on 中
比如模板: <button @click="handleClick">click me </button> 转化成虚拟DOM:
Vnode {
tag: 'button',
data: {
on: {
click: handleClick,
...
}
},
...
}
}
这里我们自己往 vnode.data.on.click 里添加点击 reference 的事件回调函数来控制气泡显隐
后续 Vue 用 diff 算法将虚拟DOM转化为真实元素时会自动绑定上我们添加的事件
*/
const data = referenceVnode.data || (referenceVnode.data = {})
const on = data.on || (data.on = {})
const events = on.click
if(Array.isArray(events)) {
events.push(this.doToggle)
} else if(events) {
on.click = [events, this.doToggle]
} else {
on.click = this.doToggle
}
/**点击页面其他地方关闭气泡*/
document.addEventListener('click', this.handleDocumentClick)
return h("span", [
// ... 后面代码保持不变
},
...,
// 组件销毁时需要清理document事件监听函数
beforeDestroy() {
document.removeEventListener('click', this.handleDocumentClick)
}
现在能够点击 reference 元素控制气泡显隐了,页面效果:

父组件 <template> 结构:
js
<my-popover title="这是一个标题">
<p>这天气好热,快吃点甜甜的西瓜吧~</p>
<template v-slot:reference>
<el-button type="primary">click me</el-button>
</template>
</my-popover>
针对 trigger 的不同值绑定相应的事件触发函数
这里我们还是暂时只考虑 trigger
为 click
或 hover
的情况
js
props: {
...,
trigger: {
type: String,
default: 'click',
validator: value => ['click', 'hover'].indexOf(value) > -1
},
// trigger 为 hover 时移出指针,popover 气泡隐藏的延迟时间(ms)
closeDelay: {
type: Number,
default: 200
},
},
methods: {
...,
// 指针移入 reference、popper 时的回调
handleMouseEnter() {
// 注: this.timer 更多充当一个变量,不需要绑定响应式
// 所以 this._timer 并没有写在 data 选项里,
clearTimeout(this._timer)
this.showPopper = true
},
// 指针移出 reference、popper 时的回调
handleMouseLeave() {
this._timer = setTimeout(() => this.showPopper = false, this.closeDelay)
},
// 将为虚拟DOM添加事件的逻辑封装为一个函数
addVnodeEvents(vnode, event, handler) {
const data = vnode.data || (vnode.data = {})
const on = data.on || (data.on = {})
const events = on[event]
if (Array.isArray(events)) {
events.push(handler)
} else if (events) {
on[event] = [events, handler]
} else {
on[event] = handler
}
}
}
完善 render 方法,当 trigger
为 hover
时,还需要为 popper 气泡元素添加 mouseenter
、mouseout
事件回调函数
js
render(h) {
... // 上面代码保持不变
if (!referenceVnode) return null
// 之前逻辑注释掉
// const data = referenceVnode.data || (referenceVnode.data = {})
// const on = data.on || (data.on = {})
// const events = on.click
// if (Array.isArray(events)) {
// events.push(this.doToggle)
// } else if (events) {
// on.click = [events, this.doToggle]
// } else {
// on.click = this.doToggle
// }
/**点击页面其他地方关闭气泡*/
// document.addEventListener("click", this.handleDocumentClick)
if (this.trigger === "click") {
this.addVnodeEvents(referenceVnode, "click", this.doToggle)
document.addEventListener("click", this.handleDocumentClick)
} else {
document.removeEventListener("click", this.handleDocumentClick)
// 为 reference 元素绑定指针移入、移出事件
if (this.trigger === "hover") {
this.addVnodeEvents(referenceVnode, "mouseenter", this.handleMouseEnter)
this.addVnodeEvents(referenceVnode, "mouseleave", this.handleMouseLeave)
}
}
return h("span", [
h(
"div",
{
ref: "popper",
class: ["popover"],
// v-show 控制 popper 显隐
directives: [
{
name: "show",
value: this.showPopper
}
],
on: {
// 为气泡弹框绑定指针移入、移出事件
mouseenter: this.trigger === "hover" ? this.handleMouseEnter : () => {},
mouseleave: this.trigger === "hover" ? this.handleMouseLeave : () => {}
}
},
[
// popover 标题
this.title ? h("div", {class: ["popover__title"]}, this.title) : null,
// popper 内容
this.$slots.default || this.content
]
),
h("span", {class: ["reference-wrapper"], ref: "wrapper"}, [referenceVnode])
])
}
引入 @popperjs/core 库,修正气泡位置
先下载 @popperjs/core
依赖,popperjs 使用文档
js
npm i @popperjs/core
在项目中引入 @popperjs/core
js
import { createPopper } from '@popperjs/core'
添加侦听器,监听 showPopper
的改变,将 popper 气泡位置定位到 reference 下方
js
import { createPopper } from '@popperjs/core'
...,
watch: {
showPopper(val) {
val ? this.createPopper() : this.destroyPopper()
},
}
...,
methods: {
...,
async createPopper() {
// 需要等 DOM 更新完成后再定位 popper,此时 popper 的 style.display !== 'none'
await this.$nextTick()
let referenceElm
(referenceElm = this.$refs.wrapper) && (referenceElm = referenceElm.children) && (referenceElm = referenceElm[0])
const popperElm = this.$refs.popper
if(!referenceElm || !popperElm) return
// 将popper气泡元素插入到 document.body 中
document.body.appendChild(popperElm)
// 将 popper 定位到 reference 下方
this.popperJS = createPopper(referenceElm, popperElm, {
// 使 popper 与 reference 保持一定距离
modifiers: [
{
name: 'offset',
options: {
offset: [0, 10],
},
}
]
})
},
// 销毁
destroyPopper() {
if(!this.popperJS) return
this.popperJS.destroy()
this.popperJS = null
}
}
页面效果:

为 popper 添加指向箭头
需要将 div.popper__arrow 元素(气泡框箭头元素,相关样式在文章前面) 插入到 popper 元素中,此外需要为 div.popper__arrow 添加 data-popper-arrow 属性(popperjs 会自动将 popper 的子元素中具有 data-popper-arrow 属性的元素作为箭头元素,定位 popper 的同时会使箭头元素指向 reference 中心)
js
// 在 popper 中添加 div.popper_arrow
render(h) {
... // 上面部分代码保持不变
return h("span", [
h(
"div",
{
ref: "popper",
class: ["popover"],
// v-show 控制 popper 显隐
directives: [
{
name: "show",
value: this.showPopper
}
],
on: {
mouseenter: this.trigger === "hover" ? this.handleMouseEnter : () => {},
mouseleave: this.trigger === "hover" ? this.handleMouseLeave : () => {}
}
},
[
this.title ? h("div", {class: ["popover__title"]}, this.title) : null,
this.$slots.default || this.content,
// 插入了 div.popper__arrow 箭头元素
h('div', { class: 'popper__arrow', attrs: { 'data-popper-arrow': '' } })
]
),
h("span", {class: ["reference-wrapper"], ref: "wrapper"}, [referenceVnode]),
])
}
页面效果:

支持传入 props:placement、offset 改变气泡位置、气泡距 reference 偏移
js
props: {
...,
placement: {
type: String,
default: 'bottom',
validator: value => /^(top|bottom|left|right)(-start|-end)?$/g.test(value)
},
offset: {
type: Number,
default: 10
}
}
...,
methods: {
...,
async createPopper() {
if (!/^(top|bottom|left|right)(-start|-end)?$/g.test(this.placement)) {
return;
}
await this.$nextTick()
let referenceElm
(referenceElm = this.$refs.wrapper) && (referenceElm = referenceElm.children) && (referenceElm = referenceElm[0])
const popperElm = this.$refs.popper
if(!referenceElm || !popperElm) return
document.body.appendChild(popperElm)
this.popperJS = createPopper(referenceElm, popperElm, {
placement: this.placement, // 根据 props.placement
modifiers: [
{
name: 'offset',
options: {
offset: [0, this.offset],
},
}
]
})
},
}
支持 v-model 和 trigger === 'manual' 控制气泡显隐
js
props:{
...,
trigger: {
type: String,
default: "click",
// validator: (value) => ["click", "hover"].indexOf(value) > -1
validator: (value) => ["click", "hover", "manual"].indexOf(value) > -1
},
...,
value: Boolean,
},
watch: {
showPopper(val) {
val ? this.createPopper() : this.destroyPopper()
this.$emit('input', val) // +
}
},
...,
created() {
// 在 created 中我们自己定义一个侦听器监听 props.value 的值
// 因为在 created 钩子中:监听 showPopper 的侦听器已经定义完成
// 所以当为 valWatcher 设置 immediate 为 true 时
// 一定能够触发 watch.showPopper 监听器的回调函数,然后气泡出现并定位到正确位置
if (!this.valWatcher) {
this.valWatcher = this.$watch(
"value",
function (val) {
this.showPopper = val
},
{immediate: true}
)
}
},
...
完整代码
js
<script>
import {createPopper} from "@popperjs/core"
export default {
name: "MyPopover",
props: {
content: String,
title: String,
trigger: {
type: String,
default: "click",
validator: (value) => ["click", "hover", "manual"].indexOf(value) > -1
},
// trigger 为 hover 时移出指针,popover 气泡隐藏的延迟时间(ms)
closeDelay: {
type: Number,
default: 200
},
placement: {
type: String,
default: "bottom",
validator: (value) => /^(top|bottom|left|right)(-start|-end)?$/g.test(value)
},
offset: {
type: Number,
default: 10
},
value: Boolean
},
watch: {
showPopper(val) {
val ? this.createPopper() : this.destroyPopper()
this.$emit("input", val)
}
},
data() {
return {
showPopper: false // 控制气泡元素显隐
}
},
render(h) {
const referenceSlot = this.$slots.reference // reference 插槽内容
// 如果没有传入 reference 插槽内容,则不渲染组件
if (!Array.isArray(referenceSlot) || referenceSlot.length === 0) return null
let referenceVnode, vnode
for (let i = 0, l = referenceSlot.length; i < l; i++) {
vnode = referenceSlot[i]
// 虚拟 DOM 有 tag 值,是元素节点
if (vnode.tag) {
referenceVnode = vnode
break
}
}
if (!referenceVnode) return null
if (this.trigger === "click") {
this.addVnodeEvents(referenceVnode, "click", this.doToggle)
document.addEventListener("click", this.handleDocumentClick)
} else {
document.removeEventListener("click", this.handleDocumentClick)
// 为 reference 元素绑定指针移入、移出事件
if (this.trigger === "hover") {
this.addVnodeEvents(referenceVnode, "mouseenter", this.handleMouseEnter)
this.addVnodeEvents(referenceVnode, "mouseleave", this.handleMouseLeave)
}
}
return h("span", [
h(
"div",
{
ref: "popper",
class: ["popover"],
// v-show 控制 popper 显隐
directives: [
{
name: "show",
value: this.showPopper
}
],
on: {
mouseenter: this.trigger === "hover" ? this.handleMouseEnter : () => {},
mouseleave: this.trigger === "hover" ? this.handleMouseLeave : () => {}
}
},
[
this.title ? h("div", {class: ["popover__title"]}, this.title) : null,
this.$slots.default || this.content,
// 插入了 div.popper__arrow 箭头元素
h("div", {class: "popper__arrow", attrs: {"data-popper-arrow": ""}})
]
),
h("span", {class: ["reference-wrapper"], ref: "wrapper"}, [referenceVnode])
])
},
methods: {
/** 点击 reference 切换气泡显隐*/
doToggle() {
this.showPopper = !this.showPopper
},
/** 点击页面其他地方隐藏气泡*/
handleDocumentClick(e) {
let reference
;(reference = this.$refs.wrapper) && (reference = reference.children) && (reference = reference[0])
const popper = this.$refs.popper
if (!reference || !popper) {
return (this.showPopper = false)
}
/**如果点击元素是 reference 或 popper 的子元素,则不隐藏气泡 */
if (reference.contains(e.target) || popper.contains(e.target)) return
/**关闭气泡 */
this.showPopper = false
},
// 指针移入 reference、popper 时的回调
handleMouseEnter() {
// 注: this.timer 更多充当一个变量,不需要绑定响应式
// 所以 this._timer 并没有写在 data 选项里,
clearTimeout(this._timer)
this.showPopper = true
},
// 指针移出 reference、popper 时的回调
handleMouseLeave() {
this._timer = setTimeout(() => (this.showPopper = false), this.closeDelay)
},
// 将为虚拟DOM添加事件的逻辑封装为一个函数
addVnodeEvents(vnode, event, handler) {
const data = vnode.data || (vnode.data = {})
const on = data.on || (data.on = {})
const events = on[event]
if (Array.isArray(events)) {
events.push(handler)
} else if (events) {
on[event] = [events, handler]
} else {
on[event] = handler
}
},
async createPopper() {
if (!/^(top|bottom|left|right)(-start|-end)?$/g.test(this.placement)) {
return
}
await this.$nextTick()
let referenceElm
;(referenceElm = this.$refs.wrapper) && (referenceElm = referenceElm.children) && (referenceElm = referenceElm[0])
const popperElm = this.$refs.popper
if (!referenceElm || !popperElm) return
document.body.appendChild(popperElm)
this.popperJS = createPopper(referenceElm, popperElm, {
placement: this.placement, // 根据 props.placement
modifiers: [
{
name: "offset",
options: {
offset: [0, this.offset]
}
}
]
})
},
// 销毁
destroyPopper() {
if (!this.popperJS) return
this.popperJS.destroy()
this.popperJS = null
}
},
created() {
if (!this.valWatcher) {
this.valWatcher = this.$watch(
"value",
function (val) {
this.showPopper = val
},
{immediate: true}
)
}
},
beforeDestroy() {
document.removeEventListener("click", this.handleDocumentClick)
}
}
</script>
<style lang="scss" scoped>
.popover {
position: absolute;
background: #fff;
min-width: 150px;
border-radius: 4px;
border: 1px solid #ebeef5;
padding: 12px;
z-index: 2000;
color: #606266;
line-height: 1.4;
text-align: justify;
font-size: 14px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
word-break: break-all;
z-index: 9999;
}
.popover__title {
color: #303133;
font-size: 16px;
line-height: 1;
margin-bottom: 12px;
}
.popper__arrow {
position: absolute;
width: 8px;
height: 8px;
}
.popover .popper__arrow::after {
content: "";
position: absolute;
width: 8px;
height: 8px;
background-color: #fff;
border: 1px solid #e4e7ed;
transform: rotate(45deg);
}
.popover[data-popper-placement^="bottom"] .popper__arrow {
top: -4px;
}
.popover[data-popper-placement^="bottom"] .popper__arrow::after {
border-right-color: transparent;
border-bottom-color: transparent;
border-top-left-radius: 2px;
}
.popover[data-popper-placement^="top"] .popper__arrow {
bottom: -4px;
}
.popover[data-popper-placement^="top"] .popper__arrow::after {
border-left-color: transparent;
border-top-color: transparent;
border-bottom-right-radius: 2px;
}
.popover[data-popper-placement^="left"] .popper__arrow {
right: -4px;
}
.popover[data-popper-placement^="left"] .popper__arrow::after {
border-left-color: transparent;
border-bottom-color: transparent;
border-top-right-radius: 2px;
}
.popover[data-popper-placement^="right"] .popper__arrow {
left: -4px;
}
.popover[data-popper-placement^="right"] .popper__arrow::after {
border-right-color: transparent;
border-top-color: transparent;
border-bottom-left-radius: 2px;
}
</style>
最后想说的话
其实与其说这是一篇教学文章,不如更像是我自己记录的一个学习笔记,谢谢大家观看!