聊一聊监听 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 指南

相关推荐
新中地GIS开发老师2 分钟前
25考研希望渺茫,工作 VS 二战,怎么选?
javascript·学习·考研·arcgis·地理信息科学·地信
萧大侠jdeps10 分钟前
Vue 3 与 Tauri 集成开发跨端APP
前端·javascript·vue.js·tauri
JYeontu1 小时前
实现一个动态脱敏指令,输入时候显示真实数据,展示的时候进行脱敏
前端·javascript·vue.js
发呆的薇薇°1 小时前
react里使用Day.js显示时间
前端·javascript·react.js
嘤嘤嘤1 小时前
基于大模型技术构建的 GitHub Assistant
前端·github
KeepCatch1 小时前
CSS 动画与过渡效果
前端
跑跑快跑1 小时前
React vite + less
前端·react.js·less
web136885658711 小时前
ctfshow_web入门_命令执行_web29-web39
前端
GISer_Jing1 小时前
前端面试题合集(一)——HTML/CSS/Javascript/ES6
前端·javascript·html
清岚_lxn1 小时前
es6 字符串每隔几个中间插入一个逗号
前端·javascript·算法