新的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);

观察对象构造函数的第一个参数便是回调函数,这能避免旧方法中轮询带来的性能问题。 在回调函数中,传入的参数是一个数组(可以观测多个元素),每个元素对象包含以下属性:

现在来看看观察对象的第二个参数,这是一个配置对象,其中包含3个重要的配置项:

  1. root,默认值为视口,在容器内滚动的场景中,我们可以指定根元素为容器
  2. rootMargin,顾名思义,值的格式与margin一样
  3. 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);
});

当然,这里还有一些细节,例如:

  1. 考虑浏览器兼容性,目前最新版 Chrome 和 Firefox 支持良好。polyfill
  2. 图片的 src 属性存放在例如 data-src 上
  3. 全部图片加载完毕,调用观测对象的disconnect()方法彻底关闭

可以参考这篇文章的实现。

多个容器中 sticky 的场景

同样,在 sticky 的场景中,IntersectionObserver 也有很好的应用。

首先来看看过去的方式有哪有限制:

对于开发者的一种理想状态是,能够监听自定义事件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 在需要计算两个元素重合状态,尤其是视口元素的场景中,可以完全替代滚动事件轮询的方式。

参考资料