亲宝软件园·资讯

展开

Dom-api MutationObserver使用方法详解

腹黑霸道城乡结合部王铁牛 人气:0

1. 概述

MutationObserver 接口提供了监视对 DOM 树所做更改的能力。它被设计为旧的 Mutation Events 功能的替代品,该功能是 DOM3 Events 规范的一部分。 - MDN

也就是说,当监视的 DOM 发生变动时 MutationObserver 将收到通知并触发事先设定好的回调函数。这个功能非常强大,意味着对于我们可以更加方便的动态操作 DOM 元素了。

你是否能联想到某些业务场景呢?

像这样的列表页,由于文案和文章配图数量的不同导致有多种不同的 ui 设计和排列方式,所以在前端对数据渲染的时候,要对列表每一项内容类型进行甄别。使用 MutationObserver 可以非常简单的完成这个需求

2. 基本使用

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

const observer = new MutationObserver(() => console.log('DOM 发生变化了~'));

回调参数的两个参数:

后文还会再详细讨论这两个参数

2.1 observer 方法

新创建的 MutationObserver 实例不会关联 DOM 的任何部分。要把这个 observerDOM 关联起来,需要使用 observe()方法

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

这个方法接收必需的参数:

这样 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.bodyclass 属性,所以了触发 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']:可以观察哪些属性的变化,在这里只观察了 classid 属性

观察文本节点(如 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,还得将 childListtrue,因为 MutationObserverInit 对象中的 attributecharacterDatachildList 属性必须 至少有一项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

在这个例子中,操作了 3DOM,所以在调用第一次 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&quot;的变化事件会设置这个属性为被替代的值 "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 变化的结果

加载全部内容

相关教程
猜你喜欢
用户评论