前言
随着浏览器不断革新,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
实例包含了变动相关的信息,包含以下属性:
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 元素的变动监听,可执行:
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
遵循四个步骤:创建观察者、定义监听回调、定义观察的目标对象、取消观察。
- 创建观察者 observer:
js
const resizeObserver = new ResizeObserver(callback);
- 定义监听回调 callback:
js
const callback = (entries) => {
entries.forEach(entrie => {
console.log(entrie.target, entrie.contentRect);
})
}
在回调中每个 entrie
都是一个对象,包含两个属性 contentRect
和 target
。contentRect
类似于 ele.getBoundingClientRect()
记录了元素的 位置 和 尺寸 信息。
- 定义观察的目标对象
js
resizeObserver.observe(container);
- 取消观察
取消单个节点的观察:
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
是一个构造函数,接收 callback
和 options
作为参数,通过 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 指南