聊一聊监听 DOM 的方式 Observer

前言

随着浏览器不断革新,JS 原生层面提供了一些 Observer API 来应对一些 观察和监听 DOM 的交互场景。比如:

  • 监听 DOM 元素 自身属性和子节点的变化 ,可以使用 MutationObserver API
  • 监听 DOM 元素的 尺寸信息 的变化(如:width/height),可以使用 ResizeObserver API
  • 监听 DOM 元素是否 处于屏幕可视区域(视窗内) ,可以使用 IntersectionObserver API

可见这三种 Observer API 各有所长,根据各自的特性能够解决不同的应用场景。

下面,我们来介绍这三种 Observer API 的用法以及其各自的应用场景。

一、MutationObserver 监听 DOM 节点信息变化

当你期望对一个 DOM 元素子节点的增加、减少、或者自身属性的变动、文本内容的变动 能够接收到通知,MutationObserver API 可以帮你来完成:它提供了监视 DOM 树内容变化的能力。

1. 特点:

  • 属于 异步动作 ,它不同于传统的 发布订阅 同步发送通知,而是等 DOM 中所有的变更都结束后触发一次,能够有效避免重复触发通知;
  • 一次通知记录了所有变动信息,它把 DOM 变动记录封装成一个数组进行统一处理;
  • 按需监听,可根据场景自由选择要观察 DOM 哪些信息的变化(监听配置)。

2. 用法:

MutationObserver 自身是一个构造函数,接收一个 callback 作为监听回调,在 DOM 信息发生变化时被执行;同时返回一个 observer 对象,后续通过它来监听 DOM。

js 复制代码
const observer = new MutationObserver(mutations => {
  console.log(mutations); // 变动记录
});

observer.observe(target[, options]) 用于监听 DOM 元素,第一参数 是指定要观察的 DOM ,第二参数 是一个配置对象,用于指定所要观察元素的变动信息。

js 复制代码
observer.observe(document.querySelector('#container'), {
  childList: true, // 监视元素 第一级直接子节点 的变动
  subtree: true, // 监视元素 所有后代节点 的变动(前提要求 childList = true)
  attributes: true, // 监视元素 属性 的变动
  characterData: true, // 监视元素或子节点树中 文本节点内容 的变化
  attributeOldValue: true, // 记录 发生改动前的值,用于同步 `MutationRecord.oldValue`
  attributeFilter: [], // 需要观察的特定属性名(比如 `['class', 'src']`)
});

在构造函数中,callback 的参数 mutations,是一个是描述所有变动记录的 MutationRecord 对象数组,通过遍历 mutations 处理每一条变动记录。

js 复制代码
const observer = new MutationObserver(mutations => {
  mutations.forEach(function(mutation) {  
    console.log(mutation);  
  });
});

单个 MutationRecord 实例包含了变动相关的信息,包含以下属性:

  1. type: string:变动的类型,值包含 attributes、characterData 或 childList(对应 childList 和 subtree 配置);
  2. target: Element:发生变动的 DOM 节点;
  3. addedNodes: NodeList:返回新增的 DOM 节点,如果没有节点被添加,则返回一个空的 NodeList;
  4. removedNodes: NodeList:返回移除的 DOM 节点,如果没有节点被移除,则返回一个空的 NodeList;
  5. previousSibling:返回被添加或移除的节点之前的兄弟节点,如果没有则返回 null;
  6. nextSibling:返回被添加或移除的节点之后的兄弟节点,如果没有则返回 null;
  7. attributeName:返回被修改的属性的属性名,如果设置了 attributeFilter,则只返回预先指定的属性;
  8. oldValue:变动前的值。这个属性只对 attribute 和 characterData 变动有效,如果发生 childList 变动,则返回 null。

如果要停止对 DOM 元素的变动监听,可执行:

js 复制代码
observer.disconnect();

3. 场景:

