前言
最近在重学 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 对象;
js
let observer = new MutationObserver((mutationRecord, mutationObserver) => {
console.log('DOM was mutated!');
});
console.log("observer = ", observer);
并且得到的 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 对象的属性,它们的值除了 attributeFilter 属性值为数组之外,全为 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】或 【attributeOldValue、characterDataOldValue】 必须至少有一项为 true。否则会抛出错误,因为没有任何变化事件能触发回调,但是又注册了回调。
observe() 方法
关联 observer 和 dom
新创建的 MutationObserver 实例不会关联 DOM 的任何部分,必须要通过 observer.observe() 方法,把 observer 与 DOM 进行关联。
observer.observe(dom, mutationObserverInit) 中两个必需参数:
dom
------ 要观察其变化的 DOM 节点mutationObserverInit
------ 符合 MutationObserverInit 定义的对象
下面的例子就是观察 <body> 标签上的属性变化:
js
// 实例化 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)
的输出结果如下:
js
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() 方法。
直接看下面的例子:
js
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() ,保证让已经入列的回调执行完毕。
takeRecords() 方法
调用 MutationObserver 实例的 takeRecords() 方法可以清空记录队列,取出并返回包含其中的所有 MutationRecord 实例的数组。
使用场景: 希望断开与观察目标的联系,但又希望获取调用 disconnect() 而被抛弃的记录队列中的 MutationRecord,这样即使已经断开关联,也能继续处理后续操作。
js
// 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 对象观察多个不同的目标节点。此时,MutationRecord 的 target 属性可以标识发生变化事件的目标节点。
js
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 的实例中,并通过数组进行保存,这样就保证了多次修改的内容都能在一次回调执行中获取到。
js
// 实例化 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
如果想了解 事件循环机制 和 Promise 的内容可以参考之前的文章: