我们前端项目里用的组件库是 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进行对齐,依赖referenceOffsetoffset: 根据传入的options.offset对popper位置popperOffset进行微调preventOverflow: 调整popper的位置使得其不超出data.boundarieskeepTogether: 调整popper的位置使得其始终在reference旁边arrow: 用于显示popper的箭头,可能会对popper的位置进行调整,箭头元素的选择器是options.arrowElement。 最后放到data的属性有data.offsets.arrow和data.arrowElementflip: 当popper和reference的展示有重合时,将popper在相反反向重新展示,会重新设置popperOffsets和data.placementapplyStyle: 绘制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较好的解决了问题。