对于现在流行的数据驱动框架来说,元素的属性、文本内容、及子节点的渲染和移除,多数都是采用数据 state 来控制,所以在项目框架下使用 MutationObserver 的场景并不是很多。

但对于接入第三方 SDK 启动的程序,如 WPS 文档、会议 SDK。在没有提供相关 API 的情况下,要去监听它里边的元素信息变化,可以去借助 MutationObserver 来实现一些监听动作。

使用示例:

js 复制代码
// 示例来自:mdn web docs MutationObserver 
// https://developer.mozilla.org/zh-CN/docs/Web/API/MutationObserver

// 选择需要观察变动的节点
const targetNode = document.getElementById("some-id");

// 观察器的配置(需要观察什么变动)
const config = { attributes: true, childList: true, subtree: true };

// 当观察到变动时执行的回调函数
const callback = function (mutationsList, observer) {
  // Use traditional 'for loops' for IE 11
  for (let mutation of mutationsList) {
    if (mutation.type === "childList") {
      console.log("A child node has been added or removed.");
    } else if (mutation.type === "attributes") {
      console.log("The " + mutation.attributeName + " attribute was modified.");
    }
  }
};

// 创建一个观察器实例并传入回调函数
const observer = new MutationObserver(callback);

// 以上述配置开始观察目标节点
observer.observe(targetNode, config);

// 之后,可停止观察
observer.disconnect();

二、ResizeObserver 监听 DOM 尺寸信息变化

ResizeObserver API 可用于监视一个 DOM 的尺寸信息变化。可以是 宽高、位置 信息的变化。

1. 用法:

使用 ResizeObserver 遵循四个步骤:创建观察者、定义监听回调、定义观察的目标对象、取消观察。

  1. 创建观察者 observer:
js 复制代码
const resizeObserver = new ResizeObserver(callback);
  1. 定义监听回调 callback:
js 复制代码
const callback = (entries) => {
  entries.forEach(entrie => {
    console.log(entrie.target, entrie.contentRect);
  })
}

在回调中每个 entrie 都是一个对象,包含两个属性 contentRecttargetcontentRect 类似于 ele.getBoundingClientRect() 记录了元素的 位置 和 尺寸 信息。

  1. 定义观察的目标对象
js 复制代码
resizeObserver.observe(container);
  1. 取消观察

取消单个节点的观察:

js 复制代码
resizeObserver.unobserve(container);

取消所有节点观察:

js 复制代码
resizeObserver.disconnect();

2. 场景:

2.1 针对元素的不同尺寸定义不同样式

在业务上要实现页面在不同尺寸的设备上布局排版自适应,可选用两种方式实现:

  • CSS @media screen 媒体查询,根据视窗大小应用不同的布局样式;
  • JS window.resize 监听视窗变化,在视窗发生变化时动态干预布局。

但这两种方式都是应用在 window 视窗上的,若现在遇到需求要监听 div 的尺寸变化(比如 宽、高)如何做呢?

现有一个需求:监听 div 容器宽度变化,调整其内部的布局结构。

比如一个容器宽度支持被拖动调整尺寸大小,并根据宽度大小对内部元素做不同自适应布局。

这类似于 css @media screen 媒体查询,但由于媒体查询只能用于监听视窗,对于监听 元素 尺寸可以使用 JS ResizeObserver API 来实现。

js 复制代码
useEffect(() => {
  const target = messagesAreaRef.current!; // 目标元素
  const resizeObserver = new ResizeObserver(entries => {
    const { width } = entries[0].contentRect; // 监听到的尺寸信息
    if (width < 842) { // 目标值
      target.classList.add("compact"); // 添加紧凑 class
    } else {
      target.classList.remove("compact");
    }
  });
  resizeObserver.observe(target);
  return () => {
    resizeObserver.disconnect();
  };
}, []);

2.2 监听目标元素尺寸变化同步应用

