MutationObserver 和 IntersectionObserver

4,453 阅读9分钟

cover

前言

在日常开发中我们通常会遇到元素的监听需求,不管是元素位置的监听还是元素内容的监听。如果没有原生 API 的支持,我们通常会想尽一切办法去实现它们,而且各类开源的解决方案也层出不穷。那有没有使用方便、适用场景广、性能优良的解决方案呢,今天它们来了——两大监听器 MutationObserver & IntersectionObserver。

MutationObserver

有时候我们会需要监听元素发生变化。在不借助使元素发生变化的业务动作的情况下,使用无污染方式监听是非常困难的,为了解决这个棘手的问题,MutationObserver 诞生了。

概述

MutationObserver 可以用来监听 DOM 的任何变化,比如子元素、属性和文本内容的变化。

概念上,它很接近事件,可以理解为 DOM 发生变动就会触发 Mutation Observer 事件。但是,它与事件有一个本质不同:事件是同步触发,也就是说,DOM 的变动立刻会触发相应的事件,而 Mutation Observer 则是异步触发,DOM 发生变化并不会马上触发,而是要等到当前所有 DOM 操作都结束才触发,执行时机有点类似于宏任务。

这样设计是为了应对 DOM 变动频繁的特点。如果不这么做,当文档中连续插入 1000 个 <p> 元素,就会连续触发 1000 个插入事件并执行每个事件的回调函数,这很可能造成浏览器的卡顿。而 Mutation Observer 完全不同,只在 1000 个段落都插入结束后才会触发,而且只触发一次。

综上所述,Mutation Observer 有以下特点:

  • 它等待所有脚本任务完成后,才会触发(宏任务)。
  • 它把所有 DOM 变动记录封装成一个数组进行处理,而不是单独处理每个 DOM 变动。
  • 它既可以观察 DOM 的所有类型变动,也可以指定只观察某一类型的变动。

构造函数

const observer = new MutationObserver((mutations, observer) => {
  mutations.forEach((mutation) => {
    console.log(mutation);
  });
});

我们通过 new MutationsObserver 来创建一个监听实例,需传入一个回调函数作为参数,回调函数的第一个参数是所有的 DOM 变动记录数组,第二个参数是监听实例。

实例属性

observe: Function

observe 方法用来启动监听,它接受两个参数。

  1. 所要观察的 DOM 节点
  2. 一个配置对象,指定所要观察的特定变化
const article = document.querySelector('article');
const options = {
  childList: true,
  attributes: true,
};
observer.observe(article, options);

上面代码中,observe 方法接收两个参数,第一个是所要观察的 DOM 元素是 article,第二个是所要观察的变动类型(子节点变动和属性变动)。

观察器所能观察的 DOM 变动类型(即上面代码的 options 对象),有以下几种:

