我们前端项目里用的组件库是 vue 的element-ui
,有个地方用到了popover组件,一个列表中每个列表项在 hover 时都会在popover
中展示提示语。这里就出现了一个优化问题:当快速滚动列表项时,即使某个列表项已经隐藏了,可是对应的popover
需要过一会才能消失,体验不大好,示范如下:
可以看到很多popover
都是到了视口顶部才消失,使用的示范代码如下:
1 | <div class="hover-wrapper" ref="hoverWrapper"> |
1 | .hover-wrapper { |
这篇文章主要是找到为什么会出现这种现象,以及如何解决它。
思路 1: transition
查看popover
组件的源码发现其出现及消失都是有一段transition
动画的:
1 | <transition :name="transition"> |
默认的transition
是fade-in-linear
,它具体的定义位于packages/theme-chalk/src/common/transition.scss
:
1 | .fade-in-linear-enter-active, |
popover
的出现消失都会有200ms
的渐变,那会不会是它的原因呢? popover
支持定制transition
动画,我们可以写一个自己的动画,将消失的时长变为 0,看看问题是否还存在?
我们的自定义动画名姑且就叫my-fade-in-linear
:
1 | .my-fade-in-linear-enter-active { |
将其传给popover
:
1 | <vi-popover transition="my-fade-in-linear"> </vi-popover> |
很难受,问题依然存在,只能看看源码怎么写的。
思路 2: popover
组件层级控制
研究了一会发现popover
的显隐是由宿主的mouseenter
、mouseleave
控制的:
1 | if (this.trigger === 'hover') { |
奇怪的是handleMouseLeave
中隐藏popover
设置了一个200ms
定时器:
1 | handleMouseLeave() { |
暂且不管_timer
是因为什么而设置的,我们尝试在定时器之外设置this.showPopper
看看问题是否还在。
1 | handleMouseLeave() { |
很不幸,还是不行。。。。。
再次仔细观察发现:在 hover
到某个列表项然后快速滚动时,其实并没有立即切换到另一个表单项,而是等到 hover
到另一个列表项后,前一个被 hover
的列表项才能取消 hover
状态,这就导致handleMouseLeave
其实很久后才真正触发。慢动作如下:
这就表明从popover
组件自身是没有办法解决这个问题的,需要更深入的研究其底层所用的库,也就是Popper.js
Popper.js
是一个很有名的管理弹出内容的小巧库,在github
上有 11000+的 star,并且其体积很小,压缩混淆且 gzip 后只有大约 6kb。
思路 3:设置 boundariesElement
Popper.js
在 element 多个组件中都有用到,最常见的就是popover
和tooltip
这俩了:
我们研究Popper.js
的主要目的是:尝试找到一个方法,在宿主 reference 元素或者 popper 内容滚动出其父级滚动区时,隐藏 popper。
在初步翻越其官网文档后,发现有一个boundariesElement
配置项,看名字是指向popper
的边界,默认的配置是viewport
。因为Popper.js
会小心控制其弹出内容不超出边界,必要时会调整popper
的位置,示范如下:
如果我们显式设置boundariesElement
为我们的滚动父级元素,那么至少popper
不会飞到视口顶部才消失,也算是用一种蹩脚的方法解决了问题。
我们的测试代码如下:
1 | this.popperOptions = { |
1 | <vi-popover :popperOptions="popperOptions"></vi-popover> |
很遗憾,这次连popper
内容都出现了问题:
看起来是定位出现了很大问题,只能硬着头皮将Popper.js
源码撸一遍,看看 popper 的具体展示逻辑,所幸没有想象中那么复杂。
Popper.js 源码解析
还是喜欢从入口着手,先看看构造函数:
构造函数
1 | function Popper(reference, popper, options) { |
_getPosition
用于获取popper
的position
属性,先看offsetParent
的定位属性,如果它为fixed
定位则返回fixed
,否则返回absolute
.
offsetParent
是一个只读属性,返回一个指向最近的包含该元素的定位元素. 如果没有定位的元素,则 offsetParent
为最近的 table
、table cell
或根元素(标准模式下为 html
;quirks
模式下为 body
)。注意当元素的 style.display
设置为 "none"
时,offsetParent
返回 null
。
setStyle
用于设置元素的内联样式,也就是style
对象属性。
_setupEventListeners
用于注册滚动父级元素的scroll
事件,监听函数为this.update
。
所以综上构造函数主要做了以下几件事:
- 归一化
popper
和reference
元素 - 获取归一化
options
- 获取
modifiers
函数,可以传入自定义的modifier
函数,参数为data
对象(参考update
函数中的data
定义) - 获取
popper
的position
属性:fixed
或absolute
- 设置初始内联
style
- 注册父级滚动元素的
scroll
事件,监听函数为update
update 函数
update
函数最核心的作用是更新 popper 的位置。
1 | Popper.prototype.update = function() { |
这里出现的 3 个函数都非常重要,关系到最终popper
在哪里展示,我们依次来看。
_getOffsets
用于获取popper
和reference
的位置信息,最后放入data.offsets
属性。这个位置信息可能在之后被modifiers
进行一些调整,后面会说到。
1 | Popper.prototype._getOffsets = function(popper, reference, placement) { |
函数的大致逻辑是先计算出reference
元素的位置及大小、popper
的大小,然后根据placement
来计算popper
的位置。placement
可以有多种枚举值.
getOuterSizes
它用于计算一个元素带上margin
的尺寸。在目标元素display:none
时运用了一点小技巧,具体是先将目标元素展示出来,然后通过获取某个位置属性强制浏览器重绘,在重绘后再计算尺寸信息:
1 | function getOuterSizes(element) { |
上面可以看出referenceOffsets
的计算是最核心的步骤,我们仔细看看。
getOffsetRectRelativeToCustomParent
用于根据一个元素以及它的某个父元素,计算相对于父元素的offset
位置以及元素自身大小。
1 | function getOffsetRectRelativeToCustomParent(element, parent, fixed) { |
利用getBoundingClientRect
计算出两个元素相对于视口的位置和大小,然后进行简单的加减即可。getScrollParent
用于找到滚动父级元素,也就是获取最近的overflow
属性为auto
或scroll
的父节点,直到body
或document
,具体就不细说了。
可以看出来getOffsetRectRelativeToCustomParent
计算的是相对位置,不会有什么问题。问题在于传递的parent
参数,它的值是getOffsetParent(popper)
,问题在哪里呢?
上面也说到offsetParent
当元素的 style.display
设置为 "none"
时会返回 null
,此时getOffsetParent
函数的返回值是document
,而我们的popper
在未展示前始终是隐藏的。这就意味着:我们计算的 referenceOffsets 和 popperOffsets 始终是相对于document
的,而非设置的boundariesElement
,这给后面 popper
的展示埋下了祸根。
_getBoundaries
说完了data.offset
的计算,接下来就是_getBoundaries
,用于计算boundariesElement
的位置及大小:
1 | Popper.prototype._getBoundaries = function(data, padding, boundariesElement) { |
看得出来,boundariesElement
可以有 3 种值: window
、viewport
和指定的HTMLElement
,前两种情况我们省略留给大家自己去看。通过前面的描述,在指定boundariesElement
时getOffsetParent(this._popper) === boundariesElement
也不会成立,所以最终boundaries
的值就是由getOffsetRect
计算得来:
1 | function getOffsetRect(element) { |
HTMLElement.offsetTop
为只读属性,它返回当前元素相对于其 offsetParent
元素的顶部的距离,我们的boundariesElement
通常并不是display:none
的,也通常会有一个非document
的offsetParent
,所以最终data.boundaries
和data.offset
的参考坐标是不一样的。
最后update
函数会调用runModifiers
来运行各种modifiers
函数.
modifiers
内置的modifier
主要作用是对popper
位置进行微调,具体可以参考官方文档。
options.modifiers
是有先后顺序的,数组前面的modifier
会先执行,所以如果某个modifier
有依赖项,依赖项就必须放到前面先执行。applyStyle
这个特殊的modifier
通常总是放在最后,因为它是用来绘制popper
的。 总体来说modifiers
机制类似于pipeline
。
runModifiers
很简单就是从前往后依次执行给定的modifiers
:
1 | Popper.prototype.runModifiers = function(data, modifiers, ends) { |
有一点需要注意的,我们可以在options
传入自定的modifier
函数,内置的modifier
可以只传枚举字符串,例如
1 | this.popperOptions = { |
在构造函数中会将其转换为具体的函数:
1 | this._options.modifiers = this._options.modifiers.map( |
modifier
函数接收一个data
对象参数,并在最后将data
返回,中间可以对这个参数做任意处理,data
是在update
函数中进行初始化的。这里对几个内置modifier
的原理做一个介绍,并解析其中一个的具体实现:
shift
: 根据形如top-start
的placement
调整popper
位置与reference
进行对齐,依赖referenceOffset
offset
: 根据传入的options.offset
对popper
位置popperOffset
进行微调preventOverflow
: 调整popper
的位置使得其不超出data.boundaries
keepTogether
: 调整popper
的位置使得其始终在reference
旁边arrow
: 用于显示popper
的箭头,可能会对popper
的位置进行调整,箭头元素的选择器是options.arrowElement
。 最后放到data
的属性有data.offsets.arrow
和data.arrowElement
flip
: 当popper
和reference
的展示有重合时,将popper
在相反反向重新展示,会重新设置popperOffsets
和data.placement
applyStyle
: 绘制popper
和arrow
到页面上,会尝试使用transform3d
进行GPU
加速。 注意这里没有设置popper
的display
属性
我们看看preventOverflow
的原理:
1 | Popper.prototype.modifiers.preventOverflow = function(data) { |
所有其他modifier
的作用发挥都基于reference
、popper
和boundariesElement
的位置和大小。
综上,update
函数做了以下几件事:
- 获取
data
属性,并最终传递给每个modifier
函数 - 获取
popper
和reference
的位置信息,放入data.offsets
属性 - 获取
boundariesElement
位置和尺寸,放入data.boundaries
- 依次运行每个
options.modifier
函数
为什么设置boundariesElement
不行?
现在可以回答为什么指定boundariesElement
不行了,从上面的分析可以看到:boundariesElement
和reference
、popper
的参考坐标是不一样的,前者是一个非document
元素,后两个都是基于document
,除非boundariesElement
的offsetParent
恰好就是document
元素,我们只能另辟蹊径了。
思路 4: IntersectionObserver
其实我们是想知道列表项何时隐藏在滚动区,当隐藏时再隐藏对应的popover
。 当然可以监听滚动区的scroll
事件然后判断每个列表项是否处于滚动区,scroll
事件很容易出现性能问题,现在我们有一个更好的选择:IntersectionObserver
,它提供了一种可以异步监听目标元素与其祖先或视窗(viewport)交叉状态的手段。
IntersectionObserver
的实现,应该采用requestIdleCallback()
,即只有线程空闲下来,才会执行观察器。这意味着,这个观察器的优先级非常低,只在其他任务执行完,浏览器有了空闲才会执行。
基于这个api
,我们可以很容易的就能达到目标,只需用一种方式记录每个列表项对应的popover
是哪个,这里我采用的是data-set
,使用一个叫data-index
的属性来记录。具体代码如下:
1 | <div class="hover-wrapper" ref="hoverWrapper"> |
1 | watchPopover() { |
最终效果如下:
总结
本文针对popover
组件在快速滚动列表项中的问题进行了优化,在解决问题的同时解析了Popper.js
的源码,并在最后利用IntersectionObserver
较好的解决了问题。