需求:我们要实现一个文件预览器(弹框),点击文件可以打开弹框进行预览。

通常文件预览器是一个全屏的弹框,我们可以通过 fixed 固定定位 轻松实现。

但现在需求升级,能够让预览弹框支持在特定区域内展示,也就是说它可以不是一个全屏的弹框,而是与页面中指定的容器区域内展示。

一种是基于容器元素使用 absolute 绝对定位来实现,但这种方式实现要求将 预览弹框的内容 挂载到这个容器 DOM 树内。

如果我们希望 预览弹框 能够挂载到 document.body 下,且还能实现在特定区域内呈现预览内容,我们需要通过 fixed + 获取容器的位置及尺寸来实现。

tsx 复制代码
// container 为外部传入的容器元素,没有传则以 
const ele = container || document.body;
const { left, top, width, height } = ele.getBoundingClientRect();

ReactDOM.createPortal(
  <div 
    className="file-preview-container" 
    ref={previewContainerRef} 
    style={{ position: 'fixed', zIndex: 3000, left, top, width, height }}>
    ...
  </div>,
  document.body,
)

但现在有一个问题是:如果调整容器的尺寸,预览弹框不会跟着实施调整宽高,这时我们借助 ResizeObserver 来监听容器的尺寸变换,并同步给预览弹框。

tsx 复制代码
const previewContainerRef = useRef<HTMLDivElement>(null);
const ele = container || document.body;
...

useEffect(() => {
  const resizeObserver = new ResizeObserver(entries => {
    const entrie = entries[0];
    const { width, height } = entrie.contentRect;
    // 同步尺寸
    previewContainerRef.current?.style.setProperty('width', width + 'px');
    previewContainerRef.current?.style.setProperty('height', height + 'px');
  });
  resizeObserver.observe(ele);
  return () => {
    resizeObserver.unobserve(ele);
  }
}, []);

三、IntersectionObserver

IntersectionObserver API 是一个交叉观察器,用于监听 目标元素 与指定的 root 元素(祖先元素或视窗) 的交叉状态(可见性)。

这对 检测某个(些)元素是否出现在可视区域 的需求非常适用。如常见的业务需求:图片懒加载视频出现在可视区域时自动播放 等。

目标元素 与 root 元素 交叉 存在以下几种情况:

  • 目标元素 即将进入 root 元素的可见区域内,处于 未交叉 状态;
  • 目标元素 进入 root 元素的可见区域内,处于 交叉 状态(又分为 部分交叉 和 完全展示在其中);
  • 目标元素 离开 root 元素的可见区域内,处于 未交叉 状态。

如果你对 交叉 一词不太容易理解,可以把它看成是:目标元素是否处于 root 容器(有滚动条)的可视区域内

1. 用法:

constructor

IntersectionObserver 是一个构造函数,接收 callbackoptions 作为参数,通过 new 关键字创建一个对象实例。

js 复制代码
const io = new IntersectionObserver(callback, options);

callback

callback 是一个监听目标元素在 root 根元素中的 交叉状态 的回调函数。在回调中可通过参数 entries 来获取目标元素于 root 的交叉信息。

