DOM规范中的MutationObserver接口,可以在DOM被修改时异步执行回调。
使用MutationObserver可以观察整个文档、DOM树的一部分,或某个元素。还可以观察元素属性、子节点、文本,或者前三者任意组合的变化。
注:新引进MutationObserver接口是为了取代废弃的MutationEvent。

14.3.1 基本用法

MutationObserver的实例要通过调用MutationObserver构造函数,并传入一个回调函数来创建

  1. let observer = new MutationObserver(() => console.log('DOM was mutated!'));

1.observe()方法

新创建的MutationObserver实例不会关联DOM的任何部分。
把observer与DOM关联起来,需要使用observe()方法
接收两个必需的参数:要观察其变化的DOM节点,以及一个MutationObserverInit对象。
MutationObserverInit对象用于控制观察哪些方面的变化,是一个键/值对形式配置选项的字典。
例如,下面的代码会创建一个观察者(observer)并配置它观察元素上的属性变化:

  1. let observer = new MutationObserver(() => console.log('<body> attributes changed'));
  2. observer.observe(document.body, { attributes: true});

执行以上代码后,元素上任何属性发生变化,都会被这个MutationObserver实例发现,然后就会异步执行注册的回调函数。
元素后代的修改或其他非属性修改都不会触发回调进入任务队列。
可以通过以下代码来验证:

  1. let observer = new MutationObserver(() => console.log('<body> attributes changed'));
  2. observer.observe(document.body, { attributes: true});
  3. document.body.className = 'foo';
  4. console.log('改变body的类名');
  5. // 改变body的类名
  6. // <body> attributes changed

注:回调中的console.log()是后执行的。这表明回调并非与实际的DOM变化同步执行。

2.回调与MutationRecord

每个回调都会收到一个MutationRecord实例的数组。
MutationRecord实例包含的信息包括发生了什么变化,以及DOM的哪一部分受到了影响。
因为回调执行之前可能同时发生多个满足观察条件的事件,所以每次执行回调都会传入一个包含按顺序入队的MutationRecord实例的数组。
下面展示了反映一个属性变化的MutationRecord实例的数组:

  1. let observer = new MutationObserver(
  2. (mutationRecords) => console.log(mutationRecords)
  3. );
  4. observer.observe(document.body, { attributes: true});
  5. document.body.setAttribute('foo', 'bar');
  6. // MutationRecord数组:
  7. // [
  8. // {
  9. // addedNodes: NodeList [],
  10. // attributeName: "foo",
  11. // attributeNamespace: null,
  12. // nextSibling: null,
  13. // oldValue: null,
  14. // previousSibling: null,
  15. // removedNodes: NodeList [],
  16. // target: body
  17. // type: "attributes"
  18. // }
  19. // ]

MutationRecord实例的属性:
image.png
传给回调函数的第二个参数,是观察变化的MutationObserver的实例

  1. let observer = new MutationObserver(
  2. (mutationRecords, MutationObserver) => console.log(mutationRecords, MutationObserver)
  3. );
  4. observer.observe(document.body, { attributes: true});
  5. document.body.className = 'foo';
  6. // [MutationRecord], MutationObserver {}

3.disconnect()方法

默认情况下,只要被观察的元素不被垃圾回收,MutationObserver的回调就会响应DOM变化事件,从而被执行。
要提前终止执行回调,可以调用disconnect()方法。
同步调用disconnect()之后,不仅会停止此后变化事件的回调,也会抛弃已经加入任务队列要异步执行的回调.

  1. let observer = new MutationObserver(
  2. (mutationRecords, MutationObserver) => console.log('<body> attributes changed')
  3. );
  4. observer.observe(document.body, { attributes: true});
  5. document.body.className = 'foo';
  6. observer.disconnect();
  7. document.body.className = 'bar';
  8. // 没有日志输出

要让已经加入任务队列的回调执行,可使用setTimeout(),让已经入列的回调执行完毕,再调用disconnect():

  1. let observer = new MutationObserver(
  2. (mutationRecords, MutationObserver) => console.log('<body> attributes changed')
  3. );
  4. observer.observe(document.body, { attributes: true});
  5. document.body.className = 'foo';
  6. setTimeout(() => {
  7. observer.disconnect();
  8. document.body.className = 'bar';
  9. }, 0);
  10. // <body> attributes changed

4.复用MutationObserver

多次调用observe()方法,可复用一个MutationObserver对象,观察多个不同的目标节点。
此时,MutationRecord的target属性,可以标识发生变化事件的目标节点。

  1. let observer = new MutationObserver(
  2. (mutationRecords) => console.log(mutationRecords.map((x) => x.target))
  3. );
  4. // 向页面主体添加两个子节点
  5. let childA = document.createElement('div'),
  6. childB = document.createElement('span');
  7. document.body.appendChild(childA);
  8. document.body.appendChild(childB);
  9. // 观察两个子节点
  10. observer.observe(childA, { attributes: true});
  11. observer.observe(childB, { attributes: true});
  12. // 修改两个子节点的属性
  13. childA.setAttribute('foo', 'bar');
  14. childB.setAttribute('foo', 'bar');
  15. // [div, span]

disconnect()方法是一个“一刀切”的方案,调用它会停止观察所有目标:

  1. let observer = new MutationObserver(
  2. (mutationRecords) => console.log(mutationRecords.map((x) => x.target))
  3. );
  4. // 向页面主体添加两个子节点
  5. let childA = document.createElement('div'),
  6. childB = document.createElement('span');
  7. document.body.appendChild(childA);
  8. document.body.appendChild(childB);
  9. // 观察两个子节点
  10. observer.observe(childA, { attributes: true});
  11. observer.observe(childB, { attributes: true});
  12. observer.disconnect();
  13. // 修改两个子节点的属性
  14. childA.setAttribute('foo', 'bar');
  15. childB.setAttribute('foo', 'bar');
  16. // (没有日志输出)

5.重用MutationObserver

调用disconnect()并不会结束MutationObserver的生命。
还可以重新使用这个观察者,再将它关联到新的目标节点。
下面的示例在两个连续的异步块中先断开,然后又恢复了观察者与元素的关联:

  1. let observer = new MutationObserver(
  2. (mutationRecords) => console.log('<body> attributes changed')
  3. );
  4. observer.observe(document.body, { attributes: true});
  5. // 这行代码会触发变化事件
  6. document.body.setAttribute('foo', 'bar');
  7. setTimeout(() => {
  8. observer.disconnect();
  9. // 这行代码不会触发变化事件
  10. document.body.setAttribute('bar', 'baz');
  11. }, 0);
  12. setTimeout(() => {
  13. // Reattach
  14. observer.observe(document.body, {attributes: true});
  15. // 这行代码会触发变化事件
  16. document.body.setAttribute('baz', 'qux');
  17. }, 0);
  18. // <body> attributes changed(出现2次)

14.3.2 MutationObserverInit与观察范围

MutationObserverInit对象用于控制对目标节点的观察范围。
粗略地讲,观察者可以观察的事件包括属性变化、文本变化和子节点变化。
下表列出了MutationObserverInit对象的属性:
image.png
注:在调用observe()时,MutationObserverInit对象中的attribute、characterData和childList属性必须至少有一项为true(无论是直接设置这几个属性,还是通过设置attributeOldValue等属性间接导致它们的值转换为true)。否则会抛出错误,因为没有任何变化事件可能触发回调。

1.观察属性

MutationObserver可以观察节点属性的添加、移除和修改。
要为属性变化注册回调,需在MutationObserverInit对象中将attributes属性设置为true

  1. let observer = new MutationObserver(
  2. (MutationRecords) => console.log(MutationRecords)
  3. );
  4. observer.observe(document.body, {attributes: true});
  5. // 添加属性
  6. document.body.setAttribute('foo', 'bar');
  7. // 修改属性
  8. document.body.setAttribute('foo', 'baz');
  9. // 移除属性
  10. document.body.removeAttribute('foo');
  11. // 以上变化都被记录下来了
  12. // [MutationRecord, MutationRecord, MutationRecord]

把attributes设置为true的默认行为是观察所有属性,但不会在MutationRecord对象中记录原来的属性值。
如果想观察某个或某几个属性,可以使用attributeFilter属性来设置白名单,即一个属性名字符串数组

  1. let observer = new MutationObserver(
  2. (MutationRecords) => console.log(MutationRecords)
  3. );
  4. observer.observe(document.body, {attributeFilter: ['foo']});
  5. // 添加白名单属性
  6. document.body.setAttribute('foo', 'bar');
  7. // 修改被排除的属性
  8. document.body.setAttribute('baz', 'qux');
  9. // 只有foo属性的变化被记录了
  10. // [MutationRecord]

如果想在变化记录中保存属性原来的值,可以将attributeOldValue属性设置为true:

  1. let observer = new MutationObserver(
  2. (MutationRecords) => console.log(MutationRecords.map((x) => x.oldValue))
  3. );
  4. observer.observe(document.body, {attributeOldValue: true});
  5. document.body.setAttribute('foo', 'bar');
  6. document.body.setAttribute('foo', 'baz');
  7. document.body.setAttribute('foo', 'qux');
  8. // 每次变化都保留了上一次的值
  9. // [null, "bar", "baz"]

2.观察字符数据

