前端 Observer API 全家桶:Intersection、Resize 与 MutationObserver 深度实战

2926 字
15 分钟
前端 Observer API 全家桶:Intersection、Resize 与 MutationObserver 深度实战

“这段元素到底有没有出现在视口里?”、“这个 DOM 是不是被第三方偷偷改了?”、“父容器尺寸变了,我的图表怎么重绘?”

三个看似不相干的问题,其实都指向浏览器原生提供的一组观察者 API。它们取代了过去靠 scroll + getBoundingClientRectsetInterval + 轮询、DOMAttrModified 这类”反模式”才能做到的事情。

本文会带你深入 IntersectionObserver、ResizeObserver、MutationObserver 三大核心 API,从原理到实战,再到组合使用,最后封装一个生产级的”懒加载 + 无限滚动 + 自适应布局”通用 SDK。

一、为什么我们需要 Observer API#

在 Observer API 出现之前,前端要”观察”页面状态变化,基本只有两个选择:

  1. 事件驱动:监听 scrollresizeclick 等事件,事件触发时手动检查。
  2. 轮询setInterval 死循环,每隔几十毫秒跑一次 getBoundingClientRect 或 DOM diff。

这两种方式都有明显的痛点:

  • scroll 事件触发频率极高(每帧最多触发一次),在回调里执行 getBoundingClientRect强制触发同步布局(forced reflow),主线程一旦卡住,FPS 直接掉到 30 以下。
  • 轮询则完全是无用功——大多数时刻页面状态没变,你却一直在”问浏览器”。

而 Observer API 走的是浏览器内部调度 + 异步回调的路子:

  • 不需要你自己写循环。
  • 浏览器只在真的发生变化时才回调你。
  • 回调统一在 requestIdleCallback 或渲染管线的空闲阶段执行,不阻塞主线程。

一句话总结:Observer API 把”观察变化”这件事,从 JavaScript 拉模式(polling)变成了浏览器推模式(push)。

二、IntersectionObserver:判断元素是否可见#

2.1 核心概念#

IntersectionObserver 用来观察目标元素与视口(或指定祖先元素)的相交状态。它的构造函数签名是:

const observer = new IntersectionObserver(callback, options);

其中 options 主要字段:

字段含义默认值
root相交计算的根元素,默认为视口null
rootMargin根元素的扩展/收缩边距,类似 CSS margin"0px 0px 0px 0px"
threshold触发回调的相交比例阈值数组[0]

一个最简单的例子:

const box = document.querySelector('.box');
const observer = new IntersectionObserver(
(entries, observer) => {
for (const entry of entries) {
console.log('isIntersecting:', entry.isIntersecting);
console.log('intersectionRatio:', entry.intersectionRatio);
console.log('boundingClientRect:', entry.boundingClientRect);
console.log('time:', entry.time);
}
},
{
root: null, // 视口
rootMargin: '0px',
threshold: [0, 0.25, 0.5, 0.75, 1], // 多个阈值
}
);
observer.observe(box);

注意几个容易踩坑的细节

  1. entries 是一个数组,即使你只观察一个元素。这是因为 IntersectionObserver 是批量回调的——浏览器会把同一帧内所有发生变化的目标打包成一次回调。
  2. time 是高精度时间戳(DOMHighResTimeStamp),单位毫秒,可用于打点。
  3. 第一次注册观察时,会立刻异步触发一次回调,告诉你当前状态。buffered 模式不像 PerformanceObserver 那样需要显式声明。

2.2 threshold 的妙用#

threshold 数组决定什么时候回调。如果你写 threshold: [0.5],那只有相交比例跨过 50% 那一瞬间才会触发。

实际场景示例——图片懒加载

function lazyLoadImages() {
const images = document.querySelectorAll('img[data-src]');
const observer = new IntersectionObserver((entries, obs) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const img = entry.target;
img.src = img.dataset.src;
img.removeAttribute('data-src');
obs.unobserve(img); // 加载完成后停止观察
});
}, {
rootMargin: '200px 0px', // 提前 200px 开始加载
threshold: 0,
});
images.forEach((img) => observer.observe(img));
}
lazyLoadImages();

