前端 Observer API 全家桶:Intersection、Resize 与 MutationObserver 深度实战
“这段元素到底有没有出现在视口里?”、“这个 DOM 是不是被第三方偷偷改了?”、“父容器尺寸变了,我的图表怎么重绘?”
三个看似不相干的问题,其实都指向浏览器原生提供的一组观察者 API。它们取代了过去靠
scroll+getBoundingClientRect、setInterval+ 轮询、DOMAttrModified这类”反模式”才能做到的事情。本文会带你深入 IntersectionObserver、ResizeObserver、MutationObserver 三大核心 API,从原理到实战,再到组合使用,最后封装一个生产级的”懒加载 + 无限滚动 + 自适应布局”通用 SDK。
一、为什么我们需要 Observer API
在 Observer API 出现之前,前端要”观察”页面状态变化,基本只有两个选择:
- 事件驱动:监听
scroll、resize、click等事件,事件触发时手动检查。 - 轮询:
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);注意几个容易踩坑的细节:
entries是一个数组,即使你只观察一个元素。这是因为 IntersectionObserver 是批量回调的——浏览器会把同一帧内所有发生变化的目标打包成一次回调。time是高精度时间戳(DOMHighResTimeStamp),单位毫秒,可用于打点。- 第一次注册观察时,会立刻异步触发一次回调,告诉你当前状态。
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); // 高度 }});⚠️
contentBoxSize和borderBoxSize还在部分浏览器(如 Firefox 早期版本)需要 polyfill,生产环境请用contentRect作为兜底。
四、MutationObserver:监听 DOM 变化
MutationObserver 是 Observer 三件套里功能最强大、也最容易误用的一个。它可以观察 DOM 树的结构、属性、文本变化。
4.1 三种观察类型
| 类型 | 触发时机 | 关键配置 |
|---|---|---|
childList | 目标子节点增删 | 无 |
attributes | 目标属性变化 | attributeOldValue、attributeFilter |
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 变化,回调可能被调用上百次/秒。
生产建议:
- 尽量缩小观察范围:只观察你需要的那一层 DOM。
- 必须使用 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;}- 不要用 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 |
| 滚动时回调过多导致掉帧 | 没有防抖 | 给回调加 requestAnimationFrame 或 setTimeout 防抖 |
| 元素从 DOM 移除后回调还在跑 | 没有 unobserve | 在 Vue/React 的 onUnmounted / useEffect cleanup 中 disconnect |
ResizeObserver 报 ResizeObserver loop completed with undelivered notifications | 回调里又改了被观察元素的尺寸 | 在回调里加 unobserve → 修改 → 强制布局 → observe |
| 列表懒加载时白屏 | rootMargin 设置太小 | 提前 200~500px 开始加载 |
七、结语
Observer API 是浏览器给前端开发者的”礼物”——它把”被动轮询”变成了”主动推送”,让我们用几十行代码就能实现以前几百行 hack 才能做到的效果。掌握三件套的关键在于:
- 理解时机:Observer 回调都是异步、批量的,不要假设同步执行。
- 控制范围:单个 observer 实例可以观察多个目标,但回调里不要做重活。
- 清理资源:组件卸载时记得
disconnect,否则会内存泄漏。
把这套思路内化后,你会发现很多”性能优化”的场景其实都可以用 Observer 来重写——用浏览器原生的能力,去做浏览器原生擅长的事。
延伸阅读
- MDN - IntersectionObserver
- MDN - ResizeObserver
- MDN - MutationObserver
- web.dev - IntersectionObserver 性能优化
- Performance Observer API 实战:前端性能监控的终极方案 — 同系列文章
- 前端事件循环与微任务 — 理解 Observer 回调的执行时机
- JavaScript Proxy 与元编程 — 另一种”观察对象变化”的范式
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!