监听 DOM 的方式 Observer 最后更新时间:2024年01月17日 ### 声明 本文来源:[javascript之属性监听的实现](https://juejin.cn/post/6844903822679080973 "javascript之属性监听的实现") 前言 随着浏览器不断革新,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。 const observer = new MutationObserver(mutations => { console.log(mutations); // 变动记录 }); observer.observe(target[, options]) 用于监听 DOM 元素,第一参数 是指定要观察的 DOM,第二参数 是一个配置对象,用于指定所要观察元素的变动信息。 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 处理每一条变动记录。 const observer = new MutationObserver(mutations => { mutations.forEach(function(mutation) { console.log(mutation); }); }); 单个 MutationRecord 实例包含了变动相关的信息,包含以下属性: type: string:变动的类型,值包含 attributes、characterData 或 childList(对应 childList 和 subtree 配置); target: Element:发生变动的 DOM 节点; addedNodes: NodeList:返回新增的 DOM 节点,如果没有节点被添加,则返回一个空的 NodeList; removedNodes: NodeList:返回移除的 DOM 节点,如果没有节点被移除,则返回一个空的 NodeList; previousSibling:返回被添加或移除的节点之前的兄弟节点,如果没有则返回 null; nextSibling:返回被添加或移除的节点之后的兄弟节点,如果没有则返回 null; attributeName:返回被修改的属性的属性名,如果设置了 attributeFilter,则只返回预先指定的属性; oldValue:变动前的值。这个属性只对 attribute 和 characterData 变动有效,如果发生 childList 变动,则返回 null。 如果要停止对 DOM 元素的变动监听,可执行: observer.disconnect(); 3、场景: 对于现在流行的数据驱动框架来说,元素的属性、文本内容、及子节点的渲染和移除,多数都是采用数据 state 来控制,所以在项目框架下使用 MutationObserver 的场景并不是很多。 但对于接入第三方 SDK 启动的程序,如 WPS 文档、会议 SDK。在没有提供相关 API 的情况下,要去监听它里边的元素信息变化,可以去借助 MutationObserver 来实现一些监听动作。 使用示例: // 示例来自: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 遵循四个步骤:创建观察者、定义监听回调、定义观察的目标对象、取消观察。 创建观察者 observer: const resizeObserver = new ResizeObserver(callback); 定义监听回调 callback: const callback = (entries) => { entries.forEach(entrie => { console.log(entrie.target, entrie.contentRect); }) } 在回调中每个 entrie 都是一个对象,包含两个属性 contentRect 和 target。contentRect 类似于 ele.getBoundingClientRect() 记录了元素的 位置 和 尺寸 信息。 image.png 定义观察的目标对象 resizeObserver.observe(container); 取消观察 取消单个节点的观察: resizeObserver.unobserve(container); 取消所有节点观察: resizeObserver.disconnect(); 2、场景: 2.1、针对元素的不同尺寸定义不同样式 在业务上要实现页面在不同尺寸的设备上布局排版自适应,可选用两种方式实现: CSS @media screen 媒体查询,根据视窗大小应用不同的布局样式; JS window.resize 监听视窗变化,在视窗发生变化时动态干预布局。 但这两种方式都是应用在 window 视窗上的,若现在遇到需求要监听 div 的尺寸变化(比如 宽、高)如何做呢? 现有一个需求:监听 div 容器宽度变化,调整其内部的布局结构。 比如一个容器拖动其边框,它的宽度根据鼠标拖动来调整尺寸大小,并根据宽度大小对内部元素做不同自适应布局。 这类似于 css @media screen 媒体查询,但由于媒体查询只能用于监听视窗,对于监听 元素 尺寸可以使用 JS ResizeObserver API 来实现。 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、监控 B 元素的高度发生变化,实时调整 A 元素的尺寸 在容器内 垂直方式布局 两个模块:消息列表(A)和 底部输入区域(包含输入框和其他操作项)(B)。 消息列表(A)支持滚动,底部输入区(B)域采用 position: absolute 绝对定位,这要求消息列表(A)需要腾出 底部输入区(B)的高度,所以设置 padding-bottom: B height 来实现。 但现在有一个情况,底部输入区(B)中的 操作项,只在某些条件下才会展示,这就会出现 底部输入区(B)的高度是会随内容发生变化。 那么它将会影响 消息列表(A)设置 padding-bottom: B height,需要实时去设置 B height。 这就需要一种监听机制,在 B height 发生变化后,实时更新 A 的 padding-bottom。 ResizeObserver 此时就派上用场了: // 监控 footer 尺寸,动态调整消息列表底边距 useEffect(() => { // React 下可以换成 Ref 绑定 DOm const listEle = document.querySelector("#chat-message-list") as HTMLElement | null; const listFooterEle = document.querySelector("#chat-message-footer") as HTMLElement | null; if (listEle && listFooterEle) { let lastHeight = 0; // 优化处理,只在 observe ele height 发生变化后,重新设置 padding-bottom const resizeObserver = new ResizeObserver(entries => { const { height } = entries[0].contentRect; // 监听到的尺寸信息 if (lastHeight !== height) { console.log("listFooterEle height: ", height); listEle.style.setProperty("padding-bottom", `${height + 16}px`); // 16 是增加 footer 与 list 之间的上下边距 lastHeight = height; } }); resizeObserver.observe(listFooterEle); return () => { resizeObserver.disconnect(); }; } }, []); 三、IntersectionObserver IntersectionObserver API 是一个交叉观察器,用于监听 目标元素 与指定的 root 元素(祖先元素或视窗) 的交叉状态(可见性)。 这对 检测某个(些)元素是否出现在可视区域 的需求非常适用。如常见的业务需求:图片懒加载、视频出现在可视区域时自动播放 等。 目标元素 与 root 元素 交叉 存在以下几种情况: 目标元素 即将进入 root 元素的可见区域内,处于 未交叉 状态; 目标元素 进入 root 元素的可见区域内,处于 交叉 状态(又分为 部分交叉 和 完全展示在其中); 目标元素 离开 root 元素的可见区域内,处于 未交叉 状态。 如果你对 交叉 一词不太容易理解,可以把它看成是:目标元素是否处于 root 容器(有滚动条)的可视区域内 1、用法: constructor IntersectionObserver 是一个构造函数,接收 callback 和 options 作为参数,通过 new 关键字创建一个对象实例。 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 请求。尽管很多图片并未出现在页面的可视区域内。 图片懒加载可以很好的控制页面中图片资源加载的时机,从而提高网站的访问速度。 思路:在图片没有出现在视窗时展示默认占位图(或者统一的封面图,且资源很小),当触发滚动 图片 出现在视窗后,替换为真实的图片的地址。 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、元素的页面曝光埋点 当一个页面中的特定元素,完全显示在可视区内时进行埋点曝光。 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 指南
Comments | NOTHING