C07 观察者与 Web API
C07 观察者与 Web API
摘要:三类观察者替代了轮询与全局监听:
IntersectionObserver在元素进入视口时回调,用于懒加载与曝光统计;ResizeObserver监听元素尺寸变化,用于图表与自适应组件;MutationObserver监听 DOM 结构或属性变化,用于第三方脚本适配。三者都在浏览器渲染前批量回调,不占用每次滚动的计算。本文给出三类观察者的完整写法、回调时机与两个补充 API(requestIdleCallback、matchMedia)。
关键词:IntersectionObserver,ResizeObserver,MutationObserver,懒加载,空闲回调
所属:frontend/js / C-高级 | 上一篇:C06-深拷贝与结构化克隆 | 下一篇:C08-History与前端路由 | 返回:C00-总结
1 引言
滚动事件里读取元素位置、窗口 resize 里重算布局、setInterval 检查 DOM 变化,三者的共同问题是:在主线程上反复读取布局,每次都可能触发同步布局计算。
本文给出三类观察者 API 的替代写法。范围见表 1-1。
| 项 | 本文覆盖 | 本文不覆盖 |
|---|---|---|
| 可见性 | IntersectionObserver 阈值与根元素 |
虚拟列表实现 |
| 尺寸 | ResizeObserver 与循环触发的规避 |
容器查询(CSS C01) |
| 结构 | MutationObserver 配置与批量处理 |
框架变更检测机制 |
| 补充 | requestIdleCallback、matchMedia |
性能剖析(C03) |
表 1-1 本文覆盖范围
2 预备知识
| 项 | 要求 | 核对方式 |
|---|---|---|
| 环境 | Chrome 130+,本地服务器 | 页面经 http 打开 |
| 前置 | A08、C03 | DOM 与长任务已掌握 |
| 文件 | observe/index.html、observe/app.js |
编码 UTF-8 |
表 2-1 环境与前置要求
3 原理与实现
3.1 观察者由浏览器在渲染前批量回调
注册后由浏览器在合适时机统一回调,不在每次滚动或每次 DOM 变更时同步执行,如图 3-1 所示。
flowchart TD
A["注册观察者"] --> B["浏览器在后台跟踪目标"]
B --> C{"达到阈值或发生变化"}
C -->|否| B
C -->|是| D["把变更放入队列"]
D --> E["渲染前的回调阶段批量执行"]
E --> F["回调内收到 entries 数组"]
F --> G["回调中避免同步读布局,防止循环触发"]
图 3-1 观察者的回调时机位于渲染之前
图上可见:回调收到的是批量条目而非单个事件,回调内修改被观察的属性会再次排队,形成循环。
3.2 完整实现
<!-- observe/index.html;运行环境:Chrome 130+ -->
<div class="spacer">向下滚动</div>
<img class="lazy" data-src="/img/a.jpg" alt="A" width="800" height="450" />
<img class="lazy" data-src="/img/b.jpg" alt="B" width="800" height="450" />
<div id="chart" class="chart"></div>
<div id="third-party"></div>
// observe/app.js;运行环境:Chrome 130+
// 1 懒加载:元素进入视口前不发起请求
const lazyObserver = new IntersectionObserver(
(entries, observer) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const img = entry.target;
img.src = img.dataset.src;
img.removeAttribute('data-src');
observer.unobserve(img); // 加载后停止观察
}
},
{ root: null, rootMargin: '200px 0px', threshold: 0.01 },
);
for (const img of document.querySelectorAll('.lazy')) lazyObserver.observe(img);
// 2 曝光统计:达到可见比例才算一次曝光
const exposure = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.intersectionRatio >= 0.5) {
console.log('exposed', entry.target.id);
exposure.unobserve(entry.target);
}
}
},
{ threshold: [0.5] },
);
// 3 尺寸变化:回调内只改不读,避免循环触发
const chart = document.querySelector('#chart');
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { inlineSize, blockSize } = entry.contentBoxSize[0];
// 写入尺寸,不在回调里读 offsetWidth
chart.dataset.w = String(Math.round(inlineSize));
chart.dataset.h = String(Math.round(blockSize));
}
});
resizeObserver.observe(chart);
// 4 DOM 变更:第三方脚本插入内容后做适配
const mutationObserver = new MutationObserver((records) => {
for (const record of records) {
if (record.type === 'childList' && record.addedNodes.length > 0) {
const added = [...record.addedNodes].filter((n) => n.nodeType === 1);
for (const node of added) node.classList.add('normalized');
}
}
});
mutationObserver.observe(document.querySelector('#third-party'), {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class'],
});
// 5 空闲时间执行低优先级任务
const pending = [() => console.log('低优先级任务')];
requestIdleCallback(
(deadline) => {
while (deadline.timeRemaining() > 1 && pending.length > 0) {
pending.shift()();
}
},
{ timeout: 2000 },
);
// 6 媒体查询变化:跟随系统偏好
const dark = matchMedia('(prefers-color-scheme: dark)');
dark.addEventListener('change', (event) => {
document.documentElement.dataset.theme = event.matches ? 'dark' : 'light';
});
// 7 卸载时统一断开
const controller = new AbortController();
window.addEventListener('pagehide', () => {
lazyObserver.disconnect();
resizeObserver.disconnect();
mutationObserver.disconnect();
controller.abort();
}, { signal: controller.signal });
三类观察者的配置与用途见表 3-1。
| 观察者 | 触发条件 | 关键配置 | 典型用途 |
|---|---|---|---|
IntersectionObserver |
目标与根的相交比例变化 | root、rootMargin、threshold |
懒加载、曝光、吸顶 |
ResizeObserver |
目标内容盒尺寸变化 | box: 'content-box' |
图表、自适应组件 |
MutationObserver |
子节点或属性变化 | childList、attributes、subtree |
第三方脚本适配 |
表 3-1 三类观察者的触发条件与配置
与旧做法的对比见表 3-2。
| 需求 | 旧做法 | 观察者方案 | 差异 |
|---|---|---|---|
| 判断元素是否可见 | 滚动事件 + getBoundingClientRect |
IntersectionObserver |
不再每次滚动同步读布局 |
| 跟踪元素尺寸 | window.resize + 读 offsetWidth |
ResizeObserver |
能感知元素自身而非视口 |
| 感知 DOM 插入 | setInterval 轮询 |
MutationObserver |
变更即回调,无空转 |
| 低优先级任务 | setTimeout(0) |
requestIdleCallback |
让出主线程给交互 |
表 3-2 观察者与传统写法的差异
4 验证与讨论
- 打开页面并向下滚动,确认图片在进入视口前 200 像素时开始加载。
- 在开发者工具中改变
#chart的宽度,确认data-w随之更新且控制台无循环警告。 - 在控制台执行
document.querySelector('#third-party').append(document.createElement('span')),确认新节点被加上normalized类。 - 切换系统的深色模式,确认
data-theme自动变化。
| 检查项 | 预期结果 | 说明 |
|---|---|---|
| 懒加载 | 未进入视口时不发起请求 | 3.2 节 |
| 阈值生效 | 可见比例达标才计曝光 | 表 3-1 |
| 尺寸跟踪 | 元素宽度变化即回调 | 表 3-2 |
| DOM 适配 | 动态插入节点被处理 | 3.2 节 |
| 清理 | disconnect 后不再回调 |
3.2 节第 7 段 |
表 4-1 五项验收
讨论与局限。 ResizeObserver 回调内若同步读取布局属性会触发 ResizeObserver loop completed with undelivered notifications 警告,做法是把读写分离到下一帧。另,IntersectionObserver 的首帧回调在观察开始后异步到达,首屏元素需要单独处理初始状态。
5 常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 懒加载图片不显示 | data-src 未替换或路径错误 |
检查 Network 面板与属性值 |
| 尺寸回调报循环警告 | 回调内同步读布局 | 只写不读,或放到 requestAnimationFrame |
| 首屏元素未触发可见回调 | 初始回调异步到达 | 初始化时先按可见处理一次 |
MutationObserver 漏掉变化 |
未开 subtree 或过滤了属性 |
补齐配置与 attributeFilter |
| 组件销毁后仍回调 | 未 disconnect |
在卸载钩子中统一断开 |
| 空闲回调从不执行 | 页面持续繁忙 | 加 timeout 保证兜底执行 |
表 5-1 观察者的六类问题
6 结论
| 序号 | 结论 | 依据 |
|---|---|---|
| 1 | 可见性与尺寸判断一律用观察者,不用滚动事件 | 表 3-2 |
| 2 | 回调内只写不读,避免循环触发 | 图 3-1 |
| 3 | 观察完成后 unobserve 或 disconnect |
3.2 节 |
| 4 | 低优先级任务放 requestIdleCallback |
表 3-2 |
表 6-1 结论汇总
- 上一篇:C06-深拷贝与结构化克隆
- 下一篇:C08-History与前端路由
- 返回:C00-总结 | 科目 README
参考文献
[1] MDN Web Docs · Intersection Observer API,https://developer.mozilla.org/zh-CN/docs/Web/API/Intersection_Observer_API(访问于 2026-10-31)
[2] MDN Web Docs · MutationObserver,https://developer.mozilla.org/zh-CN/docs/Web/API/MutationObserver(访问于 2026-10-31)

评论 0
还没有评论,快来抢沙发吧~