突然有了新的想法怎么办?
窗外阳光明媚,下了一个星期的雨,终于放晴了,所以,很显然,代码撸起来,才对得起这个周末,嘿嘿嘿。
一、实现怎样的效果?
一般文档页面都比较长,为了方便定位,都会基于标题生成一个导航。
比方说我的博客文章:
Ant.design或者Vue.js的文档页均有类似的功能:
以前我写过一个jQuery小插件实现此功能,详见:“jQuery小插件titleNav.js”。
后来也写过原生JS写过此功能,IE9+都支持,监听滚动事件,判断没有标题元素和滚动窗体位置,谁位置最近就哪个高亮,就是本博客目前使用的代码,滚动的时候实时计算每一个标题元素的位置,代码啰嗦,性能也一般般。
今天我在整Mobilebone新的文档,又需要实现类似的交互效果。
我就琢磨着,有没有更简单的实现方法,不需要实时计算,就知道该高亮哪一个导航元素。
于是就在脑中遍历自己的知识储备,然后有个API浮出了水面,这个API就是IntersectionObserver,可以观察元素和窗体的交叉情况。
貌似有戏。
二、IntersectionObserver是什么?
web领域有很多的Observer,俗称观察器,就是可以实时反馈网页的某些交互变化。
例如Mutation Observer,可以观察DOM元素的增删以及属性变化,可参见“聊聊JS DOM变化的监听检测与应用”一文;又例如Resize Observer,可以观察元素的尺寸变化,可参见“JS ResizeObserver API简介”一文。
这里要介绍的Intersection Observer是观察元素和窗体相交的状态,非常适合用在与滚动相关的交互事件中。
例如图片的懒加载效果,或者是无限滚动加载效果等,V1版本规范的兼容性还是很不错的,移动端几乎可以放心使用,恩……2年之后几乎可以放心使用,iOS的兼容性稍稍滞后了一些,具体参见下截图。
使用套路很简单,如下:
var zxxObserver = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
// entry.target元素进入区域了
}
});
});
// 观察元素1,2,...
zxxObserver.observe(ele1);
zxxObserver.observe(ele2);
...
用文字解释下就是这两步:
- 定义元素交叉后干嘛干嘛;
- 需要观察那些元素;
实际开发的时候,主要工作就是对entries.forEach
这部分的代码进行处理。
其中,entry
对象包括以下这些参数:
entry.boundingClientRect
当前观察元素的矩形区域,top/right/bottom/left属性可以获得此时相对视区的距离,width/height属性包含尺寸。此属性和Element.getBoundingClientRect()
这个API方法非常类似。
entry.intersectionRatio
当前元素被交叉的比例。比例要想非常详细,需要IntersectionObserver()
函数的第2个可选参数中设置thresholds
参数,也就是设置触发交叉事件的阈值。
entry.intersectionRect
和视区交叉的矩形大小。
entry.isIntersecting
如果是true,则表示元素从视区外进入视区内。
entry.rootBounds
窗体根元素的矩形区域对象。
entry.target
当前交叉的元素。
entry.time
当前时间戳。
在本例中,主要使用entry.isIntersecting
,表示当前元素和目标区域交叉了。
三、具体实现过程记录
假设我们需要观察的标题元素都是<h3>
元素,则代码可以这么处理:
var zxxObserver = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
// active()是一个自定义的高亮方法
entry.target.active();
}
});
});
// 观察标题元素
document.querySelectorAll('h3').forEach(function (ele) {
zxxObserver.observe(ele);
});
这样,标题元素进去视区的时候就会高亮。
但是上面代码实现的最终效果有些迟钝,例如页面一屏中同时有多个标题元素,那么中间的标题元素的高亮就会被跳过(一次只能高亮一个元素)。
最好是标题元素进入屏幕中间区域时候才触发交叉检测。
有办法的,可以使用IntersectionObserver()
函数的第2个可选参数实现。
new IntersectionObserver(callback, option);
也就是这里的option
可选参数。
支持下面这些属性值:
root
用来交叉检测的根元素,默认是浏览器窗体元素。
rootMargin
检测区域的偏移。支持1-4个值,和margin属性表示的方位含义是一模一样的。但是正负值的含义却是不同的,我今天就被这一点给坑了。例如一个元素设置margin:100px
,其自身区域大小只会小100px,但是rootMargin
参数却不同,正数值是增大视区的检测区域,负值反而是减小。
thresholds
触发callback
函数执行的阈值,是个数组,例如[0.00, 0.01, 0.02, ..., 0.99, 1.00]
,则交叉面积从1%都100%都会触发callback
,默认只会在100%的时候触发一次。此参数支持function类型,返回对应的数组即可。
回到本文案例,因此,如果希望交叉检测区域就是浏览器窗体中间这部分,可以使用rootMargin
参数,相关代码如下所示:
var zxxObserver = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (entry.isIntersecting) {
entry.target.active();
}
});
}, {
rootMargin: '-33% 0% -33% 0%'
});
噢耶,赶快run一下,看看实现的效果。
结果呵呵哒,发现第1个导航元素无法高亮,因为上方已经没有足够的空间让第1个标题元素进入页面中间1/3区域。
底部最后1个导航元素也会遇到类似的问题。
我整个人就不好了,琢磨着有没有什么办法检测到首个元素和最后一个元素的位置,然后专门处理下,一番脑细胞消耗,发现不行,通过内置某些隐藏元素扩大标题元素面积的做法不具有可复制性。
或者滚动容器内创建一个等高的0宽元素,配合thresholds参数,这样可以实时感知滚动行为的发生,于是就有能力进行非常细致的处理,但是这样做本质上就和滚动事件没什么区别了。
心有不甘,不想使用scroll事件,最后……还是妥协使用了scroll事件实现了部分功能,和传统的滚动交互实现一样,让容器滚动到顶部一定是第1个元素高亮,滚动到底部,一定是最后1个元素高亮,相关代码如下所示:
window.addEventListener('scroll', function () {
var root = document.scrollingElement;
if (root.scrollTop == 0) {
elements[0].active();
} else if (root.scrollTop + root.clientHeight > root.scrollHeight - 1) {
elements[elements.length - 1].active();
}
});
然而,事情并没有想的那么顺利,虽然滚动的起止位置的高亮没问题了,但是又出现了其他的问题,理论上,应该是标题2高亮,现在滚动高度为0的时候强制标题1高亮,此时,再滚动,是不会触发标题2的交叉行为的,因此标题2一直无法高亮。
然后通过设置一个冗余标志量的方式修复了这个问题,基本上体验下来还行,但偶尔还是有标题跳过的问题,伤心……
我一看时间,已经凌晨1点了,答应家里的领导12:30睡觉的,于是,先放着,关机睡觉。
时间来到了今天,周一,20102020年11月最后1天,下班回家,又打开昨天写的代码,琢磨着这歪瓜裂枣的代码、以及那个不得已用上的滚动事件代码是不是可以优化的,有没有办法纯粹通过交叉检测实现。
刷刷微博喂喂鱼,看似在游荡,实际上在找灵感。
反问了自己一个问题,“为什么我后来折腾了那么多鬼东西?”
这个问题很好回答:“一开始的时候标题1应该高亮,但是高亮了标题2,应该2者默认都在屏幕内,由于一次只能高亮一个,因此,处在后面的标题2高亮了。”
这个时候,我脑中突然擦出了一点火花,这小手啊,就像不受控制一样,还原到最初不设置rootMargin
参数的状态,然后在entries.forEach
中间加了个小小的reverse()
,如下截图:
然后再一体验,我了个擦,这不八九不离十了嘛,正向滚动基本符合预期,至少一进来的时候,或者滚动到顶部的时候高亮的是第1个标题元素。
但是,又有新的问题,当标题1在屏幕之外,但是标题2在屏幕内的时候,标题2并没有高亮,因为标题2一直在屏幕中;也就是,当标题1、标题2同时在屏幕中,标题1滚走的时候,标题2是不会触发entry.isIntersecting
的,因为IntersectionObserver API中的callback是相交变化的时候才触发。
这个问题好办,我立刻就有了思路,在元素移出屏幕的时候做一个去高亮处理。
于是核心代码就变成这样:
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。