Dom-api MutationObserver使用方法详解
腹黑霸道城乡结合部王铁牛 人气:01. 概述
MutationObserver 接口提供了监视对 DOM 树所做更改的能力。它被设计为旧的 Mutation Events 功能的替代品,该功能是 DOM3 Events 规范的一部分。 - MDN
也就是说,当监视的 DOM
发生变动时 MutationObserver
将收到通知并触发事先设定好的回调函数。这个功能非常强大,意味着对于我们可以更加方便的动态操作 DOM
元素了。
你是否能联想到某些业务场景呢?
像这样的列表页,由于文案和文章配图数量的不同导致有多种不同的 ui 设计和排列方式,所以在前端对数据渲染的时候,要对列表每一项内容类型进行甄别。使用 MutationObserver
可以非常简单的完成这个需求
2. 基本使用
MutationObserver
是一个构造函数,通过调用 MutationObserver 构造函数并传入一个回调函数来创建一个观察 DOM
的实例
const observer = new MutationObserver(() => console.log('DOM 发生变化了~'));
回调参数的两个参数:
mutationRecords
:数组队列,记录操作的结果observer
:与构造函数的返回值 全等,因为这个回调函数是 异步执行,所以也可以访问到外部的observer
后文还会再详细讨论这两个参数
2.1 observer 方法
新创建的 MutationObserver
实例不会关联 DOM 的任何部分。要把这个 observer
与 DOM
关联起来,需要使用 observe()
方法
observer.observe(document.body, { attributes: true });
这个方法接收必需的参数:
- 第一个参数:要观察的
DOM
节点 - 第二个参数:
MutationObserverInit
对象
这样 document.body
就被观察了,只要 document.body
元素的任何属性值发生变化,就会触发观察对象,并且 异步调用 传入 MutationObserver
的回调函数(这是一个 微任务)
const observer = new MutationObserver(() => console.log('DOM 发生变化了~')); observer.observe(document.body, { attributes: true }); setTimeout(() => { document.body.className = 'test'; }, 1000);
等过了一秒之后,定时器的回调函数执行,修改了 document.body
的 class
属性,所以了触发 MutationObserver
的回调函数
2.2 MutationObserverInit 对象
在上面的例子中,只要 document.body
本身的任意属性发生了,都会被观察到,但是其他修改 DOM
的行为不会被观察,例如节点的增删改查,子节点属性的修改...,因为我们在调用 observe()
方法的时候传入的 MutationObserverInit
对象添加了 attributes
属性,所以 observe()
方法作用是只能侦测自身的元素属性值的变化。MutationObserverInit
对象除了这个属性之外,还有很多非常强大的属性可以观察更多的节点操作
MutationObserverInit
对象用于控制对目标节点的观察范围。观察方式的类型有 属性变化、文本变化 和 子节点变化 这三种。
所以在调用 observe()
时,MutationObserverInit
对象中的 attribute
(属性变化)、characterData
(文本变化) 和 childList
(子节点变化) 属性必须 至少有一项 为 true
(无论是直接设置这几个属性,还是通过设置 attributeOldValue
(属性变化)等属性间接导致它们的值转换为 true
)。否则会抛出错误,因为 DOM
的变化不会被任何变化事件类型触发回调。
- 属性变化
观察节点 属性 的 添加、移除 和 修改。需要在 MutationObserverInit
对象中将 attributes
属性设置为 true
const observer = new MutationObserver(() => console.log('DOM 发生变化了~')); observer.observe(document.body, { attributes: true }); setTimeout(() => { document.body.className = 'test'; }, 1000);
还有 attributeOldValue: true
:可以记录变化之前的属性值。attributeFilter: ['class', 'id']
:可以观察哪些属性的变化,在这里只观察了 class
和 id
属性
- 文本变化
观察文本节点(如 Text 文本节点、Comment 注释 ) 中字符的 添加、删除 和 修改。要在 MutationObserverInit
对象中将 characterData
属性设置为 true
const observer = new MutationObserver(() => console.log('DOM 发生变化了~')); observer.observe(document.body.firstChild, { characterData: true }); setTimeout(() => { document.body.firstChild.textContent = '123'; }, 1000);
还有 characterDataOldValue
:可以记录变化之前的文本值
- 观察子节点
观察目标节点子节点的添加和移除。需要在 MutationObserverInit
对象中将 childList
属性设置为 true
const observer = new MutationObserver(() => console.log('DOM 发生变化了~')); observer.observe(document.body, { childList: true }); setTimeout(() => { document.body.appendChild(document.createElement('div')); }, 1000);
在这个例子中控制台输出两次,第一次是 body
元素在 0s
触发回调,第二次才是新创建的元素在 1s
之后触发回调,因为观察 document.body
会在创建 body
的时候就立即被观察到,而观察非 body
元素,不会触发自身创建的过程
childList
只会观察子节点,但不会观察深层的节点,可以在 MutationObserverInit
对象中将 subtree
属性设置为 true
,还得将 childList
为 true
,因为 MutationObserverInit
对象中的 attribute
、characterData
和 childList
属性必须 至少有一项 为 true
<div></div> <script> const observer = new MutationObserver(mutationRecords => { console.log('触发了'); console.log(mutationRecords.length); // 2 }); observer.observe(document.body.children[0], { childList: true, subtree: true }); setTimeout(() => { document.body.children[0].appendChild(document.createElement('div')); document.body.children[0].children[0].appendChild(document.createElement('div')); }, 1000); </script>
这里虽然只会触发一次回调,但是会在 mutationRecords
这个数组中会分别记下两次 DOM
操作的记录,所以数组的长度为 2
<div></div> <script> const observer = new MutationObserver(mutationRecords => { console.log('触发了'); console.log(mutationRecords.length); // 1 }); observer.observe(document.body.children[0], { childList: true, subtree: true }); setTimeout(() => { document.body.children[0].appendChild(document.createElement('div')); }, 1000); setTimeout(() => { document.body.children[0].children[0].appendChild(document.createElement('div')); }, 1000); </script>
这个例子与上个例子区别是将两次 DOM
操作放在两个不同的定时器执行,但是结果却是截然不同,这里会输出两次,mutationRecords
数组的长度为 1
这是因为 DOM
操作是同步的,DOM
渲染是异步的,MutationObserver
中的回调函数执行会被包裹在一个 微任务 中,而定时器是 宏任务,所以整个执行过程是:第一个定时器先执行,观察 DOM
的回调函数执行,第二个定时器再执行,所以 DOM
变化被观察了两次。
上一个的例子 DOM
操作是在同一个 宏任务
中执行,因为浏览器会优化 DOM
渲染的过程,所以等到两个 div
元素创建完毕才会渲染,之后执行观察 DOM
的 微任务,所以才会触发一次观察,但是产生了两个结果,所以 mutationRecords
数组的长度为 2
这里还有一个怪异现象,在第二个例子中,为什么两输出 mutationRecords
的长度都是 1
,因为这两个数组不是同一个数组,关于为什么 mutationRecords
数组 不会缓存 第一次的操作结果,而是创建两个不同的数组,会在后面的内容详细讨论。
2.3 disconnect()方法
默认情况下,只要被观察的元素不被垃圾回收,MutationObserver
的回调就会响应 DOM
变化事件,从而被执行。想要 提前终止执行 回调,可以调用 disconnect()
方法。
<div></div> <script> const observer = new MutationObserver(mutationRecords => { console.log('触发了'); }); observer.observe(document.body.children[0], { childList: true, subtree: true }); setTimeout(() => { document.body.children[0].appendChild(document.createElement('div')); setTimeout(() => { observer.disconnect(); }, 0); }, 1000); setTimeout(() => { document.body.children[0].appendChild(document.createElement('div')); }, 2000); </script>
在这个例子中,在第一秒的时候执行了 DOM
操作,并且创建一个定时器包裹 disconnect()
方法,然后执行 disconnect()
方法,在第二秒的时候执行了另外一个 DOM
操作。所以结果只有第一次 DOM
操作会被观察到
为什么这里需要将 disconnect
方法计时器里执行呢,千万别忘了,DOM
操作是 同步执行 的,DOM
渲染是 异步执行 的,disconnect()
也是 同步执行 的。如果不添加定时器,在 DOM
渲染值之前就取消了观察,虽然操作了 DOM
,但是渲染过程并没有观察到
2.4 takeRecords
调用 MutationObserver
实例的 takeRecords()
方法可以清空记录队列,取出并返回其中的所有 MutationRecord
实例。
const observer = new MutationObserver(mutationRecords => { console.log(mutationRecords); // 不输出 }); observer.observe(document.body, { attributes: true }); document.body.className = 'test1'; document.body.className = 'test2'; document.body.className = 'test3'; console.log(observer.takeRecords().length); // 3 console.log(observer.takeRecords().length); // 0
在这个例子中,操作了 3
次 DOM
,所以在调用第一次 takeRecords()
方法的时候会输出 3
,并且切断了与观察对象的联系,所以不会触发 MutationObserver
的回调,但是这种切断关系是 不牢靠 的,也就意味着下次的 DOM
操作会 重启观察,就像下面的这个例子表现的一样
const observer = new MutationObserver(mutationRecords => { console.log(mutationRecords); // 输出两次 }); observer.observe(document.body.children[0], { attributes: true }); document.body.children[0].className = 'test1'; document.body.children[0].className = 'test2'; document.body.children[0].className = 'test3'; observer.takeRecords(); document.body.children[0].className = 'test4'; setTimeout(() => { document.body.children[0].className = 'test5'; });
3. MutationRecord
MutationRecord
是一个 记录队列 的数组,,仅当 微任务队列 没有其他的微任务回调时(队列中微任务 长度为 0
),才会将观察者注册的 回调 作为微任务放置到任务队列上。这样可以保证记录队列的内容不会被回调处理两次。
在回调的微任务异步执行期间,有可能又会发生更多变化事件。因此被调用的回调会接收到一个 MutationRecord
实例的数组,顺序为它们进入记录队列的顺序。回调要负责处理这个数组的每一个实例,因为 回调函数 退出之后这些实现就不存在了。回调函数执行完成后,这些 MutationRecord
就用不着了, 因此记录队列会被清空,其内容会被丢弃。所以每一个回调函数中的 MutationRecords
数组是 不同的实例
3.1 MutationRecord 实例
const observer = new MutationObserver(mutationRecords => { console.log(mutationRecords); }); const oDiv = document.getElementsByTagName('div')[0]; observer.observe(oDiv, { attributeOldValue: true }); oDiv.classList.add('box');
几个重要的属性:
属性 | 说明 |
---|---|
target | 被修改影响的目标节点 |
type | 表示变化的类型:"attributes"、"characterData"或"childList" |
oldValue | 如果在 MutationObserverInit 对象中启用(attributeOldValue 或 characterData OldValue 为 true),"attributes"或"characterData"的变化事件会设置这个属性为被替代的值 "childList"类型的变化始终将这个属性设置为 null |
addedNodes | 对于"childList"类型的变化,返回包含变化中添加节点的 NodeList 默认为空 NodeList |
4. MutationObserver 实战
一个简单的业务场景:
用户提交评论,如果评论的内容超过最大宽度,需要隐藏多余的部分,同时展示“查看更多”按钮,点击这个按钮就会展示评论的全部内容
难点:只有当 DOM
被渲染的时候 才知道实际的高度,所以无法预先分析评论文本内容而选择渲染方式的类型
实现思路:使用 MutationObserver
监听评论区列表,每当用户提交新的评论,新生成的 DOM
就会被观察到,判断评论的内容是否超出最大高度,更新 UI
<script lang="ts" setup> import { onMounted, reactive, ref } from 'vue'; interface ICommentItem { id: string; text: string; showBtn: boolean; } const comIptVal = ref(''); const commentList = reactive<ICommentItem[]>([]); const commentListRef = ref<HTMLElement | null>(null); const MaxSize = 50; // 每一项最大高度 const observer = new MutationObserver(mutationRecord => { const currRecord = mutationRecord[mutationRecord.length - 1]; // 最新的记录 const newNode = currRecord.addedNodes[currRecord.addedNodes.length - 1] as HTMLElement; // 新添加的节点 // 新增加的按钮也会触发观察,所以要判断新增加节点是否是评论 if (newNode.className === 'comment-item') { const id = newNode.dataset.id; const item = commentList.find(item => item.id === id)!; if (newNode.clientHeight > MaxSize) { // 如果超出最大高度 const oText = newNode.children[0] as HTMLElement; oText.style.height = MaxSize + 'px'; oText.style.overflow = 'hidden'; item.showBtn = true; } } }); onMounted(() => { observer.observe(commentListRef.value as HTMLElement, { subtree: true, childList: true, }); }); const addCommentItem = () => { commentList.push({ id: String(new Date().getTime()), // 评论的 id text: parseComment(comIptVal.value), // 解析输入文本内容 showBtn: false, // 默认不超出最大高度 }); }; const parseComment = (str: string) => { return str.replace(/[\n\r]/g, '<br />'); // 将 \n 换行解析成 <br /> 元素 }; const showAllBtnClick = (el: HTMLElement, item: ICommentItem) => { el.style.overflow = 'visible'; el.style.height = 'auto'; item.showBtn = false; // 隐藏点击更多按钮 }; const child = reactive<HTMLElement[]>([]); // 循环绑定 DOM </script> <template> <textarea v-model="comIptVal"></textarea> <button @click="addCommentItem">添加</button> <ul class="comment-list" ref="commentListRef"> <li class="comment-item" v-for="(item, index) in commentList" :key="item.id" :data-id="item.id"> <div v-html="item.text" :ref="(el: any) => child[index] = el"></div> <button v-if="item.showBtn" @click="showAllBtnClick(child[index] as HTMLElement, item)"> 更多 </button> </li> </ul> </template>
参考文献
JavaScript 高级程序设计第 4 版.PDF – 1024.Cool
总结
MutationObserver
api 使用大多数场景为:动态监听 DOM
元素的变化,在传入构造函数的回调函数中可以访问到触发 DOM
变化的 target
和 影响 DOM
变化的结果
加载全部内容