MutationObserver可以观察文本节点(如Text、Comment或ProcessingInstruction节点)中字符的添加、删除和修改。
要为字符数据注册回调,需在MutationObserverInit对象中,将characterData属性设置为true

  1. let observer = new MutationObserver(
  2. (mutationRecords) => console.log(mutationRecords)
  3. );
  4. // 创建要观察的文本节点
  5. document.body.firstChild.textContent = 'foo';
  6. observer.observe(document.body.firstChild, {characterData: true});
  7. // 赋值为相同的字符串
  8. document.body.firstChild.textContent = 'foo';
  9. // 赋值为新字符串
  10. document.body.firstChild.textContent = 'bar';
  11. // 通过节点设置函数赋值
  12. document.body.firstChild.textContent = 'baz';
  13. // 以上变化都被记录下来了
  14. // [MutationRecord, MutationRecord, MutationRecord]

将characterData属性设置为true的默认行为,不会在MutationRecord对象中记录原来的字符数据。
若想在变化记录中保存原来的字符数据,可以将characterDataOldValue属性设置为true:

  1. let observer = new MutationObserver(
  2. (mutationRecords) => console.log(mutationRecords.map((x) => x.oldValue))
  3. );
  4. document.body.innerText = 'foo';
  5. observer.observe(document.body.firstChild, {characterDataOldValue: true});
  6. document.body.innerText = 'foo';
  7. document.body.innerText = 'bar';
  8. document.body.firstChild.textContent = 'baz';
  9. // 每次变化都保留了上一次的值
  10. // ['foo', 'foo', 'bar']
  11. // 但是我的代码出不来这个结果>_<

3.观察子节点

MutationObserver可以观察目标节点子节点的添加和移除。
观察子节点,需在Mutation-ObserverInit对象中,将childList属性设置为true。

  1. // 清空主体
  2. document.body.innerHTML = '';
  3. let observer = new MutationObserver(
  4. (mutationRecords) => console.log(mutationRecords)
  5. );
  6. observer.observe(document.body, {childList: true});
  7. document.body.appendChild(document.createElement('div'));
  8. // [
  9. // {
  10. // addedNodes: NodeList [div],
  11. // attributeName: null,
  12. // attributeNamespace: null,
  13. // nextSibling: null,
  14. // oldValue: null,
  15. // previousSibling: null,
  16. // removedNodes: NodeList [],
  17. // target: body,
  18. // type: "childList",
  19. // }
  20. // ]
  21. // [
  22. // {
  23. // addedNodes: NodeList [text],
  24. // attributeName: null,
  25. // attributeNamespace: null,
  26. // nextSibling: null,
  27. // oldValue: null,
  28. // previousSibling: div,
  29. // removedNodes: NodeList [],
  30. // target: body,
  31. // type: "childList",
  32. // }
  33. // ]

对子节点重新排序(尽管调用一个方法即可实现)会报告两次变化事件,因为从技术上会涉及先移除和再添加

  1. // 清空主体
  2. document.body.innerHTML = '';
  3. let observer = new MutationObserver(
  4. (mutationRecords) => console.log(mutationRecords)
  5. );
  6. // 创建两个初始子节点
  7. document.body.appendChild(document.createElement('div'));
  8. document.body.appendChild(document.createElement('span'));
  9. observer.observe(document.body, {childList: true});
  10. // 交换子节点顺序
  11. document.body.insertBefore(document.body.lastChild, document.body.firstChild);
  12. // 发生了两次变化:第一次时节点被移除,第二次是节点被添加
  13. // [MutationRecord, MutationRecord]
  14. // [MutationRecord]

4.观察子树

默认情况下,MutationObserver将观察的范围,限定为一个元素及其子节点的变化。
可把观察的范围扩展到这个元素的子树(所有后代节点),需在MutationObserverInit对象中,将subtree属性设置为true。
下面的代码展示了观察元素及其后代节点属性的变化:

  1. // 清空主体
  2. document.body.innerHTML = '';
  3. let observer = new MutationObserver(
  4. (mutationRecords) => console.log(mutationRecords)
  5. );
  6. // 创建一个后代
  7. document.body.appendChild(document.createElement('div'));
  8. // 观察<body>元素及其子树
  9. observer.observe(document.body, {attributes: true, subtree: true});
  10. // 修改<body>元素的子树
  11. document.body.firstChild.setAttribute('foo', 'bar');
  12. // 记录了子树的变化
  13. // [
  14. // {
  15. // addedNodes: NodeList [],
  16. // attributeName: "foo",
  17. // attributeNamespace: null,
  18. // nextSibling: null,
  19. // oldValue: null,
  20. // previousSibling: null,
  21. // removedNodes: NodeList [],
  22. // target: div,
  23. // type: "attributes",
  24. // }
  25. // ]

