新的API: IntersectionObserver
在懒加载图片的场景中,常常需要在滚动事件回调函数中检测目标图片是否在视口内,常用的检测方法如下:
function inViewport( el, h ) {
var elH = el.offsetHeight, // 元素高度
scrolled = scrollY(), // 视口顶部距离document顶部的距离
viewed = scrolled + getViewportH(), // 视口底部距离document的距离
elTop = el.getBoundingClientRect().top, // 元素顶部距离document顶部的距离
elBottom = elTop + elH, // 元素底部距离document顶部的距离
h = h || 0; // 高度系数
return (elTop + elH * h) <= viewed && (elBottom) >= scrolled;
}
这种方法的问题是使用getBoundingClientRect()
读取元素尺寸数据时,会造成 re-layout,在滚动过程中会导致页面卡顿(jank)。
IntersectionObserver
IntersectionObserver 作为新引入的 API,异步查询元素相对于另一个元素(例如视口)的位置,不会影响主线程,可以解决检测方法带来的性能问题。
首先我们需要创建一个观察对象,构造函数方法签名如下:
new IntersectionObserver(entries => {}, options);
随后将使用这个观察对象对元素的位置变化进行观测:
io.observe(element);
观察对象构造函数的第一个参数便是回调函数,这能避免旧方法中轮询带来的性能问题。 在回调函数中,传入的参数是一个数组(可以观测多个元素),每个元素对象包含以下属性:
rootBounds
,ClientRect
对象,等同于对根元素(默认值为视口)调用getBoundingClientRect()
boundingClientRect
,ClientRect
对象,等同于对被观测元素调用getBoundingClientRect()
intersectionRect
,以上两个矩形的交集intersectionRatio
,被观测元素被覆盖的比例,如下图所示:
现在来看看观察对象的第二个参数,这是一个配置对象,其中包含3个重要的配置项:
root
,默认值为视口,在容器内滚动的场景中,我们可以指定根元素为容器rootMargin
,顾名思义,值的格式与margin
一样threshold
,这个阈值甚至可以是一个列表,每次达到其中的一个阈值时都会触发回调函数,取值范围为[0-1]
最后,相对于observe()
观测方法,自然也有解除观测方法unobserve()
。
以上就是这个新 API 的全部内容了,下面让我们看看在实际场景中如何应用吧。
懒加载图片
有了这个新的 API,实现滚动到视口内加载图片就十分简单了,其实就是多了新的判断方法,不再需要在滚动事件中获取了。
// 创建观测对象
let observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
// 图片和视口有交集了
if (entry.intersectionRatio > 0) {
// 停止继续观测,直接加载
observer.unobserve(entry.target);
loadImage(entry.target);
}
});
}, {threshold: 0.01});
// 观测所有图片
images.forEach(image => {
observer.observe(image);
});
当然,这里还有一些细节,例如:
- 考虑浏览器兼容性,目前最新版 Chrome 和 Firefox 支持良好。polyfill
- 图片的 src 属性存放在例如 data-src 上
- 全部图片加载完毕,调用观测对象的
disconnect()
方法彻底关闭
可以参考这篇文章的实现。
多个容器中 sticky 的场景
同样,在 sticky 的场景中,IntersectionObserver 也有很好的应用。
首先来看看过去的方式有哪有限制:
- 对于开发者而言,通过 CSS 规则
position: sticky
将控制行为完全交给浏览器。除了通过手动计算,完全不知道一个元素当前是否处于 sticky 状态,当然这个计算过程,又需要放在滚动事件中重复执行,容易造成 jank。 - 当涉及到多个独立容器的场景,计算过程将变得十分复杂。
对于开发者的一种理想状态是,能够监听自定义事件sticky-change
,在元素 sticky 状态发生改变之时进行操作,这样就避免了在滚动事件处理函数中不断轮询。
document.addEventListener('sticky-change', e => {
const header = e.detail.target; // header became sticky or stopped sticking.
const sticking = e.detail.stuck; // true when header is sticky.
header.classList.toggle('shadow', sticking); // add drop shadow when sticking.
});
首先我们需要为每一个容器建立首尾两个“哨兵” DOM。IO 需要关注它们和视口容器的重合度。
这里以头部的“哨兵”为例,每次一进入视口容器就需要开始计算位置信息。尾部哨兵略有不同,只有全部在容器中才需要开始计算。
const observer = new IntersectionObserver((records, observer) => {
for (const record of records) {
const targetInfo = record.boundingClientRect;
const stickyTarget = record.target.parentElement.querySelector('.sticky');
const rootBoundsInfo = record.rootBounds;
// Started sticking.
if (targetInfo.bottom < rootBoundsInfo.top) {
fireEvent(true, stickyTarget);
}
// Stopped sticking.
if (targetInfo.bottom >= rootBoundsInfo.top &&
targetInfo.bottom < rootBoundsInfo.bottom) {
fireEvent(false, stickyTarget);
}
}
}, {threshold: [0], root: container});
// Add the top sentinels to each section and attach an observer.
const sentinels = addSentinels(container, 'sticky_sentinel--top');
sentinels.forEach(el => observer.observe(el));
发送自定义事件的代码就省略了。完整代码可参考原文中的DEMO 地址。
现在我们可以做到监听元素 sticky 状态的变动,由此引申到另一个问题,那就是元素其他 CSS 属性的变动也能够检测么?
MutationObserver
可以监听样式类的增减,但是计算出来的样式规则发生变动则不行。未来 Style Mutation Observer将作为一个很好的补充。
总结
IO 在需要计算两个元素重合状态,尤其是视口元素的场景中,可以完全替代滚动事件轮询的方式。