页面生命周期与观察器:等待变化,而不是不停猜测
如果每 100 毫秒遍历页面检查节点是否增加、元素是否变宽、图片是否进入屏幕,程序像一位不停开门查快递的住户。浏览器已经提供专门通知机制,关键是选对变化类型。
就绪、可见和下一帧不是一回事
DOMContentLoaded 关注初始文档解析等过程;load 还等待相关资源。visibilitychange 告诉你文档可见性变化,可以结合 document.hidden 暂停非必要工作。它不能证明用户正在认真阅读,窗口可见也不等于用户视线在那里。
requestAnimationFrame 请求在下一次合适的绘制更新前运行回调,适合动画。回调只执行一次,要继续就再请求;它不是固定每 16 毫秒的计时器,后台页面可能暂停或降频。使用回调时间戳计算位移,不能假设每次前进固定像素就能在不同刷新率下保持相同速度。
三种观察器各有一张通知单
| API | 观察内容 | 不应推断 |
|---|---|---|
| MutationObserver | 节点、属性、文本等 DOM 变更 | 页面已经完成绘制 |
| ResizeObserver | 元素的尺寸变化 | 只由窗口缩放引起 |
| IntersectionObserver | 元素与指定根的交叉状态变化 | 用户确实看到了内容、逐像素实时轨迹 |
下面的 HTML 可直接打开。点击按钮改变盒子内容,控制台会看到相关观察回调,实际次数不保证与点击次数严格一一对应。
<!doctype html>
<meta charset="utf-8">
<title>观察变化</title>
<button id="add" type="button">增加一行</button>
<div id="box" style="border:1px solid; width:240px">观察区域</div>
<script>
const box = document.querySelector('#box');
const controller = new AbortController();
document.querySelector('#add').addEventListener('click', () => {
const line = document.createElement('p');
line.textContent = '增加了一行内容';
box.append(line);
}, { signal: controller.signal });
const mutations = new MutationObserver(records => {
console.log('DOM 变更记录:', records.length);
});
mutations.observe(box, { childList: true });
const sizes = new ResizeObserver(entries => {
console.log('内容高度:', entries[0].contentRect.height);
});
sizes.observe(box);
const intersections = new IntersectionObserver(entries => {
console.log('交叉状态:', entries[0].isIntersecting);
});
intersections.observe(box);
window.stopObservation = () => {
controller.abort();
mutations.disconnect();
sizes.disconnect();
intersections.disconnect();
};
</script>MutationObserver 通知以异步方式批量交付,配置 subtree: true 才会覆盖所选子树范围;本例仅关心直接子节点增加。不要在回调里无条件修改同一个被观察目标,否则可能反复触发自己。
ResizeObserver 回调中改变被观察元素尺寸也可能形成反馈循环。将写操作延后不自动消除逻辑循环,仍要确保最终能稳定。IntersectionObserver 的 threshold 指定关心的交叉比例阈值,rootMargin 可以提前触发加载;它不是精确遮挡检测的万能替代。
在控制台调用 stopObservation() 后,新变化不会继续被这些观察器处理。真实组件销毁时应执行同类清理。
一次回调为什么能收到多次修改
MutationObserver 记录 DOM 操作,再异步交付记录。记录的 target 是同一个活节点;在回调里读取 target 的当前属性,会看见当前状态,未必是每次修改发生当时的状态。
下面完整 HTML 可直接打开:
<!doctype html>
<meta charset="utf-8">
<title>修改记录与当前值</title>
<div id="sample"></div>
<pre id="log"></pre>
<script>
const sample = document.querySelector('#sample');
const log = document.querySelector('#log');
const observer = new MutationObserver(records => {
log.textContent += `回调记录数:${records.length}\n`;
for (const record of records) {
log.textContent += `旧值=${record.oldValue} 当前值=${record.target.getAttribute('data-step')}\n`;
}
observer.disconnect();
});
observer.observe(sample, {
attributes: true,
attributeFilter: ['data-step'],
attributeOldValue: true
});
sample.setAttribute('data-step', 'a');
sample.setAttribute('data-step', 'b');
log.textContent += '同步修改完成\n';
</script>先显示“同步修改完成”,随后收到两条记录。它们的旧值依次为 null、a,而两次读取当前值都是 b。MutationRecord 没有一个通用 newValue 字段;若把 target 当前值当成每一步的新值,就会把历史过程错误地记成 null→b、a→b。
只观察自己需要的属性有两个好处:减少无关记录,也能避免回调更新日志时又观察到日志本身,导致通知反复触发。textContent 替换整个文本结构还可能体现为 childList 变化;修改已有文本节点的 data 才对应 characterData。选择配置前先说明要观察的是结构还是字符内容。
若关闭前必须处理待交付记录,先 observer.takeRecords() 取出队列,再 disconnect;takeRecords 会清空这批记录,后续回调不会再替你处理一次。若不需要尾部变化,直接 disconnect 即可。清理策略由是否允许丢弃未处理变化决定。
ResizeObserver 与 IntersectionObserver 的通知和布局/渲染更新有关,不能把本例的 MutationObserver 顺序硬套给所有 observer。想根据尺寸再写样式时,先判断新尺寸是否已经满足目标,避免每收到一次通知就无条件改变尺寸,制造反馈循环。
防抖与节流保留的信息不同
防抖等待一段安静时间,适合“编辑暂时停下来再保存”;节流限制处理频率,适合持续滚动时定期更新。若使用防抖,用户一直输入就可能一直不执行,这是其定义带来的取舍。
function debounce(fn, delay) {
let timer;
function wrapped(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
}
wrapped.cancel = () => clearTimeout(timer);
return wrapped;
}这里保留最近一次调用的参数与 this,取消接口允许页面离开时停止待执行任务。它是尾沿防抖,不承诺立即执行或最大等待时间。
视觉更新也可用一帧最多安排一次的方式合并:
function frameThrottle(fn) {
let frame = null;
let latest;
function wrapped(...args) {
latest = { context: this, args };
if (frame !== null) return;
frame = requestAnimationFrame(() => {
frame = null;
fn.apply(latest.context, latest.args);
});
}
wrapped.cancel = () => {
if (frame !== null) cancelAnimationFrame(frame);
frame = null;
latest = null;
};
return wrapped;
}这是按绘制机会合并,不是严格的每秒固定次数。网络请求竞态也不会因为防抖就消失,仍需要上一节的过期结果检查。
练习
元素因为旁边内容换行而变高,应选哪个观察器,为什么监听 window resize 不够?
连续输入十秒,希望至少每两秒保存一次,单纯尾沿防抖是否满足要求?
两次属性变化的记录为什么都读到当前值b?如果takeRecords后再等待回调,是否会再次得到同批记录?
修改观察盒子示例:只统计新加入的直接 p 元素数量,把总数显示在盒子外的 output 中。一次 append 两个 p 应增加 2;加入文本节点不增加;停止观察后再改 DOM 不增加。