DOM 规范 —— MutationObserver 接口

前言

最近在重学 JavaScript 中,再一次接触到了 MutationObserver 内容,接着联想到了 Vue 源码中有使用过这个接口,因此觉得有必要对 MutationObserver 接口进行相关了解和学习。

下面是 vue 源码中关于 MutationObserver 接口使用的代码:

MutationObserver

主要作用

MutationObserver 可以观察整个 文档DOM 树的一部分具体 dom 元素,主要是观察元素的 属性、子节点、文本 的变化,并且可以在 DOM 被修改时异步执行回调。

MutationObserver 接口是为了取代废弃的 MutationEvent:

  • DOM Level 2 规范中描述的 MutationEvent 定义了一组会在各种 DOM 变化时触发的事件。由于浏览器事件的实现机制,这个接口出现了严重的性能问题。因此,DOM Level 3 规定废弃了这些事件。 - MutationObserver 接口更实用、性能更好

基本用法

MutationObserver 的实例要通过调用 MutationObserver 构造函数并传入一个回调函数来创建,这个回调函数会接收两个参数:

  • mutationRecord —— 是一个数组存储的是 MutationRecord 的实例,数组的每一项包含发生了什么变化,以及 DOM 的哪一部分受到了影响。因为回调执行之前可能同时发生多个满足被观察 dom 修改的条件,所以当前回调就会被执行多次,每次执行回调都会传入一个包含按顺序入队的 MutationRecord 实例的数组;
  • mutationObserver —— 是观察变化的 MutationObserver 实例,也就是外部实例化得到的 observer 对象;
 let observer = new MutationObserver((mutationRecord, mutationObserver) => {
            console.log('DOM was mutated!');
        });
        
console.log("observer = ", observer);
复制代码

image.png 并且得到的 observe 实例可以调用 MutationObserver 原型上的三个方法:

  • observe()
  • disconnect()
  • takeRecords()

MutationObserverInit 对象

在正式介绍以上三个方法之前,有必要先了解一下 MutationObserverInit 对象,因为 observe() 方法的第二个参数需要接收的就是一个 MutationObserverInit 对象。

MutationObserverInit 对象用于控制对目标节点的观察范围,简单点说,就是 observe 实例可以检测的事件内容包括:

  • 属性变化 —— 如:dom.removeAttribute() || dom.setAttribute() 等
  • 文本变化 —— 如: dom.innerText = xxx || dom.innerHTML = xxx || dom.textContent = xxx 等
  • 子节点变化 —— 如:dom.appendChild() || dom.insertBefore() || dom.replaceChild() || dom.removeChild() 等

MutationObserverInit 对象的属性,它们的值除了 attributeOldValue 属性值为数组之外,全为 Boolean 类型:

  • subtree —— true 表示需要检测子节点的变化,false 则相反
  • attributes —— true 表示需要检测属性变化,false 则相反
  • attributeFilter —— 字符串数组,表示要观察哪些属性的变化
  • attributeOldValue —— true 表示 MutationRecord 需要记录变化之前的属性值,false 则相反,一旦这个属性设置为 true ,会把 attributes 的值也设置为 true
  • characterData —— true 表示修改文本内容是否触发变化事件,false 则相反
  • characterDataOldValue —— true 表示 MutationRecord 需要记录变化之前的字符数据,false 则相反,一旦这个属性设置为 true ,会把 characterData 的值也设置为 true
  • childList —— 表示修改目标节点的子节点是否触发变化事件,false 则相反

总结,就是一个对象拥有符合 MutationObserverInit 上定义的这些属性,就能被称为 MutationObserverInit 对象

在调用 observe() 时,MutationObserverInit 对象中的 【attribute、characterData 、childList】或 a【ttributeOldValue、characterDataOldValue】 必须至少有一项为 true。否则会抛出错误,因为没有任何变化事件能触发回调,但是又注册了回调。

observe() 方法

关联 observer 和 dom

新创建的 MutationObserver 实例不会关联 DOM 的任何部分,必须要通过 observer.observe() 方法,把 observerDOM 进行关联。

observer.observe(dom, mutationObserverInit) 中两个必需参数:

  • dom —— 要观察其变化的 DOM 节点
  • mutationObserverInit —— 符合 MutationObserverInit 定义的对象

下面的例子就是观察 <body> 标签上的属性变化:

