Skip to content

页面生命周期与观察器:等待变化,而不是不停猜测 ​

如果每 100 毫秒遍历页面检查节点是否增加、元素是否变宽、图片是否进入屏幕,程序像一位不停开门查快递的住户。浏览器已经提供专门通知机制,关键是选对变化类型。

就绪、可见和下一帧不是一回事 ​

DOMContentLoaded 关注初始文档解析等过程;load 还等待相关资源。visibilitychange 告诉你文档可见性变化,可以结合 document.hidden 暂停非必要工作。它不能证明用户正在认真阅读,窗口可见也不等于用户视线在那里。

requestAnimationFrame 请求在下一次合适的绘制更新前运行回调,适合动画。回调只执行一次,要继续就再请求;它不是固定每 16 毫秒的计时器,后台页面可能暂停或降频。使用回调时间戳计算位移,不能假设每次前进固定像素就能在不同刷新率下保持相同速度。

三种观察器各有一张通知单 ​

API观察内容不应推断
MutationObserver节点、属性、文本等 DOM 变更页面已经完成绘制
ResizeObserver元素的尺寸变化只由窗口缩放引起
IntersectionObserver元素与指定根的交叉状态变化用户确实看到了内容、逐像素实时轨迹

下面的 HTML 可直接打开。点击按钮改变盒子内容,控制台会看到相关观察回调,实际次数不保证与点击次数严格一一对应。

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 可直接打开:

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。想根据尺寸再写样式时,先判断新尺寸是否已经满足目标,避免每收到一次通知就无条件改变尺寸,制造反馈循环。

防抖与节流保留的信息不同 ​

防抖等待一段安静时间,适合“编辑暂时停下来再保存”;节流限制处理频率,适合持续滚动时定期更新。若使用防抖,用户一直输入就可能一直不执行,这是其定义带来的取舍。

js
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,取消接口允许页面离开时停止待执行任务。它是尾沿防抖,不承诺立即执行或最大等待时间。

视觉更新也可用一帧最多安排一次的方式合并:

js
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;
}

这是按绘制机会合并,不是严格的每秒固定次数。网络请求竞态也不会因为防抖就消失,仍需要上一节的过期结果检查。

练习 ​

  1. 元素因为旁边内容换行而变高,应选哪个观察器,为什么监听 window resize 不够?

  2. 连续输入十秒,希望至少每两秒保存一次,单纯尾沿防抖是否满足要求?

  3. 两次属性变化的记录为什么都读到当前值b?如果takeRecords后再等待回调,是否会再次得到同批记录?

  4. 修改观察盒子示例:只统计新加入的直接 p 元素数量,把总数显示在盒子外的 output 中。一次 append 两个 p 应增加 2;加入文本节点不增加;停止观察后再改 DOM 不增加。

查看本节答案

用心记录每一步成长