实现 Resize Observer Polyfill 的有趣之处
目录
- A Primer on Element Resize
- overflow & underflow
- 大大大
- 小一点
- bomb!撞在一起
- 你看的见我,又看不见我了,偷偷摸摸搞点事情
- ResizeObserver
- 还有别的办法么?
- 结语
- Refs
A Primer on Element Resize
监听元素尺寸的变化一直以来,都是一个很常见的需求,但是又不那么容易去实现。因为浏览器都有实现针对窗口变化的监听,而却没有针对元素变化的监听。这个常常发生在一些内部元素大小变化的情况。
比如飞书 Admin 的管理页面,当左侧侧边栏收起或者展开的时候,右侧的宽度会发生变化,而浏览器的窗口并没有变化。
如果我们从实际角度出发,监听元素的变动其实大部分是为了监听因为窗口变化而导致的大小变化,此时最简单的方案就是直接监听浏览器窗口的 resize 事件,这个就不细说了。
其次针对上文说道的情况,社区有很多的实现方案,但很多基于 JQuery 实现的,而且性能较差,因为是使用计数器去拉取元素宽度。不过会有很多优化方案,比如当点击某些可能会导致宽度发生变化的时候才启动定时器去检查,并且检查一段时间发现没有变化的时候,就停止检查。但是不管怎样,性能都是不太好的。
overflow
& underflow
于是,社区中有另外一种检测方案,那就是基于事件检测,用的就是 overflow
和 underflow
事件。不知道这两个事件是什么的,可以去看 MDN 或者这篇博客。
简单来讲,就是当内容超出外部容器的时候,会触发 overflow
事件,当内容又小于容器宽度的时候,会触发 underflow
事件。
那怎么检测呢?也很简单,先从扩大变化检测说起
假设有这样一个容器,长度宽度为 100px,内部有一个元素,长宽分别为 101px,也就是比父容器各大 1px。那么如果你将外部容器扩大的一瞬间,长宽至少会增加一个像素,也就是说至少比内部元素大,≥ 101px,此时会触发 underflow
事件。扩大检测就实现完成了,在检测完成之后重置内部元素大小依旧比外部容器大即可。
同理缩小检测也很简单,外部容器不变,内部元素宽度和父容器一直,当缩小的时候,父容器肯定会小于内部元素,于是触发 overflow
。缩小检测也完成了,再重置宽度即可。
看似一切美好,但实际上,这两个事件的兼容到现在都巨差无比。但别担心,还有别的办法
大大大
我们现在考虑下扩大的时候,如果父容器有滚动条,有一样东西是会发生变化的。那就是可滚动区域的大小——这不是废话么。别急,先想想可滚动区域变化的话,什么也会跟着变化呢?
没错,就是父元素的 scrollTop
和 scrollLeft
。具体怎么讲呢,这边我们先考虑 scrollTop
的情况。
假设有这样一个容器(绿色),高宽为 100px,内部有一个元素(红色),高 200px,宽 100px,也就是比容器固定大 100px。容器滚动到最底部,此时容器的 scrollTop: 100px
,如左图所示。如果此时容器扩大 10px,很明显随着滚动区域的变大,scrollTop
也会自动变化成 90px,如右图。
那么问题来了,scrollTop
怎么监听呢?
神奇就神奇在这里,因为元素尺寸的变化而导致 scrollTop
的变化竟然会触发容器的 scroll
事件,而且这个事件触发还是跨平台的,兼容性甚至可以下探到 ie7
小一点
这个时候你会发现,想要变小一点,那么问题就麻烦了。你会发现上面那个方式,你不管怎么搞,都无法让他在缩小的时候触发 scrollTop
的改变。
但是换个思路来考虑,你会发现另外一种让 scrollTop
发生改变的情况。依旧从上面那个方式来讲,如果容器缩小了 10px,那么内容至少要缩小 11px 才能导致滚动发生变化,那么我们会想到什么呢?
百分比对不对?这里假设设置内容高度为 200%,就可以做到当容器缩小 10px 的时候,内部元素可以缩小 20px,也就是 200px(100px 200%) ⇒ 180px(90px 200%)。如图所示
bomb!撞在一起
把上面两个结合起来,就可以实现监听 resize 事件了。但实际上使用需要处理一些样式问题,不过这个问题很简单,不单独说了。
如果前面你能看懂,那么到这里你心中有没有考虑一个情况,就是在重置的时候,会导致 scrollTop/Left
又发生了修改,从而又导致的 scroll
事件的触发,而这个怎么消除呢?
有这么两种办法:
通过检测元素是否发生变动,如果有变动那么就是因为元素缩放导致的,而如果没有缩放,那么就是通过重置导致的。
通过 raf,在同一帧内,先触发因为元素变动导致的
scroll
,再触发因为重置导致的事件,然后将对事件的处理统一推迟到下一阵开始的时间。从而消除循环事件
你看的见我,又看不见我了,偷偷摸摸搞点事情
众所周知,当你给一个元素设置 display:none;
的时候,元素不会渲染,同理也不会监听外部容器尺寸的变化。那么这就给了被监听元素可乘之机,他可以让 DOM 先隐藏,然后再改变大小,再显示 DOM,此时尺寸就乱了,于是就无法很好的监听 resize 了。
不过,也不是没有办法。
当 DOM 显示在浏览器的时候,会执行一个操作——播放动画
而动画事件是可以被监听到的,于是骚操作来了,给 DOM 元素添加一个 1ms 的动画,然后监听动画开始的事件,然后重置并检测大小变化。
ResizeObserver
这么通用的需求,怎么能逃过 w3c 的双眼,于是就推出了 ResizeObserver,方便做 resize 的监听。
而这个 api 所能做的远比我们想象的强大很多:
能够监听不同
box-sizing
的变化,比如border-box
/content-box
能够监听元素挂载和卸载
能够监听非容器类型的 DOM,比如
input
canvas
等等
正好说到了这个,顺便讲下一个有意思的事情。自古监听,从来没逃过循环计算的问题,大家熟悉的 angular 脏检查的次数限制,而 ResizeObserver
是怎么解决这个问题的呢。
这里先说明几个概念:
observer
每一个ResizeObserver
实例observation
当 observer 实例调用 observe 方法之后创建的每一个监听,每个observer
可能包含多个observation
target
被监听的 DOM 元素depth
深度,表示一个 DOM 元素距离根元素的距离,在网页中,也就是距离 html 标签的距离。
在每一次 Event Loop 中,会检查每一个 observation
的 target
的 depth
,并取一个最小值。然后顺便检查有那些 observation
产生了变化,并创建对应的 entity
,最后作为参数传给 observer
的回调。当上面这一操作之后,就完成了一轮检测,然后会再重复一遍这样的操作,只不过这次有个要求,不仅仅要求 observation
有变化,还要要求对应的 depth
比上次检查的最小值还要大,才可以创建 entity
。就这样一直一直循环检测跑下去,直到没有任何东西被检测到发生变化。
用一点通俗的话来说,除了第一轮的检查外,其他的每一轮检查都要求元素的高度要大于在上一轮检查元素的高度最小值,从而保证每一次检查,深度都会越来越大,直到达到最小的根节点,进而检查结束。
不过也许你会好奇,难道有些元素就会被跳过不检查么?其实不会的,对于那些深度小于上一次的最小深度的 observation
会自动到下一个 Event Loop 的时机去检查。
以上内容确实有点绕,我自己写的再看第二遍都看不懂了,原本好像补一个图仔细讲讲,但是发现上图也讲不清,算了弃疗,大家有兴趣的看看 Spec 吧。还有就是兼容性有点差,微软家的和苹果家的都不支持,摊手
还有别的办法么?
当然有了,还有我们 IntersectionObserver
呀。
怎么用?和最上面使用 scroll 的差不多,但是要略微简单一些,其实也没简单多少,因为该 api 判断的是相交的面积,也就是不能通过一个哨兵来判断,而是一个方向需要有一个哨兵。
至于兼容性么,除了 IE 不支持,其他都支持,这就很完美了么,对吧。(诡异的双眼,实际上坑很多,不建议尝试)
结语
本文没有特别深入的去讲解一些细节,只是讲了一下我觉得有意思的地方,如果你觉得哪里不清楚或者想了解更多,可以尝试去看源码。/ 我又没说包教包会 /
另外,我也是没懂为啥各大浏览器厂商这次这么一致,针对容器大小变化导致的 scrollTop
变化会触发 scroll
事件。可能是我太年轻了吧,这种上古事件的在各大浏览器之间的爱情纠葛我实在是无从考证。
另外,上面说 HC 很多,要多多推荐人,我也不喜欢专门打广告这种事情,但……杭州 Lark 部门招人,前端后端设计产品,应该(因为我也不确定,逃)是都要的。另外我觉得字节跳动是个好公司,如果你有其他部门或者其他城市的部门想要内推或者了解详情的,我也可以帮忙引荐下,只要进了字节我觉得就是对字节最大的贡献,不一定要来我们部门的,滑稽脸。