属性名含义
childList: boolean是否监听子节点的变动(指新增,删除或者更改)
attributes: boolean是否监听属性的变动
characterData: boolean是否监听节点内容或节点文本的变动
subtree: boolean是否将该观察器应用于该节点的所有后代节点
attributeOldValue: boolean观察 attributes 变动时,是否需要记录变动前的属性值
characterDataOldValue: boolean观察 characterData 变动时,是否需要记录变动前的值
attributeFilter: Array需要观察的特定属性名(比如 ['class', 'src']

对一个节点添加观察器,就像使用addEventListener方法一样,多次添加同一个观察器是无效的,回调函数依然只会触发一次。但是,如果指定不同的options对象,就会被当作两个不同的观察器。

disconnect: Function

disconnect 方法用来停止观察。调用该方法后,DOM 再发生变动,也不会触发观察器。

observer.disconnect();

takeRecords: Function

takeRecords 方法用来在下一次事件触发前立即清除所有变动记录,即不再处理未处理的变动。该方法返回当下积累的变动记录的数组。

const records = observer.takeRecords();

变动记录

DOM 每次发生变化,就会生成一条变动记录(MutationRecord 实例)。该实例包含了与变动相关的所有信息。Mutation Observer 处理的就是一个个 MutationRecord 实例所组成的数组。

MutationRecord 对象包含了 DOM 的相关信息,有如下属性:

属性名含义
type: string观察的变动类型(attribute、characterData、childList)
target: Element发生变动的 DOM 节点
addedNodes: Array新增的 DOM 节点
removedNodes: Array删除的 DOM 节点
previousSibling: Elementnull前一个同级节点,如果没有则返回 null
nextSibling: ELementnull下一个同级节点,如果没有则返回 null
attributeName: string发生变动的属性。如果设置了 attributeFilter,则只返回预先指定的属性
oldValue: stringnull变动前的值。这个属性只对 attribute 和 characterData 变动有效,如果发生 childList 变动,则返回 null

兼容性

Can I use

值得庆幸的是这个 API 能在大部分设备上满足我们的需求。

IntersectionObserver

以往我们实现图片懒加载往往是通过监听存放图片的滚动容器的滚动事件或者整个页面的滚动事件,在滚动事件触发的时候调用图片元素的 getBoundingClientRect() 函数来进行可见性比对。这种方式是在事件触发的时候同步进行,如果运算量过大极有可能会导致主线程阻塞,从而页面卡顿。延伸开来,我们急迫地需要一个高性能的元素可见性变化解决方案,所以 IntersectionObserver 诞生了。

概述

这个 API 可以观察目标元素与视口或指定根元素产生的交叉区的变化,所以这个 API 也叫做“交叉观察器”。

它和 MutationObserver 一样都是异步的,不随着目标元素的滚动同步触发。发明者规定,IntersectionObserver 的实现,应该采用 requestIdleCallback() 的方式,即只有线程空闲下来,才会执行观察器。这意味着,这个观察器的优先级非常低,只在其他任务执行完,浏览器有了空闲才会执行。

构造函数

它的创建非常简单。

const io = new IntersectionObserver(callback, option);

上面代码中,IntersectionObserver 是浏览器原生提供的构造函数,接收两个参数:

  1. 可见性变化时的回调函数
  2. 配置对象(该参数可选)

构造函数的返回值是一个观察器实例。实例的 observe 方法可以指定观察哪个 DOM 节点。

// 开始观察
io.observe(document.getElementById('example'));

// 停止观察
io.unobserve(element);

// 关闭观察器
io.disconnect();

上面代码中,observe 的参数是一个 DOM 节点对象。如果要观察多个节点,就要多次调用这个方法。

io.observe(elementA);
io.observe(elementB);

监听回调

目标元素的可见性变化时,就会调用观察器的回调函数 callback

callback 默认情况下会触发两次。一次是目标元素刚刚进入视口时(开始可见),另一次是完全离开视口时(开始不可见)。

const io = new IntersectionObserver((entries) => {
  console.log(entries);
});

上面代码中,callback 函数的参数(entries)是一个数组,每个成员都是一个 IntersectionObserverEntry 对象。举例来说,如果同时有两个被观察的对象的可见性发生变化,entries 数组就会有两个成员。

回调信息

entries 对象中每个条目 IntersectionObserverEntry 对象提供目标元素的信息,一共有六个属性。

{
  time: 3893.92,
  rootBounds: {
    bottom: 920,
    height: 1024,
    left: 0,
    right: 1024,
    top: 0,
    width: 920
  },
  boundingClientRect: {
     // 同上
  },
  intersectionRect: {
    // 同上
  },
  intersectionRatio: 0.54,
  target: Element,
}

每个属性的含义如下:

属性名含义
time: number可见性发生变化的时间,是一个高精度时间戳,单位为毫秒
target: Element被观察的目标元素,是一个 DOM 节点对象
rootBounds: Objectnull根元素的矩形区域的信息,同 getBoundingClientRect() 方法的返回值,如果没有根元素(即直接相对于视口滚动),则返回 null
boundingClientRect: Object目标元素的矩形区域的信息,同 getBoundingClientRect() 方法的返回值
intersectionRect: Object目标元素与视口(或根元素)的交叉区域的信息,同 getBoundingClientRect() 方法的返回值
intersectionRatio: number目标元素的可见比例,即 intersectionRect 占 boundingClientRect 的比例,完全可见时为 1,完全不可见时小于等于 0

图示

img

上图中,灰色的水平方框代表视口,深红色的区域代表四个被观察的目标元素。它们各自的intersectionRatio 图中都已经注明。

监听配置

IntersectionObserver 构造函数的第二个参数是一个配置对象。它可以设置以下属性:

threshold

threshold 属性决定了什么时候触发回调函数。它是一个数组,每个成员都是一个门槛值,默认为 [0],即交叉比例(intersectionRatio)达到 0 时触发回调函数。

new IntersectionObserver((entries) {/* ... */}, {
  threshold: [0, 0.25, 0.5, 0.75, 1],
});

用户可以自定义这个数组。比如,[0, 0.25, 0.5, 0.75, 1] 就表示当目标元素 0%、25%、50%、75%、100% 可见时,分别触发回调函数。

root & rootMargin

很多时候,目标元素不仅会随着窗口滚动,还会在容器里面滚动(比如在 iframe 窗口里滚动)。容器内滚动也会影响目标元素的可见性,参见上节图示。

IntersectionObserver API 也支持容器内滚动的监听。root 属性指定目标元素所在的容器节点(即根元素)。注意,容器元素必须是目标元素的祖先节点。

const opts = { 
  root: document.querySelector('.container'),
  rootMargin: '500px 0px',
};

const observer = new IntersectionObserver(
  callback,
  opts,
);

上面代码中,除了 root 属性,还有 rootMargin 属性。后者定义根元素的 margin,用来扩展或缩小 rootBounds 这个矩形的大小,从而影响 intersectionRect 交叉区域的大小。它使用CSS的定义方法,比如 10px 20px 30px 40px,表示 top、right、bottom 和 left 四个方向的值。

这样设置以后,不管是窗口滚动或者容器内滚动,只要目标元素可见性变化,都会触发观察器。

兼容性

Can I use

这个 API 在大部分设备上能使用,但是需要在低版本 Android 和 iOS 设备上注意一下兼容性。