共计 8783 个字符,预计需要花费 22 分钟才能阅读完成。
第十四集: 从零开始实现一套 pc 端 vue 的 ui 组件库(Popover 弹出框)
1. 本集定位
Popover 组件不同于 alert 这种霸道总裁, 它更倾向于辅助显示某些未显示完整的内容, toast 组件与其相比更偏向 ’ 提示 ’, Popover 更偏向于 ’ 展示 ’, 但属于一种 ’ 轻展示 ’, 毕竟不会出现 ’ 蒙层 ’ 等效果.
别看它小小的, 它里面的门道还不少, 最主要的就是他的定位问题, 比如说它设定为出现在元素上方, 可是元素自己已经在最顶上了, 此时就需要给他 ’ 换个方位 ’ 展示了, 关于这个定位的计算方式还可以在其他组件上应用, 比如下一集要写的 ’ 日期组件 ’, 还有就是这个弹出框的消失时机, 本人更推荐只要滚动就清除它, 每次计算他的位置所消耗的性能很高的, 因为每次都会触发重排与重绘, 话不多说本次我们就一起来搞一搞这个小东西.????
效果展示
2. 需求分析
- 可配置触发的形式, 比如 ’ 点击 ’ 与 ’ 悬停 ’.
- 可定义组件出现的位置, ‘ 上左 ’、’ 上中 ’、’ 上右 ’ 等等情况吧 …
- 本组件可能会被大批量的使用, 性能优化方面需要重点考虑.
- 及时移除相关事件
3. 基础的搭建
vue-cc-ui/src/components/Popover/index.js
export {default} from './main/index';
vue-cc-ui/src/components/Popover/main/popover.vue
<template>
// 老套路, 父级
<div class="cc-popover" ref='popover'>
// 内容区域
<div class="cc-popover__content" ref='content'>
// 这里分了两层是为了解决一会遇到的问题的
<div class="cc-popover__box">
<slot name="content"> 请输入内容 </slot>
</div>
</div>
// 这个是被包裹的元素;
// 要用我们的 popover 标签起来才有效;
<slot />
</div>
</template>
export default {
name: "ccPopover",
props: {
// 事件类型用户自己传, 本次只支持两种模式
trigger: {
type: String,
default: "hover",
// 这里为了扩展所以这样写
// 只有两种情况可以优化为只要不是 click 就默认给 hover
validator: value => ["click", "hover"].indexOf(value) > -1
},
placement: {
// 方位我们定位的范围是, 每个方向都有 '开始','中间','结束' 三种情况
type: String,
default: "right-middle",
validator(value) {let dator = /^(top|bottom|left|right)(-start|-end|-middle)?$/g.test(value);
return dator;
}
}
},
初始化项目的一些操作
通过用户的输入, 来给 dom 添加监听事件
下面的 on 方法 其实是借鉴了 element-ui 的写法, 有所收获.
mounted() {this.$nextTick(() => {
// 获取到当前用户定义的事件类型
let trigger = this.trigger,
// 本次选择操作 dom
popover = this.$refs.popover;
if (trigger === "hover") {
// hover 当然要监听 进入与离开的事件拉
on(popover, "mouseenter", this.handleMouseEnter);
on(popover, "mouseleave", this.handleMouseLeave);
} else if (trigger === "click") {on(popover, "click", this.handlClick);
}
});
},
on 方法的封装
element 还判断了是不是服务器环境等操作, 我们这里只选取了浏览器端相关的代码.
vue-cc-ui/src/assets/js/utils.js
// 添加事件, element-ui 判断是不是服务器环境
export function on(element, event, handler) {if (element && event && handler) {element.addEventListener(event, handler, false);
}
}
// 移除事件
export function off(element, event, handler) {if (element && event) {element.removeEventListener(event, handler, false);
}
}
4. 从点击事件说起
假设用户传入的事件类型是 ’click’, mounted 里面的操作已经绑定了相应的事件 ’handlClick’, 接下来的任务是:
- 让提示框出现或者消失.
- 如果是出现, 计算要出现在什么位置.
- 如果是出现, 为 document 绑定事件, 用于隐藏这个 popover.
思路概述
- this.init 变量来配合 v -if, 这样保证组件在没有被使用过的情况下, 永远不会渲染出来.
- 涉及到频繁点击时, v-show 就要登场了, this.show 控制 v -show, 所以这两个指令可以来一次亲密配合.
- 事件不要绑定在 body 上, 有一种可能就是用户 body 没有完全包裹内容, 比如不设高度.
handlClick() {
// 不管怎么样只要触发一次, 这个值就会把 v -if 永远置成 true;
this.init = true;
// 在他本身被 css 属性隐藏的时候
if (this.$refs.content && this.$refs.content.style.display === "none") {
// 必须这样强制写,
// 否则与之后的代码配合时, 有 bug 无法消失
this.$refs.content.style.display = "block";
this.show = true;
} else {
// 除了第一次之外, 之后都只是变换这个 this.show 的 '真假'
this.show = !this.show;
}
// 不要监听 body, 因为可能 height 不是 100%;
// 这个 document 其实也可以由用户指定
// 放入让 popover 消失的函数, 这样方便之后的移除事件操作
this.show && document.addEventListener("click", this.close);
},
点击消失事件
close(e) {
// 肯定要判断事件源到底是不是咱们的 popover 组件
if (this.isPopover(e)) {
this.show = false;
// 点击完就可以移除了, 下次操作再绑定就可以
// 因为如果往 document 绑定太多事件, 会非常卡, 非常卡
document.removeEventListener("click", this.close);
}
},
isPopover
- 这个负责判断点击的元素是不是 popover 组件
- 点击 popover 弹出层里面的元素, 也算是点击 popover, 因为用户可能会通过 slot 传入一些结构, 这种情况不能关闭.
isPopover(e) {
let dom = e.target,
popover = this.$refs.popover,
content = this.$refs.content;
// 1: 点击 popover 包裹的元素, 关闭 popover
// 2: 点击 popover 内容区元素, 不关闭 popover
return !(popover.contains(dom) || content.contains(dom));
},
上面讲述了具体的出现与消失的逻辑, 接下来我们来让他真正的出现在屏幕上
watch: {
// 我们会监控 v -if 的情况, 第一次渲染的时候才做这里的操作, 而且只执行一次
init() {this.$nextTick(() => {
let trigger = this.trigger,
dom = this.$refs.content,
content = this.$refs.content;
// 这里有人会有疑问, 这什么鬼写法
// 这里是因为 append 操作属于剪切, 所以不会出现两个元素
// 其实这个元素出现之后就一直存在与页面上了, 除非销毁本组件
// 组件销毁的时候, 我们会 document.body.removeChild(content);
document.body.appendChild(dom);
if (trigger === "hover") {on(content, "mouseenter", this.handleMouseEnter);
on(content, "mouseleave", this.handleMouseLeave);
}
});
},
// 这个才是每次显示隐藏都会触发的方法
show() {
// 判断只有显示提示框的时候才回去计算位置
if (this.show) {this.$nextTick(() => {let { popover, content} = this.$refs,
{left, top, options} = getPopoverposition(
popover,
content,
this.placement
);
// 有了坐标, 就可以很开心的定位了
this.left = left;
this.top = top;
// 这个配置是决定 '小三角' 的位置的
this.options = options;
});
}
}
},
5. 重点问题, 获取显示的位置 getPopoverposition
思路
- 先实验是否可以按照用户传进来的坐标进行展示.
- 如果不可以按照用户传进来的坐标进行展示, 循环所有展示方式, 查看是否有可用的方案.
- 获取 dom 坐标会引起重排重绘, 所以获取坐标的工作我们只做一次.
vue-cc-ui/src/assets/js/vue-popper.js
// 受到 vue 源码实例化 vue 部分的启发, 有了如下写法.
// CONTANT 常数: 物体距离目标的间隙距离, 单位 px;
function getPopoverPosition(popover, content, direction,CONTANT) {
// 这个 show 本次用不到, 为以后的组件做准备
let result = {show: true};
// 1: 让这个函数去初始化 '参与运算的所有参数';
// 把处理好的值, 付给 result 对象
getOptions(result, popover, content, direction,CONTANT);
// 2: 拿到屏幕的偏移
let {left, top} = getScrollOffset();
// 3: return 出去的坐标, 一定是针对当前可视区域的
result.left += left;
result.top += top;
return result;
}
先把所有可能做成列表, 也许有人有疑问, 为什么不把 list 这个组 for 循环生成, 那是因为 for 循环也是需要性能的, 这样直接下来可以减少运算, 所以很多没必要的运算尽量不要写
const list = [
'top-end',
'left-end',
'top-start',
'right-end',
'top-middle',
'bottom-end',
'left-start',
'right-start',
'left-middle',
'right-middle',
'bottom-start',
'bottom-middle'
];
getOptions 初始化运算所需参数
function getOptions(result, popover, content, direction,CONTANT = 10) {
// 1: 可能会反复的调用, 所以来个深复制
let myList = list.concat(),
client = popover.getBoundingClientRect();// 获取 popover 的可视区距离
// 2: 每次使用一种模式, 就把这个模式从 list 中干掉, 这样直到数组为空, 就是所有可能性都尝试过了
myList.splice(list.indexOf(direction), 1);
// 3: 把参数整理好, 传给处理函数
getDirection(result, {
myList,
direction,
CONTANT,
top: client.top,
left: client.left,
popoverWidth: popover.offsetWidth,
contentWidth: content.offsetWidth,
popoverHeight: popover.offsetHeight,
contentHeight: content.offsetHeight
});
}
getDirection
代码有点多, 但是逻辑很简单, 我来说一下思路
- 比如用户传入的是 ’top-end’ 拆分为 top 与 end 字段
- 也就是要出现在目标元素的上方, 靠右边.
- 针对 end–> result.left = 目标元素左侧距离可视区 + 目标元素宽度 – 弹出框宽度;
- 针对 top–> result.top = 目标元素上方距离可视区 – 弹出框高度 – 两者间距距离;
- 没有什么复杂逻辑, 就是单纯的算术
function getDirection(result, options) {
let {
top,
left,
CONTANT,
direction,
contentWidth,
popoverWidth,
contentHeight,
popoverHeight
} = options;
result.options = options;
let main = direction.split('-')[0],
around = direction.split('-')[1];
if (main === 'top' || main === 'bottom') {if (around === 'start') {result.left = left;} else if (around === 'end') {result.left = left + popoverWidth - contentWidth;} else if (around === 'middle') {result.left = left + popoverWidth / 2 - contentWidth / 2;}
if (main === 'top') {result.top = top - contentHeight - CONTANT;} else {result.top = top + popoverHeight + CONTANT;}
} else if (main === 'left' || main === 'right') {if (around === 'start') {result.top = top;} else if (around === 'end') {result.top = top + popoverHeight - contentHeight;} else if (around === 'middle') {result.top = top + popoverHeight / 2 - contentHeight / 2;}
if (main === 'left') {result.left = left - contentWidth - CONTANT;} else {result.left = left + popoverWidth + CONTANT;}
}
testDirection(result, options);
}
testDirection 检验算出来的值是否能够出现在用户的视野里面
思路
- 算出弹出框的四个角, 是否都在可视区之内, 是否有显示不全的.
- 比如说 left 为负数, 肯定有被遮挡的地方.
- 如果不符合要求, 就继续循环 list 里面的类型, 重新算定位的 left 与 top.
- 如果循环到最后都没有合适的, 那就用最后一个方案.
function testDirection(result, options) {let { left, top} = result,
width = document.documentElement.clientWidth,
height = document.documentElement.clientHeight;
if (
top < 0 ||
left < 0 ||
top + options.contentHeight > height ||
left + options.contentWidth > width
) {
// 还有可以循环的
if (options.myList.length) {options.direction = options.myList.shift();
getDirection(result, options);
} else {
// 实在不行就在父级身上
result.left = options.left;
result.right = options.right;
}
} else {result.show = true;}
}
dom 结构上要相应的加上对应的样式
这里的 click 一定不可以用 stop 修饰符, 会干扰用户的正常操作.
这里我们加上一个动画, 看起来渐隐渐现的有点美感.
<div class="cc-popover"
ref='popover'>
<!-- 不可以使用 stop 会阻止用户的操作 -->
<transition name='fade'>
<div v-if="init"
ref='content'
v-show='show'
class="cc-popover__content"
:class="options.direction"
:style="{ // 这里就是控制定位的关键
top:top+'px',
left:left+'px'
}">
<div class="cc-popover__box">
<slot name="content"> 请输入内容 </slot>
</div>
</div>
</transition>
<slot />
</div>
6. hover 状态
上面在 watch 里面也有体现了, 与 click 的区别就是, 绑定的事件不同
这里消失有 200 毫秒的延迟, 是因为用户离开目标元素, 可能是为了移入 popover 弹出框
// 移入
handleMouseEnter() {clearTimeout(this.time);
this.init = true;
this.show = true;
},
// 移出
handleMouseLeave() {clearTimeout(this.time);
this.time = setTimeout(() => {this.show = false;}, 200);
}
7. 定义 ’ 清除指令 ’ 与收尾工作
vue-cc-ui/src/components/Popover/main/index.js
思路
- 挂载 $clearPopover 命令, 执行效果是隐藏屏幕上所有的 popover 提示框
- 之前工作遇到这样一个情况, 有两张表单, 定位在一起, 一个在上面一个在下面, 结果切换的时候, 上一份表单的 popover 在第二份上面, 由此我根据需要一个全局的清理方法 .
- 监听 window 的滚动事件, 每次滚动把所有的 popover 都隐藏.
- 自定义指令 v-scroll-clear-popover, 放在某个元素上, 就会监听这个元素的滚动事件, 从而隐藏 popover 弹出框.
- 当然了, 这些监听滚动的方法, 都做了节流, 400 毫秒触发一次
import Popover from './popover.vue';
import prevent from '@/assets/js/prevent';
Popover.install = function(Vue) {Vue.component(Popover.name, Popover);
Vue.prototype.$clearPopover = function() {let ary = document.getElementsByClassName('cc-popover__content');
for (let i = 0; i < ary.length; i++) {ary[i].style.display = 'none';
}
};
// 监听指令
window.addEventListener('scroll',()=>{prevent(1,() => {Vue.prototype.$clearPopover()
},400);
},false)
Vue.directive('scroll-clear-popover', {
bind: el => {el.addEventListener('scroll', ()=>{prevent(1,() => {Vue.prototype.$clearPopover()
},400);
}, false);
}
});
};
export default Popover;
不要小看这个, 如果没有这个收尾工作, 也许内存都爆了.
移除所有事件, 删除 dom 元素
beforeDestroy() {let { popover, content} = this.$refs;
off(content, "mouseleave", this.handleMouseLeave);
off(popover, "mouseleave", this.handleMouseLeave);
off(content, "mouseenter", this.handleMouseEnter);
off(popover, "mouseenter", this.handleMouseEnter);
off(document, "click", this.close);
document.body.removeChild(content);
}
展示一下最终效果
end
大家都可以一起交流, 共同学习, 共同进步, 早日实现自我价值!!
下一集聊聊 ’ 日历组件 ’
工程 github 地址:github
个人技术博客(组件的官网): 技术博客