IntersectionObserver` 默认初始化会触发一次 callback,并在第一时间拿到 目标元素与 root 容器的交叉状态。

entries 中包含了多个观察对象(支持监听多个目标元素),每个 entrie 代表一个目标元素的监听信息,具体如下:

  • target - HTMElement: 交叉的目标元素;
  • isIntersecting - Boolean: 是否处于交叉状态,当目标元素出现在 root 可视区域(交叉)时值为 true,离开了 root 可视区域时值为 false(取决下方于配置项 阈值 options.threshold);
  • intersectionRatio - Number:返回目标元素出现在 root 可视区域的比例,范围是 0 - 1,为 1 表示完全可见;
  • boundingClientRect - Object: 返回包含目标元素 与 root 元素的边界信息,返回结果与 element.getBoundingClientRect() 一致;
  • intersectionRect - Object: 目标元素与视口(或根元素)的交叉区域的信息,同 getBoundingClientRect() 方法的返回值;
  • time: 返回从监听目标元素开始,在触发交叉时的时间(时间戳,类似于 performance.now())。

判断交叉状态常用的两个信息是:isIntersecting 和 intersectionRatio。

options

options 用于配置 目标元素 与 root 元素出现 交叉 的规则,共三个属性:

  • root - HTMLElement | null: 指定交叉参照的祖先元素,在未传入时使用定义文档的视窗;
  • rootMargin - String: 用来扩展或缩小 rootBounds 这个矩形的大小,从而影响 intersectionRect 交叉区域的大小。所有的偏移量均可用像素 (px)或百分比 (%)来表达, 默认值为"0px 0px 0px 0px"。
  • threshold - Array | Number: 决定了触发回调函数的交叉规则。它是一个数组,默认为 [0],仅在出现交叉时执行一次回调。当设置 1 时,表示目标元素需要 100% 可见时触发回调,当设置 [0, 0.25, 0.5, 0.75, 1] 就表示当目标元素 0%、25%、50%、75%、100% 可见时,分别触发回调函数。

methods

拿到 IntersectionObserver 的实例后,可使用下列方法来 监听/停止监听 目标元素。

  • observe(HTMLElement): 监听一个目标元素;
  • unobserve(HTMLElement): 停止监听目标元素;
  • takeRecords(): 返回所有观察的目标对象的 entrie 信息;
  • disconnect(): 停止所有目标元素的监听工作。

2. 场景:

2.1 图片懒加载提高

判断元素是否出现在可视区域,比较常见的场景是 图片懒加载

假如进入页面要渲染 100 条带有图片的数据,如果将它们全部进行网络资源请求,这将会阻塞页面的其他 API 请求。尽管很多图片并未出现在页面的可视区域内。

图片懒加载可以很好的控制页面中图片资源加载的时机,从而提高网站的访问速度。

思路:在图片没有出现在视窗时展示默认占位图(或者统一的封面图,且资源很小),当触发滚动 图片 出现在视窗后,替换为真实的图片的地址。

js 复制代码
<img src="./loading-url.png" data-src="./url.png" alt="">

const imgList = [...document.querySelectorAll('img')];
const io = new IntersectionObserver((entries) =>{
  entries.forEach(item => {
    // isIntersecting 是一个 Boolean 值,判断目标元素当前是否进入 root 视窗
    if (item.isIntersecting) {
      item.target.src = item.target.dataset.src; // 真实的图片地址存放在 data-src 自定义属性上
      // 图片加载后停止监听该元素
      io.unobserve(item.target);
    }
  });
})
// observe 监听所有 img 节点
imgList.forEach(img => io.observe(img));

2.2 元素的页面曝光埋点

当一个页面中的特定元素,完全显示在可视区内时进行埋点曝光。

js 复制代码
const boxList = [...document.querySelectorAll('.box')];

var io = new IntersectionObserver((entries) =>{
  entries.forEach(item => {
    if (item.intersectionRatio === 1) { // 元素完全出现在可视区域
      // ... 增加埋点曝光逻辑
      io.unobserve(item.target);
    }
  })
}, {
  root: null,
  threshold: 1, // 阀值设为 1,当只有比例达到 1 时才触发回调函数
})

// observe遍历监听所有box节点
boxList.forEach(box => io.observe(box))

参考

1. 是谁动了我的 DOM?
2. MutationObserver 和 IntersectionObserver
3. 超好用的API之IntersectionObserver
4. 现代浏览器观察者 Observer API 指南

相关推荐
崔庆才丨静觅12 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606113 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了13 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅13 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅14 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅14 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment14 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅15 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊15 小时前
jwt介绍
前端
爱敲代码的小鱼15 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax