首頁 > 軟體

Dom-api MutationObserver使用方法詳解

2022-11-07 14:01:20

1. 概述

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 的任何部分。要把這個 observerDOM 關聯起來,需要使用 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.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(/[nr]/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 變化的結果

以上就是Dom-api MutationObserver使用方法詳解的詳細內容,更多關於Dom-api MutationObserver方法的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com