这里的 rootMargin: '200px 0px'关键优化——它把根元素的判定范围向下扩展 200px,相当于”在元素进入视口前 200px 就开始加载”,用户滚到图片时刚好显示出来,不会出现白屏闪烁。

2.3 实现无限滚动#

无限滚动的原理是观察列表底部的”哨兵元素”

function setupInfiniteScroll(container, sentinel, onLoadMore) {
const observer = new IntersectionObserver(
async (entries) => {
const entry = entries[0];
if (!entry.isIntersecting) return;
// 解锁:避免重复触发
observer.unobserve(sentinel);
await onLoadMore();
// 重新观察
observer.observe(sentinel);
},
{ root: container, rootMargin: '100px 0px' }
);
observer.observe(sentinel);
}

注意两个细节:

  • root: container 意味着我们关心的是容器内的可见性(不是视口),这在长列表外层有滚动容器时很关键。
  • unobserve + 加载完成后 observe 是一个防抖锁,防止用户在弱网下重复触发。

2.4 性能陷阱:大量元素的批量观察#

如果你的页面有 5000 张图片需要懒加载,一个 IntersectionObserver 实例观察 5000 个目标是没问题的——浏览器内部对它们做了统一调度。但千万不要给每个元素 new 一个 observer,那会创建 5000 个观察者实例,内存直接爆炸。

正确做法:单例 + 多次 observe

const globalObserver = new IntersectionObserver(handleIntersect, options);
document.querySelectorAll('img').forEach((img) => globalObserver.observe(img));

三、ResizeObserver:监听元素尺寸变化#

window.resize 只能告诉你”视口变了”。而 ResizeObserver 可以监听任意 DOM 元素的尺寸变化——这是它不可替代的地方。

3.1 基础用法#

const box = document.querySelector('.chart');
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
console.log(`size: ${width} x ${height}`);
}
});
ro.observe(box);

contentRect 是元素的内容盒(不包含 padding、border、margin),单位是 CSS 像素。

3.2 实战:响应式图表#

ECharts、Chart.js 这类图表库都需要在容器尺寸变化时调用 resize()

function bindChartResize(chart, container) {
let timer = null;
const ro = new ResizeObserver(() => {
clearTimeout(timer);
// 防抖:避免拖动窗口时每帧都重绘
timer = setTimeout(() => {
chart.resize();
}, 100);
});
ro.observe(container);
return ro;
}

3.3 容易踩的坑#

坑 1:观测 <html> 元素

如果你想监听视口变化,现代做法是优先用 window.resize + matchMedia,而不是 ResizeObserver(document.documentElement)。后者在某些浏览器(如旧版 Safari)会触发过于频繁,且会与视口 resize 事件循环。

坑 2:观测 display: none 的元素

display: none 元素尺寸为 0,不会触发回调。如果你依赖显示/隐藏元素后的尺寸变化,需要在元素变回可见后手动触发一次初始化。

坑 3:回调里的”循环触发”

// ❌ 错误:在回调里修改尺寸,会立刻再次触发回调
ro.observe(box);
ro.callback = (entries) => {
box.style.padding = '10px'; // 改了 box 的尺寸,又触发一次
};
// ✅ 正确:先断开观察,改完再观察
const ro = new ResizeObserver((entries) => {
ro.unobserve(box);
box.style.padding = '10px';
// 触发强制布局后重新观察
box.getBoundingClientRect();
ro.observe(box);
});

3.4 contentBoxSize 与 borderBoxSize#

ResizeObserverEntry 除了 contentRect,还有两个更现代的字段:

  • contentBoxSize:返回元素内容盒尺寸数组(支持多设备像素比)。
  • borderBoxSize:返回元素边框盒尺寸数组。
ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const [boxSize] = entry.contentBoxSize;
console.log('inlineSize:', boxSize.inlineSize); // 宽度
console.log('blockSize:', boxSize.blockSize); // 高度
}
});

