尝试使用JS IntersectionObserver让标题和导航联动

这篇文章发布于 2020年12月1日,星期二,01:05,归类于 JS API, JS实例。 阅读 25137 次, 今日 7 次 12 条评论

 

南瓜封面图

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

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

一、实现怎样的效果?

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

比方说我的博客文章:

导航截图示意

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

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

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

entries.reverse().forEach(function (entry) {
    if (entry.isIntersecting) {
        entry.target.active();  
    } else if (entry.target.isActived) {
        entry.target.unactive();  
    }
});

也就是如果当前元素本身是激活的,那他移出屏幕则就要失活,让其他元素高亮(这个逻辑在unactive()方法中完成)。

核心思路没问题,其他就是细节上的修修补补,如元素创建、点击导航元素的定位等,相关细节就不展开了。

最终效果实现后一体验,耶耶耶,勒欧勒欧勒,之前有元素不选中的问题没有了,至少在目前这个demo中没有了,您可以狠狠地点击这里:IntersectionObserver自动生成导航demo

手机端不方便体验了也可以看下面的部分区域的GIF录屏效果(点击播放 189K):

滚动定位录屏GIF

这一版的实现完全没有用到scroll事件,完成了自己的初衷,真正体会到了,柳暗花明又一村的感觉。

四、使用、语法以及注意事项

使用方法如下。

首先引入JS文件:

<script src="./smart-nav.js"></script>

JS文件在这里:smart-nav.js

然后使用,例如:

smartNav('article h3');

会把符合选择器'article h3'的元素聚合成和快速定位的导航元素。

语法

smartNav(elements, options);

其中:

elements
必需。标题元素们,可以是Nodelist对象,也可以是元素的选择器字符串。
options
可选。目前支持一个参数如下表:
API名称 默认值 释义
nav null 导航容器对象,创建的导航列表会append到这里。如果为null,本JS会自动创建一个容器元素,结构为:div.title-nav-ul > a.title-nav-li

也就是导航样式需要自己CSS设置。

其他说明

本JS是自己以学习目的为主的实验产品,没有考虑兼容性,由于采用了比较新颖的原理实现,因此,不能保证交互功能的稳健性,也就是可能有预期之外的表现。

所以,建议大家不要在正式的项目中使用。

为了方便大家学习,我把这个JS以项目形式开源到gitee了:https://gitee.com/zhangxinxu/smart-nav

smart-nav项目截图

欢迎反馈其中实现的问题,或者你有更好的实现也欢迎指出。

五、意义、结束语

虽然说折腾了1个下午+两个晚上,就折腾出了只能在个人项目、实验项目中使用的代码,看起来做了一件投入产出比很低的事情。如果把这么多时间用来去外面接个活,明面上赚得肯定比瞎折腾赚得多。

实际上,非也!

从长远来看,权衡各种收益,瞎折腾觉得赚的更多,他只是在未来,或当下,以一种更加无形的方式反馈给了你。

专业成长了,对IntersectionObserver这个API有了更加直观且深入的接触,下一次使用的时候就能快速上手;有了产出,不仅有代码,还有文档,都会作为个人影响力,或者说对行业的价值,在日后的工作、或者其他事务中以更隐蔽的方式馈赠给你。

例如,很多人买我写的《CSS世界》和《CSS选择器》世界,就会受到我博客日积月累写作的影响,无形中增加了自己的收益。

所以,走专业路线,千万不要有功利的思想。

我学的这个东西以后会不会火,有没有用,能不能赚钱。基本上,有这种心态的人,都不适合走专业路线。

大家有兴趣可以看看我最近20篇文章,一半的文章是基础API介绍,另外一半全我都是自创的技术实现,有些源自项目,有些源自灵感。

所有这一切都是源自于每天不间断的基础知识积累,积累的足够多,自然能够触类旁通,想法就会层出不穷。

所以,这几天的折腾有没有意义呢?

肯定是有的!

说不定哪天在其他场合又会触发不一样的花火。

花火

OK,又凌晨1点了,就不继续唠嗑了。

感谢您坚持阅读到此处,如果您觉得看这篇文章还算有点收获,欢迎转发,欢迎分享,比心!

(本篇完)

分享到:


发表评论(目前12 条评论)

  1. Steven说道:

    被观察的元素区域(content+padding+border)出现在容器区域(content)的比例达到阀值就会触发回调。

  2. silence说道:

    监控的是title,但实际项目应该是监控包含title的容器

  3. Zachary0476说道:

    你好:

    感谢分享;
    纠正前文的一个笔误:
    zxxObserver.observer is not a function. 应该是zxxObserver.observe,望及时修复。

  4. mfk说道:

    你那demo还是有些问题。往上滚动是正常的。但是从上往下有些问题。

    比如当窗口高度有限时,标题2在屏幕上方不可见区,标题3在屏幕下方不可见区,此时屏幕中的内容全部都是属于标题2的。但是你那导航是高亮的是标题3。

    因为你认为标题2已经不见了,所以把它unactive了。

  5. 虾米的空空说道:

    试了好久发现thresholds无效,其实是threshold,文章里写错了

  6. kaviar说道:

    IntersectionObserver自动生成导航demo还是有问题,
    譬如说我快速切换
    点一次标题二,然后快速点击三或者四再点回标题二,会闪动一下标题一的高亮

  7. 小老弟说道:

    “在脑中遍历自己的知识储备”
    妈耶,这都能遍历,大佬?

  8. 王大锤说道:

    大佬,guthub上发现gpu.js插件好像很强的样子,但是中文科普文章不多,大佬有兴趣科普下吗?

  9. jcomey说道:

    2010年11月最后1天!你穿越了 老张!

  10. Sinosaurus说道:

    让我对 `observer` 类的有了进一步了解,得找个时间来实验一下了

  11. du说道:

    说的非常好,尤其最后一段话说的很有深意,感谢大佬