// 实例化 observer 并注册回调
 let observer = new MutationObserver((mutationRecord, mutationObserver) =>{
          // 大约 2s 执行这个回调 
          console.log('body attributes changed!!!'); // body attributes changed!!!
          console.log('mutationRecord = ', mutationRecord); // [MutationRecord]
          console.log('mutationObserver === observer', mutationObserver === observer);// true
         });
         
 // 将 observer 实例与目标 dom 进行关联
 observer.observe(document.body, { attributes: true });
 
 // 大约 2s 后修改 body 标签的 class 值
   setTimeout(() => {
        document.body.setAttribute('class', 'body')
      }, 2000)
复制代码

回调函数中的 MutationRecord 实例

上面 console.log('mutationRecord = ', mutationRecord) 的输出结果如下:

 mutationRecord =  [
                          {
                              addedNodes: NodeList [],
                              attributeName: "class",
                              attributeNamespace: null,
                              nextSibling: null,
                              oldValue: null,
                              previousSibling: null
                              removedNodes: NodeList [],
                              target: body.body
                              type: "attributes"
                          }
                   ]
复制代码

下面是每个属性对应的解释:

  • target —— 被修改影响的目标 dom 节点
  • type —— 表示变化的类型,也就是 MutationObserverInit 对象中的三种:"attributes"、"characterData" 或 "childList"
  • attributeName —— 针对 "attributes" 类型的变化时,保存被修改属性的名字
  • attributeNamespace —— 对于使用了命名空间的 "attributes" 类型的变化,保存被修改属性的名字,其他变化事件会将这个属性设置为 null
  • oldValue —— 如果在 MutationObserverInit 对象中启用(attributeOldValue 或 characterData OldValue 为 true),则 "attributes" 或 "characterData" 的变化事件会设置这个属性为被替代的值;"childList" 类型的变化始终将这个属性设置为 null
  • addedNodes —— 针对 "childList" 类型的变化,返回包含变化中添加节点的 NodeList,其他变化事件会将这个属性设置为空 NodeList 数组
  • previousSibling —— 对于 "childList" 类型的变化,返回包含变化中删除节点的 NodeList,默认为空 NodeList
  • nextSibling —— 对于 "childList" 类型的变化,返回变化节点的后一个同胞 Node,默认为 null
  • removedNodes —— 对于"childList"类型的变化,返回变化节点的前一个同胞 Node,默认为 null

disconnect() 方法

默认情况下,只要被观察的元素不被垃圾回收,MutationObserver 的回调就会响应 DOM 变化事 件,从而被执行。要提前终止执行回调,可以调用 disconnect() 方法。

直接看下面的例子:

            let observer = new MutationObserver((mutationRecord, mutationObserver) => {
                console.log(mutationRecord);
                console.log(mutationObserver);
            })

            observer.observe(document.body, { attributes: true });
            
            // 位置1
            observer.disconnect();
            
            setTimeout(() => {
              // 位置2
              // observer.disconnect();
              
              document.body.setAttribute('class', 'body');
              
              // 位置3
              // observer.disconnect();
            }, 2000);
            
           // 位置4
           // observer.disconnect();
复制代码

上面我们把 observer.disconnect() 分别放在 位置 1、2、3、4,但实际上它们的效果都是一样的,都会直接终止执行回调,要想让已经加入任务队列的回调执行,可以利用事件循环机制,比如:区分同步和异步修改,然后在异步操作中调用 disconnect() ,保证让已经入列的回调执行完毕。 关于浏览器的事件循环机制,可以参考我之前的文章 JavaScript 事件循环(EventLoop) —— 浏览器 & Node

takeRecords() 方法

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

使用场景: 希望断开与观察目标的联系,但又希望获取调用 disconnect() 而被抛弃的记录队列中的 MutationRecord,这样即使已经断开关联,也能继续处理后续操作。

     // 1. 未调用 takeRecords()
     let observer = new MutationObserver(
        (mutationRecord, mutationObserver) => {
          console.log('body had mutated!!!')
          console.log(mutationRecord); // [MutationRecord, MutationRecord, MutationRecord]
        },
      )

      observer.observe(document.body, { attributes: true })

      document.body.className = 'body1'
      document.body.className = 'body2'
      document.body.className = 'body3'
      
     // 2. 调用 takeRecords()
     let observer = new MutationObserver(
     // 这个回调函数不再执行,因为已经通过 observer.takeRecords 获取到了 mutationRecord
        (mutationRecord, mutationObserver) => {
          console.log('body had mutated!!!')
          console.log(mutationRecord); // [MutationRecord, MutationRecord, MutationRecord]
        },
      )

      observer.observe(document.body, { attributes: true })

      document.body.className = 'body1'
      document.body.className = 'body2'
      document.body.className = 'body3'
      
      console.log(observer.takeRecords()); // 这里输出 [MutationRecord, MutationRecord, MutationRecord]
      console.log(observer.takeRecords());  // 上面获取到集合之后,再次获取,此时已经被清空,输出: [] 
