Mutation Observer API

深入理解 Mutation Observer API 如何监听 DOM 变化、其与已废弃 MutationEvent 的区别、以及在框架实现和自动化测试中的应用。

Mutation Observer API(Mutation Observer API)

一、MutationObserver 的背景

1.1 从 MutationEvent 到 MutationObserver

在 MutationObserver 出现之前,DOM 变化是通过 MutationEvent API 来监听的。但 MutationEvent 有严重的设计缺陷:它是同步的。这意味着每次 DOM 变化都会立即触发事件,在大量 DOM 操作时会导致严重的性能问题。

// 已废弃的 MutationEvent
element.addEventListener('DOMNodeInserted', (event) => {
  console.log('Node inserted:', event.target);
});

MutationEvent 的问题包括:

  • 同步执行,性能差
  • 不支持观察子树的所有变化
  • API 设计不合理,很多事件类型从未被正确实现

1.2 MutationObserver 的设计

MutationObserver 是异步观察者模式的应用:

  1. 异步执行:DOM 变化不会立即触发回调,而是被批量收集后在微任务队列中执行
  2. 按需观察:可以精确指定要观察的变化类型
  3. 低开销:浏览器内部优化,不需要重复扫描整个 DOM 树
// 基本的 MutationObserver 使用
const observer = new MutationObserver((mutations) => {
  mutations.forEach((mutation) => {
    console.log('Type:', mutation.type);
    console.log('Target:', mutation.target);
  });
});

observer.observe(document.body, {
  childList: true,
  subtree: true
});

二、API 详解

2.1 创建观察者

const observer = new MutationObserver(callback);

// callback 接收 MutationRecord 数组
function callback(mutations, observer) {
  mutations.forEach(mutation => {
    // 处理每个变化
  });
}

2.2 observe 方法

observer.observe(target, options);

// options 定义要观察的变化类型
const options = {
  // 观察子节点的添加/删除
  childList: false,

  // 观察后代节点的变化
  subtree: false,

  // 观察属性的添加/删除/修改
  attributes: false,

  // 观察属性值的变化(需要 attributes: true)
  attributeOldValue: false,

  // 观察文本内容的变化
  characterData: false,

  // 观察文本节点的旧值(需要 characterData: true)
  characterDataOldValue: false,

  // 只观察指定属性
  attributeFilter: ['class', 'data-*']
};

2.3 MutationRecord 类型

mutations.forEach(mutation => {
  switch (mutation.type) {
    case 'childList':
      // 子节点的添加、删除、重排序
      console.log('Added nodes:', mutation.addedNodes);
      console.log('Removed nodes:', mutation.removedNodes);
      console.log('Previous sibling:', mutation.previousSibling);
      console.log('Next sibling:', mutation.nextSibling);
      break;

    case 'attributes':
      // 属性变化
      console.log('Attribute name:', mutation.attributeName);
      console.log('Old value:', mutation.oldValue);
      console.log('New value:', mutation.target.getAttribute(mutation.attributeName));
      break;

    case 'characterData':
      // 文本节点内容变化
      console.log('Old value:', mutation.oldValue);
      console.log('New value:', mutation.target.textContent);
      break;
  }
});

三、实际应用场景

3.1 动态内容检测

// 检测页面动态加载的内容
class DynamicContentDetector {
  constructor() {
    this.observer = new MutationObserver((mutations) => {
      mutations.forEach(mutation => {
        mutation.addedNodes.forEach(node => {
          if (node.nodeType === Node.ELEMENT_NODE) {
            this.onElementAdded(node);
          }
        });
      });
    });
  }

  start() {
    this.observer.observe(document.body, {
      childList: true,
      subtree: true
    });
  }

  stop() {
    this.observer.disconnect();
  }

  onElementAdded(element) {
    // 检测脚本标签
    if (element.tagName === 'SCRIPT') {
      console.log('Script added:', element.src || 'inline');
    }

    // 检测图片懒加载
    if (element.tagName === 'IMG' && element.dataset.src) {
      console.log('Lazy image detected');
    }

    // 检测第三方嵌入
    if (element.tagName === 'IFRAME') {
      console.log('iframe embedded:', element.src);
    }
  }
}

3.2 表单变化追踪

// 追踪表单字段的变化
class FormChangeTracker {
  constructor(form) {
    this.form = form;
    this.originalValues = new Map();
    this.changedFields = new Set();
    this.observer = null;

    this.init();
  }

  init() {
    // 记录初始值
    this.form.querySelectorAll('input, select, textarea').forEach(field => {
      this.originalValues.set(field, this.getFieldValue(field));
    });

    // 观察变化
    this.observer = new MutationObserver((mutations) => {
      mutations.forEach(mutation => {
        if (mutation.type === 'childList') {
          mutation.addedNodes.forEach(node => {
            if (node.nodeType === Node.ELEMENT_NODE) {
              this.trackNewFields(node);
            }
          });
          mutation.removedNodes.forEach(node => {
            if (node.nodeType === Node.ELEMENT_NODE) {
              this.untrackFields(node);
            }
          });
        }
      });
    });

    this.observer.observe(this.form, {
      childList: true,
      subtree: true
    });

    // 监听 input 事件
    this.form.addEventListener('input', (e) => {
      this.onFieldChange(e.target);
    });
  }

  getFieldValue(field) {
    if (field.type === 'checkbox' || field.type === 'radio') {
      return field.checked;
    }
    return field.value;
  }

