一、实现怎样的效果?
一般文档页面都比较长,为了方便定位,都会基于标题生成一个导航。
比方说我的博客文章:
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是相交变化的时候才触发。
这个问题好办,我立刻就有了思路,在元素移出屏幕的时候做一个去高亮处理。
于是核心代码就变成这样:
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):
这一版的实现完全没有用到scroll事件,完成了自己的初衷,真正体会到了,柳暗花明又一村的感觉。
四、使用、语法以及注意事项
使用方法如下。
首先引入JS文件:
<script src="./smart-nav.js"></script>
JS文件在这里:smart-nav.js
然后使用,例如:
smartNav('article h3');
会把符合选择器'article h3'
的元素聚合成和快速定位的导航元素。
语法
smartNav(elements, options);
其中:
API名称 | 默认值 | 释义 |
---|---|---|
nav | null | 导航容器对象,创建的导航列表会append到这里。如果为null,本JS会自动创建一个容器元素,结构为:div.title-nav-ul > a.title-nav-li |
elements
必需。标题元素们,可以是Nodelist对象,也可以是元素的选择器字符串。
options
可选。目前支持一个参数如下表:
也就是导航样式需要自己CSS设置。
其他说明
本JS是自己以学习目的为主的实验产品,没有考虑兼容性,由于采用了比较新颖的原理实现,因此,不能保证交互功能的稳健性,也就是可能有预期之外的表现。
所以,建议大家不要在正式的项目中使用。
为了方便大家学习,我把这个JS以项目形式开源到gitee了:https://gitee.com/zhangxinxu/smart-nav
欢迎反馈其中实现的问题,或者你有更好的实现也欢迎指出。
五、意义、结束语
虽然说折腾了1个下午+两个晚上,就折腾出了只能在个人项目、实验项目中使用的代码,看起来做了一件投入产出比很低的事情。如果把这么多时间用来去外面接个活,明面上赚得肯定比瞎折腾赚得多。
实际上,非也!
从长远来看,权衡各种收益,瞎折腾觉得赚的更多,他只是在未来,或当下,以一种更加无形的方式反馈给了你。
专业成长了,对IntersectionObserver这个API有了更加直观且深入的接触,下一次使用的时候就能快速上手;有了产出,不仅有代码,还有文档,都会作为个人影响力,或者说对行业的价值,在日后的工作、或者其他事务中以更隐蔽的方式馈赠给你。
例如,很多人买我写的《CSS世界》和《CSS选择器》世界,就会受到我博客日积月累写作的影响,无形中增加了自己的收益。
所以,走专业路线,千万不要有功利的思想。
我学的这个东西以后会不会火,有没有用,能不能赚钱。基本上,有这种心态的人,都不适合走专业路线。
大家有兴趣可以看看我最近20篇文章,一半的文章是基础API介绍,另外一半全我都是自创的技术实现,有些源自项目,有些源自灵感。
所有这一切都是源自于每天不间断的基础知识积累,积累的足够多,自然能够触类旁通,想法就会层出不穷。
所以,这几天的折腾有没有意义呢?
肯定是有的!
说不定哪天在其他场合又会触发不一样的花火。
OK,又凌晨1点了,就不继续唠嗑了。
感谢您坚持阅读到此处,如果您觉得看这篇文章还算有点收获,欢迎转发,欢迎分享,比心!