⚠️ contentBoxSizeborderBoxSize 还在部分浏览器(如 Firefox 早期版本)需要 polyfill,生产环境请用 contentRect 作为兜底

四、MutationObserver:监听 DOM 变化#

MutationObserver 是 Observer 三件套里功能最强大、也最容易误用的一个。它可以观察 DOM 树的结构、属性、文本变化。

4.1 三种观察类型#

类型触发时机关键配置
childList目标子节点增删
attributes目标属性变化attributeOldValueattributeFilter
subtree目标及后代节点的 childList/attributes
characterData文本节点内容变化characterDataOldValue
const target = document.querySelector('#app');
const mo = new MutationObserver((mutations, observer) => {
for (const m of mutations) {
console.log('type:', m.type);
if (m.type === 'childList') {
console.log('added:', m.addedNodes);
console.log('removed:', m.removedNodes);
} else if (m.type === 'attributes') {
console.log('attr:', m.attributeName, 'old:', m.oldValue, 'new:', m.target.getAttribute(m.attributeName));
}
}
});
mo.observe(target, {
childList: true,
attributes: true,
subtree: true,
attributeOldValue: true,
characterData: true,
characterDataOldValue: true,
attributeFilter: ['class', 'data-state'],
});

4.2 实战:自动重新执行第三方脚本#

很多 SaaS SDK(如客服、统计)会在你 appendChild 之前检查元素是否已经存在。手动追踪太麻烦,用 MutationObserver 即可:

function watchForElement(selector, onFound) {
const mo = new MutationObserver((mutations) => {
if (document.querySelector(selector)) {
onFound();
mo.disconnect();
}
});
mo.observe(document.body, { childList: true, subtree: true });
}

4.3 实战:富文本编辑器的内容同步#

function syncEditorState(editor, onChange) {
let debounceTimer = null;
const mo = new MutationObserver(() => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
onChange(editor.innerHTML);
}, 50);
});
mo.observe(editor, {
childList: true,
subtree: true,
characterData: true,
});
}

4.4 性能陷阱:subtree 是性能怪兽#

subtree: true 意味着目标节点下所有后代的变化都会触发回调。在大型 SPA(Vue/React 应用)下,状态更新会导致大量 DOM 变化,回调可能被调用上百次/秒

生产建议

  1. 尽量缩小观察范围:只观察你需要的那一层 DOM。
  2. 必须使用 subtree 时,加防抖
function debouncedMutation(target, callback, delay = 50) {
let timer = null;
const mo = new MutationObserver(() => {
clearTimeout(timer);
timer = setTimeout(() => {
callback();
}, delay);
});
mo.observe(target, { childList: true, subtree: true, attributes: true });
return mo;
}
  1. 不要用 MutationObserver 替代框架的响应式系统。Vue/React 内部已经有 VDOM diff,在业务代码里再加一层 DOM diff 是重复劳动。

五、组合实战:通用 Observer SDK#

把三者组合起来,我们写一个生产级的 SDK,封装懒加载、无限滚动、自适应图表三大能力。