复制代码

复用 MutationObserver 对象

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

      let h1 = document.createElement('h1')
      let h2 = document.createElement('h2')

      let observer = new MutationObserver(
        (mutationRecord, mutationObserver) => {
          console.log(mutationRecord)
        },
      )
      // 初次检测
      observer.observe(h1, {
        attributes: true,
      })
      // 再次检测
      observer.observe(h2, {
        attributes: true,
      })

      h1.className = 'h1'
      h1.textContent = 'this is h1'
      h2.className = 'h2'
      h2.textContent = 'this is h2'

     // 即使没有把 h1 和 h2 节点添加的文档中,上面的对 className 的修改,也可以触发回调执行
      document.body.appendChild(h1)
      document.body.appendChild(h2)
复制代码

observer.disconnect() 方法调用之后,所有和 observer 关联的 dom 就全部断开,但是后续可以继续使用 observer.observe() 方法重新关联。

MutationObserver 回调与记录队列

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

记录队列对每个 MutationObserver 实例都是唯一的,是所有 DOM 变化事件的有序列表。

根据下面的例子来简单理解,下面的 body 元素虽然被连续修改 2 次,但是我们注册的回调函数不会被执行 2 次,而是把 2 次操作的信息分别放到 MutationRecord 的实例中,并通过数组进行保存,这样就保证了多次修改的内容都能在一次回调执行中获取到。

// 实例化 observer 对象并注册回调
let observer = new MutationObserver((mutationRecord, mutationObserver) => {
   console.log(mutationRecord);// 这里输出的是两次修改的集合
})
// 将 observer 与 dom 进行关联
observer.observe(document.body, { attributes: true });
// 连续两次修改属性值
document.body.className = "body1";
document.body.className = "body2";
复制代码

使用 MutationObserver 仍然是有代价

虽然在上面说了不少 MutationObserver 的优势,但是应该要理解为是与旧的MutationEvent 相比的情况下,因为 MutationObserver 本身还是存在缺点的。 这也就是为什么 vue 源码中没有直接使用它的原因,当然在 vue 中它是仅次于 promise 的,因为 MutationObserver 和 Promise 一样属于微任务,能够被事件循环尽快执行。

  • MutationObserver 的引用
    • MutationObserver 对要观察的目标节点的引用属于弱引用,所以不会妨碍垃圾回收程序回收目标节点
    • 目标节点对 MutationObserver 的引用属于强引用。如果目标节点从 DOM 中被移除,随后被垃圾回收,则关联的 MutationObserver 也会被垃圾回收。
  • MutationRecord 的引用
    • 记录队列中的每个 MutationRecord 实例至少包含对已有 DOM 节点的一个引用,即里面的 target 属性,如果变化是 childList 类型,则会包含多个节点的引用
    • 记录队列和回调处理的默认行为是耗尽这个队列,处理每个 MutationRecord,然后让它们超出作用域并被垃圾回收
    • 有时候可能需要保存某个观察者的完整变化记录,那么就保存所有的 MutationRecord 实例,也就会保存它们引用的节点,而这会妨碍这些节点被回收
    • 如果需要尽快地释放内存,可以从每个 MutationRecord 中抽取出最有用的信息,保存到一个新对象,然后释放 MutationRecord 中的引用

最后

既然开头提到了 vue2 源码中对 MutationObserver 的使用,其实也就是和 nextTick 源码相关的部分,那么在这就简单的总结一下:

  • 先定义了一个 callbacks 存放所有的 nextTick 里的回调函数
  • 然后判断当前环境是否支持 Promise,如果支持,就用 Promise 来触发回调函数
  • 如果不支持 Promise 就判断是否支持 MutationObserver,通过观察文本节点发生变化,去触发执行所有异步回调函数
  • 如果不支持 MutationObserver 就判断是否支持 setImmediate,如果支持,就通过setImmediate 来触发回调函数
  • 如果以上都不支持就只能用 setTimeout 来完成异步执行

延迟调用优先级如下:

Promise > MutationObserver > setImmediate > setTimeout

猜你喜欢

转载自juejin.im/post/7036733000565915655