  trackNewFields(root) {
    root.querySelectorAll('input, select, textarea').forEach(field => {
      if (!this.originalValues.has(field)) {
        this.originalValues.set(field, this.getFieldValue(field));
      }
    });
  }

  untrackFields(root) {
    root.querySelectorAll('input, select, textarea').forEach(field => {
      this.originalValues.delete(field);
      this.changedFields.delete(field);
    });
  }

  onFieldChange(field) {
    const original = this.originalValues.get(field);
    const current = this.getFieldValue(field);

    if (original !== current) {
      this.changedFields.add(field);
    } else {
      this.changedFields.delete(field);
    }
  }

  hasUnsavedChanges() {
    return this.changedFields.size > 0;
  }

  getChangedFields() {
    return Array.from(this.changedFields).map(field => ({
      name: field.name,
      original: this.originalValues.get(field),
      current: this.getFieldValue(field)
    }));
  }

  destroy() {
    this.observer.disconnect();
  }
}

3.3 实现简易响应式数据绑定

// 基于 MutationObserver 的简易数据绑定
class ReactiveBinder {
  constructor() {
    this.bindings = new Map();
    this.observer = new MutationObserver((mutations) => {
      this.handleMutations(mutations);
    });
  }

  // 绑定数据源到元素
  bind(source, expression, element, property = 'textContent') {
    if (!this.bindings.has(source)) {
      this.bindings.set(source, new Set());
    }

    this.bindings.get(source).add({ expression, element, property });
    this.updateBinding(source, expression, element, property);
  }

  // 开始观察
  observe(root) {
    this.observer.observe(root, {
      childList: true,
      subtree: true,
      characterData: true
    });
  }

  handleMutations(mutations) {
    mutations.forEach(mutation => {
      if (mutation.type === 'characterData') {
        // 文本变化,触发更新
        const source = mutation.target;
        const bindings = this.bindings.get(source);
        if (bindings) {
          bindings.forEach(binding => {
            this.updateBinding(source, binding.expression, binding.element, binding.property);
          });
        }
      }
    });
  }

  updateBinding(source, expression, element, property) {
    try {
      // 简单表达式解析
      const value = expression(source);
      element[property] = value;
    } catch (e) {
      // 忽略绑定错误
    }
  }

  disconnect() {
    this.observer.disconnect();
  }
}

四、与框架的集成

4.1 虚拟 DOM diffing 辅助

// 使用 MutationObserver 辅助虚拟 DOM 对比
class VDOMObserver {
  constructor(root, render) {
    this.root = root;
    this.render = render;
    this.observer = null;
    this.pending = false;

    this.init();
  }

  init() {
    this.observer = new MutationObserver(() => {
      if (!this.pending) {
        this.pending = true;
        requestAnimationFrame(() => {
          this.reconcile();
          this.pending = false;
        });
      }
    });

    this.observer.observe(this.root, {
      childList: true,
      subtree: true,
      attributes: true,
      characterData: true
    });
  }

  reconcile() {
    // 触发重新渲染
    // 实际框架会进行精细的 diff
    this.render();
  }

  disconnect() {
    this.observer.disconnect();
  }
}

4.2 自动化测试中的 DOM 监控

// 测试工具:等待特定 DOM 变化
async function waitForElement(selector, timeout = 5000) {
  const element = document.querySelector(selector);
  if (element) return element;

  return new Promise((resolve, reject) => {
    const timeoutId = setTimeout(() => {
      observer.disconnect();
      reject(new Error(`Element ${selector} not found within ${timeout}ms`));
    }, timeout);

    const observer = new MutationObserver((mutations) => {
      mutations.forEach(mutation => {
        mutation.addedNodes.forEach(node => {
          if (node.nodeType === Node.ELEMENT_NODE) {
            if (node.matches && node.matches(selector)) {
              clearTimeout(timeoutId);
              observer.disconnect();
              resolve(node);
            }
            const found = node.querySelector(selector);
            if (found) {
              clearTimeout(timeoutId);
              observer.disconnect();
              resolve(found);
            }
          }
        });
      });
    });

    observer.observe(document.body, {
      childList: true,
      subtree: true
    });
  });
}

五、性能考虑

5.1 观察范围最小化

// ❌ 过度观察
observer.observe(document.body, {
  childList: true,
  subtree: true,
  attributes: true,
  characterData: true
});

// ✅ 精确观察
observer.observe(specificElement, {
  childList: true,  // 只关心子节点变化
  // 不需要 subtree, attributes, characterData
});

5.2 使用 attributeFilter

// ❌ 观察所有属性变化
observer.observe(element, {
  attributes: true
});

// ✅ 只观察特定属性
observer.observe(element, {
  attributes: true,
  attributeFilter: ['class', 'data-active', 'disabled']
});

5.3 批量处理与防抖

// 批量处理变化
const observer = new MutationObserver((mutations) => {
  // 在一次重排中处理所有变化
  const added = [];
  const removed = [];

  mutations.forEach(mutation => {
    if (mutation.type === 'childList') {
      added.push(...mutation.addedNodes);
      removed.push(...mutation.removedNodes);
    }
  });

  // 统一更新
  updateUI(added, removed);
});

observer.observe(document.body, {
  childList: true,
  subtree: true
});

六、disconnect 和 takeRecords

6.1 disconnect

// 停止观察
observer.disconnect();

// 清理时必须调用
observer.disconnect();

6.2 takeRecords

// 获取未处理的记录(调用 disconnect 前)
const pending = observer.takeRecords();

console.log('Pending mutations:', pending.length);
pending.forEach(mutation => {
  // 处理未触发回调的变化
});

参考资料

延展阅读