突然有了新的想法怎么办?

窗外阳光明媚,下了一个星期的雨,终于放晴了,所以,很显然,代码撸起来,才对得起这个周末,嘿嘿嘿。

一、实现怎样的效果?

一般文档页面都比较长,为了方便定位,都会基于标题生成一个导航。

比方说我的博客文章:

导航截图示意

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的兼容性稍稍滞后了一些,具体参见下截图。

Intersection Observer兼容性

使用套路很简单,如下:

var zxxObserver = new IntersectionObserver(function (entries) {

entries.forEach(function (entry) {
    if (entry.isIntersecting) {
        // entry.target元素进入区域了
    }
});

});
// 观察元素1,2,...
zxxObserver.observe(ele1);
zxxObserver.observe(ele2);
...

用文字解释下就是这两步:

  1. 定义元素交叉后干嘛干嘛;
  2. 需要观察那些元素;

实际开发的时候,主要工作就是对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项无法高亮

底部最后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是相交变化的时候才触发。

这个问题好办,我立刻就有了思路,在元素移出屏幕的时候做一个去高亮处理。

于是核心代码就变成这样:


虾米的空空
1 声望0 粉丝