有意思的是,被观察子树中的节点,被移出子树之后,仍然能够触发变化事件。
这意味着:在子树中的节点离开该子树后,即使严格来讲该节点已经脱离了原来的子树,但它仍然会触发变化事件。

  1. // 清空主体
  2. document.body.innerHTML = '';
  3. let observer = new MutationObserver(
  4. (mutationRecords) => console.log(mutationRecords)
  5. );
  6. let subtreeRoot = document.createElement('div'),
  7. subtreeLeaf = document.createElement('span');
  8. // 创建包含两层的子树
  9. document.body.appendChild(subtreeRoot);
  10. subtreeRoot.appendChild(subtreeLeaf);
  11. // 观察子树
  12. observer.observe(subtreeRoot, { attributes: true, subtree: true });
  13. // 把节点转移到其他子树
  14. document.body.insertBefore(subtreeLeaf, subtreeRoot);
  15. subtreeLeaf.setAttribute('foo', 'bar');
  16. // 移出的节点仍然触发变化事件
  17. // [MutationRecord]

14.3.3 异步回调与记录队列

MutationObserver接口是出于性能考虑而设计的,核心是异步回调与记录队列模型。
为了在大量变化事件发生时不影响性能,每次变化的信息(由观察者实例决定)会保存在MutationRecord实例中,然后添加到记录队列。
这个队列对每个MutationObserver实例都是唯一的,是所有DOM变化事件的有序列表。

1.记录队列

每次MutationRecord被添加到MutationObserver的记录队列时,仅当之前没有已排期的微任务回调时(队列中微任务长度为0),才会将观察者注册的回调(在初始化MutationObserver时传入)作为微任务调度到任务队列上。
这样可以保证记录队列的内容不会被回调处理两次。
但是在回调的微任务异步执行期间,有可能又会发生更多变化事件。
因此,被调用的回调会接收到一个MutationRecord实例的数组,顺序为它们进入记录队列的顺序。
回调要负责处理这个数组的每一个实例,因为函数退出之后这些实现就不存在了。
回调执行后,这些MutationRecord就用不着了,因此记录队列会被清空,其内容会被丢弃。

2.takeRecords()方法

调用MutationObserver实例的takeRecords()方法,可以清空记录队列,取出并返回其中的所有MutationRecord实例

  1. let observer = new MutationObserver(
  2. (mutationRecords) => console.log(mutationRecords)
  3. );
  4. observer.observe(document.body, { attributes: true });
  5. document.body.className = 'foo';
  6. document.body.className = 'bar';
  7. document.body.className = 'baz';
  8. console.log(observer.takeRecords());
  9. console.log(observer.takeRecords());
  10. // [MutationRecord, MutationRecord, MutationRecord]
  11. // []

应用场合:在希望断开与观察目标的联系,同时希望处理由于调用disconnect()而被抛弃的记录队列中的MutationRecord实例时比较有用。

14.3.4 性能、内存与垃圾回收

DOM Level 2规范中描述的MutationEvent定义了一组会在各种DOM变化时触发的事件。
由于浏览器事件的实现机制,这个接口出现了严重的性能问题。
因此,DOM Level 3规定废弃了这些事件。
MutationObserver接口就是为替代这些事件而设计的更实用、性能更好的方案。
将变化回调委托给微任务来执行可以保证事件同步触发,同时避免随之而来的混乱。
为Mutation-Observer而实现的记录队列,可以保证即使变化事件被爆发式地触发,也不会显著地拖慢浏览器。
无论如何,使用MutationObserver仍然不是没有代价的。
因此理解什么时候避免出现这种情况就很重要了。

1.MutationObserver的引用

MutationObserver实例与目标节点之间的引用关系是非对称的。
MutationObserver拥有对要观察的目标节点的弱引用。
因为是弱引用,所以不会妨碍垃圾回收程序回收目标节点。
目标节点拥有对MutationObserver的强引用。
如果目标节点从DOM中被移除,随后被垃圾回收,则关联的MutationObserver也会被垃圾回收。

2.MutationRecord的引用

记录队列中的每个MutationRecord实例,至少包含对已有DOM节点的一个引用。
如果变化是childList类型,则会包含多个节点的引用。
记录队列和回调处理的默认行为是耗尽这个队列,处理每个MutationRecord,然后让它们超出作用域并被垃圾回收。
有时可能需要保存某个观察者的完整变化记录。
保存这些MutationRecord实例,也就会保存它们引用的节点,因而会妨碍这些节点被回收。
如果需要尽快地释放内存,建议从每个MutationRecord中抽取出最有用的信息,然后保存到一个新对象中,最后抛弃MutationRecord。