7.1 简单轮子:VuePopover组件
大纲链接 §
[toc]
popover组件 需求
- 可以激活
- hover
- click
- 激活后弹出提示
- 简单文本提示
- 复杂内容
- 两种形式
- 按钮在popover组件中
- 封装为指令,在按钮中使用
API设计
popover组件 UI
为什么需要单独的 popover 组件
|
|
- 需要让浮动消息出现在按钮正上方
- 提供给用户以封装好样式的popover 组件,不用另外写样式
基本结构
|
|
先实现VuePopover组件包裹按钮的方式
显示和隐藏提示消息
使用展示组件
Popovers.vue
|
|
VuePopover.vue
|
|
- 在
slot
上添加样式时无效的 - 需要
slot
在外边再包裹一层div
- 将
v-if="visible"
的控制逻辑页移至外层div
- 如此就可以加样式,使得弹出消息框的
div
可以定位
|
|
目前存在的 bug
- 未实现点击外边空白处,隐藏消息框
实现隐藏消息框
在
document.body
上添加监听click
事件
|
|
document.body.addEventListener('click', () => { this.visible = false; });
- 当点击外部,就隐藏消框
无法出现消息框 bug
- 由于事件冒泡机制
- 未在点击事件结束后就添加监听点击事件
- 会连续触发点击切换与隐藏消息框两个执行逻辑因此导致了 bug
加两句log来测试
|
|
- 连续触发 点击切换 与 隐藏消息框
- 先把
this.visible
变成true
,紧接着就把this.visible
变成false
- 弹出后立马关闭
- 显然不符合预期的需求
使用异步this.$nextTick
来控制执行顺序
|
|
另一个 bug
|
|
- 将
body
区域使用border
标识出来 - 当body未占满时,当点击下方非body区时,无法关闭消息框
- 解决方法时不监听
document.body
,而直接监听document
|
|
点三次点击的bug
- 点击按钮,出现消息框
- 点击外部空白,隐藏消息框
- 再次点击按钮,消息框未出现
原因
- 目前存在两个监听器,都在监听
click
事件- 第一个是按钮在监听用户点击
- 第二个是
document
上在监听点击 - 调用顺序是,先调用按钮的,在调用
document
的
- 而
document
上的监听事件未被移除掉,就在再次点击后添加了另一个点击事件监听 - 造成监听器越积越多
需要及时地销毁监听器
- 在
this.visible = false;
后就销毁监听器 - 需要一个具名函数,并且注意
this
的指向
尝试使用function x() {}.bind(this)
|
|
() => {}
相当于function x() {}.bind(this)
- 不改变
this
的指向 - 尝试失败
.bind
每次执行返回一个新的函数(堆内存中) - 事件添加的回调函数和(堆内存中) 删除的不是同一个函数
老老实实地声明
const eventHandler = () => {...}
|
|
还有几个 bug
- 改变节点
|
|
- 点击多次按钮切换时,再点击
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>...
|
|
如果组件在一个有着
overflow: hidden;
的样式的节点中
- 弹出消息框会被外层节点遮挡
- 应该将
popover
组件的弹出消息框的节点放在</body>
关闭标签前
@click.stop
阻止冒泡带来的 bug
- 用户使用
popover
组件时,无法使用监听组件外层点击事件 - 打断了用户的事件传播链
- 推翻使用
@click.stop
解决popover
的三个问题
overflow: hidden;
- 重复切换按钮多次,点击一次document,关闭重复n次
- 未取消监听 document
使用
ref
属性,获取到 消息框的节点,并打印在控制台
|
|
- 注意,当使用了条件渲染
v-if
时,未出现在文档流中的节点上的ref
无法获取 - 补充
v-show
V.S.v-if
的知识点v-show
只改变节点的样式v-if
会改变节点存在于DOM树与否
获取
this.$refs.contentWrapper
之后,就可以将获取到的节点添加到document.body
上
|
|
- 当改变 vue 中组件节点在文档中的位置时,并不影响组件的功能
- 包括点击事件执行回调依然有效
- 只是需要注意的是,
- 如果vue文件的
<style lang="scss" scoped>...
标签中如果写了scoped
属性,并且类样式互相嵌套 - 则被改变位置的元素不再具有该样式
- 如果vue文件的
- 解决方法是将该类样式提出到嵌套最外边
|
|
不在组件挂载一完成时,就将消息框添节点加到
document.body
上
|
|
- 而是等到当满足条件
this.visible === true
显示时,再把消息框添节点加到document.body
上 - 注意刚
this.visible === true
时,节点并未出现在文档中,需要使用this.$nextTick
- 这样就不用担心被外层
css
样式设置的overflow: hidden;
遮住 - 接下来只需获取按钮元素的位置,据此改变定位样式,让节点出现在按钮上方
用JS获取按钮元素的位置
- 同样地在
slot
上方加ref
引用属性是无效的 - 需要再包裹一层节点,在外层节点上添加
ref
引用
|
|
使用Element.getBoundingClientRect() API
获取元素尺寸与位置
|
|
- 将得到的值赋给
(this.$refs.contentWrapper as any).style.left
(this.$refs.contentWrapper as any).style.top
- 并且调整一下样式
transform: translateY(100%);
解决容器有overflow hidden
的bug
测试poopover
|
|
VuePopover.vue
|
|
- 注意相对的位置距离是不同的
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
不同
判断点击事件目标对象
目标对象是按钮,还是弹出消息框
|
|
- 实现点击按钮切换,点击消息框不切换的逻辑分开
- 判断点击的目标对象
event.target
- 是否包含在
this.$refs.triggerWrapper
中 - 即是否点击了按钮
this.$refs.triggerWrapper.contains(event.target)
- 是否包含在
- 当 弹出框 显示
- 将弹出框节点放到 body 子节点的最后
- 改变弹出框样式,使其出现在相对按钮合适的位置
当 popover 显示时 执行的逻辑
|
|
- 点击按钮 执行的方法:切换显示/隐藏 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);
- 显示 弹出框,将弹出框节点放到 body 子节点的最后
- 当 popover 隐藏时 执行的逻辑
- 切换显示/隐藏 popover
- 点击popover部分 执行的逻辑
- 点击按钮部分 执行的逻辑
- 重构 抽象提出
positionPop
listenToDocument
方法
|
|
在不阻止冒泡的条件下,判断事件的回调函数的目标对象
- 重构 抽象提出
closeHandler
closeEvent
方法 - 如果目标对象 存在于 包裹弹出框的div中,则什么也不做
- 如果目标对象 不存在于 包裹弹出框的div中,则
- 隐藏 popover弹出框
this.visible = false;
- 移除 事件监听
document.removeEventListener('click', eventHandler);
- 隐藏 popover弹出框
this.listenToDocument()
还是会在点击事件冒泡完成之前执行- 必须使用
setTimeout
来延迟,使 添加监听在 点击事件冒泡 之后 执行
- 必须使用
|
|
@Watch('visible')
监听数据this.visible
的变化visible
重构改成isVisible
解决重复叠加监听与重复多次移除监听的bug
是否可以在
created
时,执行this.listenToDocument()
- 不可
- 当页面中有多个
popover
组件时,每次创建就添加监听会造成不必要的性能浪费
- 当页面中有多个
- 而监听事件正确的做法是 即用即添,用完即毁
- 当点击弹出一个消息框,就添加一个监听
- 当消息框关闭时,立即清除
收拢关闭操作的入口
- 高内聚
- 抽象方法
- 将多个表达相同逻辑的代码抽象成一个方法,在处理该逻辑的时候调用这个方法
- 实例代码的
closeEvent()
,将收尾的逻辑聚拢到一个方法中,有两个操作- 关闭显示
this.isVisible = false;
- 移除监听
this.rmListenerToDocument();
- 关闭显示
|
|
- 设置触发部分
<span class="triggerWrapper" ref="triggerWrapper">...
的样式- 将默认行内显示的
<span>
标签添加样式display: inline-block;
- 将默认行内显示的
小结 popover 的三个问题
overflow: hidden;
由body.appendChild
解决- 处理
window.scrollX
和window.scrollY
滚动偏移量
- 处理
- 关闭重复n次,分开处理逻辑
- 职责明确
document
只管外面popover
只管里面
- 未取消监听 document,由收拢
close
的逻辑解决
添加支持四个方位功能
添加消息框的三角指向样式
MDN filter: drop-shadow()
代替box-shadow
- 需要配合
background-color: white;
- 需要配合
添加消息框的四个方向 的外部数据
可以看到,去掉了
transition
和margin-top
属性的popover
和按钮的左上角是对齐的
区分位置样式
|
|
使用表驱动编程重构
- 创建一张表
const positionList = {...}
分别对应四个位置'position-top': {...},
'position-bottom': {...},
'position-left': {...},
'position-right': {...},
- 设置每个位置的
top
和left
属性 - 提取重复代码
(contentWrapper as HTMLElement).style.top = ...
(contentWrapper as HTMLElement).style.left = ...
- 去除
if
的判断,简化逻辑为一一对应
VuePopover.vue
|
|
抽出部分
VuePopover.scss
|
|
动态监听事件vue2.6+
- 动态绑定事件
@[event]="eventFn"
v-on="{[popUp]: [openEvent, clearTimer], [popDown]: startTimer}"
- 一次绑定多个事件
v-on="{mouseover: this.clearTimer, mouseout: this.startTimer}"
- 可使用计算属性简化为
v-on="multiEvent"
- 不可使用缩写
@
|
|
添加支持可选的click
和hover
两种触发popover
方式
Popovers.vue
添加外部数据trigger
默认为hover
|
|
VuePopover.vue
|
|
在弹出框中加上按钮,并且传递属性参数
在弹出框加上关闭按钮,并将组件中的关闭方法传给按钮,使按钮可以调用组件传来的 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
|
|
<slot name="content" :closeEvent="closeEvent"></slot>
- 在具名插槽
content
中绑定数据:closeEvent="closeEvent"
Popovers.vue
展示组件
|
|
<template #content="{closeEvent}">...</template>
- 解构数据
#content="{closeEvent}"
- 使用解构出的方法
<VueButton @click="closeEvent">关闭</VueButton>
- 解构数据
参考
封装为指令
|
|
参考
- 代码链接:popover.vue