class ObserverKit {
constructor() {
this.intersectionObserver = null;
this.resizeObservers = new Map();
}
// 1) 图片懒加载
lazyImages(selector = 'img[data-src]', { rootMargin = '200px 0px' } = {}) {
if (!('IntersectionObserver' in window)) {
// 降级:直接加载所有图片
document.querySelectorAll(selector).forEach((img) => {
img.src = img.dataset.src;
});
return null;
}
const observer = new IntersectionObserver((entries, obs) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const img = entry.target;
img.src = img.dataset.src;
img.removeAttribute('data-src');
obs.unobserve(img);
});
}, { rootMargin, threshold: 0 });
document.querySelectorAll(selector).forEach((img) => observer.observe(img));
this.intersectionObserver = observer;
return observer;
}
// 2) 无限滚动
infiniteScroll(sentinel, container, onLoadMore, { rootMargin = '100px 0px' } = {}) {
let loading = false;
const observer = new IntersectionObserver(async (entries) => {
if (loading || !entries[0].isIntersecting) return;
loading = true;
observer.unobserve(sentinel);
try {
await onLoadMore();
} finally {
loading = false;
observer.observe(sentinel);
}
}, { root: container, rootMargin, threshold: 0 });
observer.observe(sentinel);
return observer;
}
// 3) 元素尺寸变化(图表自适应)
watchSize(target, callback, { debounce = 100 } = {}) {
if (!('ResizeObserver' in window)) {
window.addEventListener('resize', () => callback(target));
return null;
}
let timer = null;
const ro = new ResizeObserver(() => {
clearTimeout(timer);
timer = setTimeout(() => callback(target), debounce);
});
ro.observe(target);
this.resizeObservers.set(target, ro);
return ro;
}
// 4) DOM 变化 + 防抖
watchDOM(target, callback, { debounce = 50, attributes = true, childList = true } = {}) {
if (!('MutationObserver' in window)) return null;
let timer = null;
const mo = new MutationObserver(() => {
clearTimeout(timer);
timer = setTimeout(callback, debounce);
});
mo.observe(target, { attributes, childList, subtree: true });
return mo;
}
destroy() {
this.intersectionObserver?.disconnect();
this.resizeObservers.forEach((ro) => ro.disconnect());
this.resizeObservers.clear();
}
}

使用示例:

const kit = new ObserverKit();
// 启动懒加载
kit.lazyImages();
// 无限滚动
const list = document.querySelector('#feed');
const sentinel = document.querySelector('#sentinel');
kit.infiniteScroll(sentinel, list, async () => {
const items = await fetchMoreItems();
items.forEach((it) => list.appendChild(renderItem(it)));
});
// 图表自适应
const chartEl = document.querySelector('#chart');
const chart = echarts.init(chartEl);
kit.watchSize(chartEl, () => chart.resize());

六、常见问题清单#

现象原因解决方案
第一次回调拿到的 isIntersecting 永远不准observer 是异步初始化的entry.intersectionRatio 兜底,或在 DOMContentLoaded 后再 observe
滚动时回调过多导致掉帧没有防抖给回调加 requestAnimationFramesetTimeout 防抖
元素从 DOM 移除后回调还在跑没有 unobserve在 Vue/React 的 onUnmounted / useEffect cleanup 中 disconnect
ResizeObserverResizeObserver loop completed with undelivered notifications回调里又改了被观察元素的尺寸在回调里加 unobserve → 修改 → 强制布局 → observe
列表懒加载时白屏rootMargin 设置太小提前 200~500px 开始加载

七、结语#

Observer API 是浏览器给前端开发者的”礼物”——它把”被动轮询”变成了”主动推送”,让我们用几十行代码就能实现以前几百行 hack 才能做到的效果。掌握三件套的关键在于:

  1. 理解时机:Observer 回调都是异步、批量的,不要假设同步执行。
  2. 控制范围:单个 observer 实例可以观察多个目标,但回调里不要做重活
  3. 清理资源:组件卸载时记得 disconnect,否则会内存泄漏。

把这套思路内化后,你会发现很多”性能优化”的场景其实都可以用 Observer 来重写——用浏览器原生的能力,去做浏览器原生擅长的事

延伸阅读#

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

前端 Observer API 全家桶:Intersection、Resize 与 MutationObserver 深度实战
https://boke.hackerdream.xyz/posts/frontend-observer-api-deep/
作者
晴天
发布于
2026-06-11
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
晴天
Hello, I'm 晴天.
公告
欢迎来到我的博客!这是一则示例公告。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
155
分类
24
标签
387
总字数
345,424
运行时长
0
最后活动
0 天前

目录