在某些場景下,我們希望能監視 DOM 樹的變動,然後做一些相關的操作。比如監聽元素被插入 DOM 或從 DOM 樹中移除,然後新增相應的動畫效果。或者在富文字編輯器中輸入特殊的符號,如 #
或 @
符號時自動高亮後面的內容等。要實現這些功能,我們就可以考慮使用 MutationObserver API,接下來阿寶哥將帶大家一起來探索 MutationObserver API 所提供的強大能力。
閱讀完本文,你將瞭解以下內容:
- MutationObserver 是什麼;
- MutationObserver API 的基本使用及 MutationRecord 物件;
- MutationObserver API 常見的使用場景;
- 什麼是觀察者設計模式及如何使用 TS 實現觀察者設計模式。
一、MutationObserver 是什麼
MutationObserver 介面提供了監視對 DOM 樹所做更改的能力。它被設計為舊的 Mutation Events 功能的替代品,該功能是 DOM3 Events 規範的一部分。
利用 MutationObserver API 我們可以監視 DOM 的變化。DOM 的任何變化,比如節點的增加、減少、屬性的變動、文字內容的變動,通過這個 API 我們都可以得到通知。
MutationObserver 有以下特點:
- 它等待所有指令碼任務執行完成後,才會執行,它是非同步觸發的。即會等待當前所有 DOM 操作都結束才觸發,這樣設計是為了應對 DOM 頻繁變動的問題。
- 它把 DOM 變動記錄封裝成一個陣列進行統一處理,而不是一條一條進行處理。
- 它既可以觀察 DOM 的所有型別變動,也可以指定只觀察某一類變動。
二、MutationObserver API 簡介
在介紹 MutationObserver API 之前,我們先來了解一下它的相容性:
(圖片來源:https://caniuse.com/#search=M...)
從上圖可知,目前主流的 Web 瀏覽器基本都支援 MutationObserver API,而對於 IE 瀏覽器只有 IE 11 才支援。在專案中,如需要使用 MutationObserver API,首先我們需要建立 MutationObserver 物件,因此接下來我們來介紹 MutationObserver 建構函式。
DOM 規範中的 MutationObserver 建構函式,用於建立並返回一個新的觀察器,它會在觸發指定 DOM 事件時,呼叫指定的回撥函式。MutationObserver 對 DOM 的觀察不會立即啟動,而必須先呼叫 observe()
方法來指定所要觀察的 DOM 節點以及要響應哪些更改。
2.1 建構函式
MutationObserver 建構函式的語法為:
const observer = new MutationObserver(callback);
相關的引數說明如下:
- callback:一個回撥函式,每當被指定的節點或子樹有發生 DOM 變動時會被呼叫。該回撥函式包含兩個引數:一個是描述所有被觸發改動的 MutationRecord 物件陣列,另一個是呼叫該函式的 MutationObserver 物件。
使用示例
const observer = new MutationObserver(function (mutations, observer) {
mutations.forEach(function(mutation) {
console.log(mutation);
});
});
2.2 方法
- disconnect():阻止 MutationObserver 例項繼續接收通知,除非再次呼叫其 observe() 方法,否則該觀察者物件包含的回撥函式都不會再被呼叫。
observe(target[, options]):該方法用來啟動監聽,它接受兩個引數。第一個引數,用於指定所要觀察的 DOM 節點。第二個引數,是一個配置物件,用於指定所要觀察的特定變動。
const editor = document.querySelector('#editor'); const options = { childList: true, // 監視node直接子節點的變動 subtree: true, // 監視node所有後代的變動 attributes: true, // 監視node屬性的變動 characterData: true, // 監視指定目標節點或子節點樹中節點所包含的字元資料的變化。 attributeOldValue: true // 記錄任何有改動的屬性的舊值 }; observer.observe(article, options);
- takeRecords():返回已檢測到但尚未由觀察者的回撥函式處理的所有匹配 DOM 更改的列表,使變更佇列保持為空。此方法最常見的使用場景是 在斷開觀察者之前立即獲取所有未處理的更改記錄,以便在停止觀察者時可以處理任何未處理的更改。
2.3 MutationRecord 物件
DOM 每次發生變化,就會生成一條變動記錄,即 MutationRecord 例項。該例項包含了與變動相關的所有資訊。Mutation Observer 物件處理的就是一個個 MutationRecord 例項所組成的陣列。
MutationRecord 例項包含了變動相關的資訊,含有以下屬性:
- type:變動的型別,值可以是 attributes、characterData 或 childList;
- target:發生變動的 DOM 節點;
- addedNodes:返回新增的 DOM 節點,如果沒有節點被新增,則返回一個空的 NodeList;
- removedNodes:返回移除的 DOM 節點,如果沒有節點被移除,則返回一個空的 NodeList;
- previousSibling:返回被新增或移除的節點之前的兄弟節點,如果沒有則返回
null
; - nextSibling:返回被新增或移除的節點之後的兄弟節點,如果沒有則返回
null
; - attributeName:返回被修改的屬性的屬性名,如果設定了
attributeFilter
,則只返回預先指定的屬性; - attributeNamespace:返回被修改屬性的名稱空間;
- oldValue:變動前的值。這個屬性只對
attribute
和characterData
變動有效,如果發生childList
變動,則返回null
。
2.4 MutationObserver 使用示例
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DOM 變動觀察器示例</title>
<style>
.editor {border: 1px dashed grey; width: 400px; height: 300px;}
</style>
</head>
<body>
<h3>阿寶哥:DOM 變動觀察器(Mutation observer)</h3>
<div contenteditable id="container" class="editor">大家好,我是阿寶哥!</div>
<script>
const containerEle = document.querySelector("#container");
let observer = new MutationObserver((mutationRecords) => {
console.log(mutationRecords); // 輸出變動記錄
});
observer.observe(containerEle, {
subtree: true, // 監視node所有後代的變動
characterDataOldValue: true, // 記錄任何有變動的屬性的舊值
});
</script>
</body>
</html>
以上程式碼成功執行之後,阿寶哥對 id 為 container 的 div 容器中原始內容進行修改,即把 大家好,我是阿寶哥! 修改為 大家好,我。對於上述的修改,控制檯將會輸出 5 條變動記錄,這裡我們來看一下最後一條變動記錄:
MutationObserver 物件的 observe(target [, options])
方法支援很多配置項,這裡阿寶哥就不詳細展開介紹了。
但是為了讓剛接觸 MutationObserver API 的小夥伴能更直觀的感受每個配置項的作用,阿寶哥把 mutationobserver-api-guide 這篇文章中使用的線上示例統一提取出來,做了一下彙總與分類:
1、MutationObserver Example - childList:https://codepen.io/impressive...2、MutationObserver Example - childList with subtree:https://codepen.io/impressive...
3、MutationObserver Example - Attributes:https://codepen.io/impressive...
4、MutationObserver Example - Attribute Filter:https://codepen.io/impressive...
5、MutationObserver Example - attributeFilter with subtree:https://codepen.io/impressive...
6、MutationObserver Example - characterData:https://codepen.io/impressive...
7、MutationObserver Example - characterData with subtree:https://codepen.io/impressive...
8、MutationObserver Example - Recording an Old Attribute Value:https://codepen.io/impressive...
9、MutationObserver Example - Recording old characterData:https://codepen.io/impressive...
10、MutationObserver Example - Multiple Changes for a Single Observer:https://codepen.io/impressive...
11、MutationObserver Example - Moving a Node Tree:https://codepen.io/impressive...
三、MutationObserver 使用場景
3.1 語法高亮
相信大家對語法高亮都不會陌生,平時在閱讀各類技術文章時,都會遇到它。接下來,阿寶哥將跟大家介紹如何使用 MutationObserver API 和 Prism.js 這個庫實現 JavaScript 和 CSS 語法高亮。
在看具體的實現程式碼前,我們先來看一下以下 HTML 程式碼段未語法高亮和語法高亮的區別:
let htmlSnippet = `下面是一個JavaScript程式碼段:
<pre class="language-javascript">
<code> let greeting = "大家好,我是阿寶哥"; </code>
</pre>
<div>另一個CSS程式碼段:</div>
<div>
<pre class="language-css">
<code>#code-container { border: 1px dashed grey; padding: 5px; } </code>
</pre>
</div>
`
通過觀察上圖,我們可以很直觀地發現,有進行語法高亮的程式碼塊閱讀起來更加清晰易懂。下面我們來看一下實現語法高亮的功能程式碼:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MutationObserver 實戰之語法高亮</title>
<style>
#code-container {
border: 1px dashed grey;
padding: 5px;
width: 550px;
height: 200px;
}
</style>
<link href="https://cdn.bootcdn.net/ajax/libs/prism/9000.0.1/themes/prism.min.css" rel="stylesheet">
<script src="https://cdn.bootcdn.net/ajax/libs/prism/9000.0.1/prism.min.js" data-manual></script>
<script src="https://cdn.bootcdn.net/ajax/libs/prism/9000.0.1/components/prism-javascript.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/prism/9000.0.1/components/prism-css.min.js"></script>
</head>
<body>
<h3>阿寶哥:MutationObserver 實戰之語法高亮</h3>
<div id="code-container"></div>
<script>
let observer = new MutationObserver((mutations) => {
for (let mutation of mutations) {
// 獲取新增的DOM節點
for (let node of mutation.addedNodes) {
// 只處理HTML元素,跳過其他節點,比如文字節點
if (!(node instanceof HTMLElement)) continue;
// 檢查插入的節點是否為程式碼段
if (node.matches('pre[class*="language-"]')) {
Prism.highlightElement(node);
}
// 檢查插入節點的子節點是否為程式碼段
for (let elem of node.querySelectorAll('pre[class*="language-"]')) {
Prism.highlightElement(elem);
}
}
}
});
let codeContainer = document.querySelector("#code-container");
observer.observe(codeContainer, { childList: true, subtree: true });
// 動態插入帶有程式碼段的內容
codeContainer.innerHTML = `下面是一個JavaScript程式碼段:
<pre class="language-javascript"><code> let greeting = "大家好,我是阿寶哥"; </code></pre>
<div>另一個CSS程式碼段:</div>
<div>
<pre class="language-css">
<code>#code-container { border: 1px dashed grey; padding: 5px; } </code>
</pre>
</div>
`;
</script>
</body>
</html>
在以上程式碼中,首先我們在引入 prism.min.js 的 script 標籤上設定 data-manual
屬性,用於告訴 Prism.js 我們將使用手動模式來處理語法高亮。接著我們在回撥函式中通過獲取 mutation 物件的 addedNodes
屬性來進一步獲取新增的 DOM 節點。然後我們遍歷新增的 DOM 節點,判斷新增的 DOM 節點是否為程式碼段,如果滿足條件的話則進行高亮操作。
此外,除了判斷當前節點之外,我們也會判斷插入節點的子節點是否為程式碼段,如果滿足條件的話,也會進行高亮操作。
3.2 監聽元素的 load 或 unload 事件
對 Web 開發者來說,相信很多人對 load
事件都不會陌生。當整個頁面及所有依賴資源如樣式表和圖片都已完成載入時,將會觸發 load
事件。而當文件或一個子資源正在被解除安裝時,會觸發 unload
事件。
在日常開發過程中,除了監聽頁面的載入和解除安裝事件之外,我們經常還需要監聽 DOM 節點的插入和移除事件。比如當 DOM 節點插入 DOM 樹中產生插入動畫,而當節點從 DOM 樹中被移除時產生移除動畫。針對這種場景我們就可以利用 MutationObserver API 來監聽元素的新增與移除。
同樣,在看具體的實現程式碼前,我們先來看一下實際的效果:
在以上示例中,當點選 跟蹤元素生命週期 按鈕時,一個新的 DIV 元素會被插入到 body 中,成功插入後,會在訊息框顯示相關的資訊。在 3S 之後,新增的 DIV 元素會從 DOM 中移除,成功移除後,會在訊息框中顯示 元素已從DOM中移除了 的資訊。
下面我們來看一下具體實現:
index.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MutationObserver load/unload 事件</title>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.0.0/animate.min.css"
/>
</head>
<body>
<h3>阿寶哥:MutationObserver load/unload 事件</h3>
<div class="block">
<p>
<button onclick="trackElementLifecycle()">跟蹤元素生命週期</button>
</p>
<textarea id="messageContainer" rows="5" cols="50"></textarea>
</div>
<script src="./on-load.js"></script>
<script>
const busy = false;
const messageContainer = document.querySelector("#messageContainer");
function trackElementLifecycle() {
if (busy) return;
const div = document.createElement("div");
div.innerText = "我是新增的DIV元素";
div.classList.add("animate__animated", "animate__bounceInDown");
watchElement(div);
document.body.appendChild(div);
}
function watchElement(element) {
onload(
element,
function (el) {
messageContainer.value = "元素已被新增到DOM中, 3s後將被移除";
setTimeout(() => document.body.removeChild(el), 3000);
},
function (el) {
messageContainer.value = "元素已從DOM中移除了";
}
);
}
</script>
</body>
</html>
on-load.js
// 只包含部分程式碼
const watch = Object.create(null);
const KEY_ID = "onloadid" + Math.random().toString(36).slice(2);
const KEY_ATTR = "data-" + KEY_ID;
let INDEX = 0;
if (window && window.MutationObserver) {
const observer = new MutationObserver(function (mutations) {
if (Object.keys(watch).length < 1) return;
for (let i = 0; i < mutations.length; i++) {
if (mutations[i].attributeName === KEY_ATTR) {
eachAttr(mutations[i], turnon, turnoff);
continue;
}
eachMutation(mutations[i].removedNodes, function (index, el) {
if (!document.documentElement.contains(el)) turnoff(index, el);
});
eachMutation(mutations[i].addedNodes, function (index, el) {
if (document.documentElement.contains(el)) turnon(index, el);
});
}
});
observer.observe(document.documentElement, {
childList: true,
subtree: true,
attributes: true,
attributeOldValue: true,
attributeFilter: [KEY_ATTR],
});
}
function onload(el, on, off, caller) {
on = on || function () {};
off = off || function () {};
el.setAttribute(KEY_ATTR, "o" + INDEX);
watch["o" + INDEX] = [on, off, 0, caller || onload.caller];
INDEX += 1;
return el;
}
on-load.js 的完整程式碼:https://gist.github.com/semli...
3.3 富文字編輯器
除了前面兩個應用場景,在富文字編輯器的場景,MutationObserver API 也有它的用武之地。比如我們希望在富文字編輯器中高亮 #
符號後的內容,這時候我們就可以通過 MutationObserver API 來監聽使用者輸入的內容,發現使用者輸入 #
時自動對輸入的內容進行高亮處理。
這裡阿寶哥基於 vue-hashtag-textarea 這個專案來演示一下上述的效果:
此外,MutationObserver API 在 Github 上的一個名為 Editor.js 的專案中也有應用。Editor.js 是一個 Block-Styled 編輯器,以 JSON 格式輸出資料的富文字和媒體編輯器。它是完全模組化的,由 “塊” 組成,這意味著每個結構單元都是它自己的塊(例如段落、標題、影像都是塊),使用者可以輕鬆地編寫自己的外掛來進一步擴充套件編輯器。
在 Editor.js 編輯器內部,它通過 MutationObserver API 來監聽富文字框的內容異動,然後觸發 change 事件,使得外部可以對變動進行響應和處理。上述的功能被封裝到內部的 modificationsObserver.ts 模組,感興趣的小夥伴可以閱讀 modificationsObserver.ts 模組的程式碼。
當然利用 MutationObserver API 提供的強大能力,我們還可以有其他的應用場景,比如防止頁面的水印元素被刪除,從而避免無法跟蹤到 “洩密” 者,當然這並不是絕對的安全,只是多加了一層防護措施。具體如何實現水印元素被刪除,篇幅有限。這裡阿寶哥不繼續展開介紹了,大家可以參考掘金上 “開啟控制檯也刪不掉的元素,前端都嚇尿了” 這一篇文章。
至此 MutationObserver 變動觀察者相關內容已經介紹完了,既然講到觀察者,阿寶哥情不自禁想再介紹一下觀察者設計模式。
四、觀察者設計模式
4.1 簡介
觀察者模式,它定義了一種一對多的關係,讓多個觀察者物件同時監聽某一個主題物件,這個主題物件的狀態發生變化時就會通知所有的觀察者物件,使得它們能夠自動更新自己。
我們可以使用日常生活中,期刊訂閱的例子來形象地解釋一下上面的概念。期刊訂閱包含兩個主要的角色:期刊出版方和訂閱者,他們之間的關係如下:
- 期刊出版方 —— 負責期刊的出版和發行工作。
- 訂閱者 —— 只需執行訂閱操作,新版的期刊釋出後,就會主動收到通知,如果取消訂閱,以後就不會再收到通知。
在觀察者模式中也有兩個主要角色:Subject(主題)和 Observer(觀察者),它們分別對應例子中的期刊出版方和訂閱者。接下來我們來看張圖,進一步加深對以上概念的理解。
4.2 模式結構
觀察者模式包含以下角色:
- Subject:主題類
- Observer:觀察者
4.3 觀察者模式實戰
4.3.1 定義 Observer 介面
interface Observer {
notify: Function;
}
4.3.2 建立 ConcreteObserver 觀察者實現類
class ConcreteObserver implements Observer{
constructor(private name: string) {}
notify() {
console.log(`${this.name} has been notified.`);
}
}
4.3.3 建立 Subject 類
class Subject {
private observers: Observer[] = [];
public addObserver(observer: Observer): void {
console.log(observer, "is pushed!");
this.observers.push(observer);
}
public deleteObserver(observer: Observer): void {
console.log("remove", observer);
const n: number = this.observers.indexOf(observer);
n != -1 && this.observers.splice(n, 1);
}
public notifyObservers(): void {
console.log("notify all the observers", this.observers);
this.observers.forEach(observer => observer.notify());
}
}
4.3.4 使用示例
const subject: Subject = new Subject();
const semlinker = new ConcreteObserver("semlinker");
const kaquqo = new ConcreteObserver("kakuqo");
subject.addObserver(semlinker);
subject.addObserver(kaquqo);
subject.notifyObservers();
subject.deleteObserver(kaquqo);
subject.notifyObservers();
以上程式碼成功執行後,控制檯會輸出以下結果:
[LOG]: { "name": "semlinker" }, is pushed!
[LOG]: { "name": "kakuqo" }, is pushed!
[LOG]: notify all the observers, [ { "name": "semlinker" }, { "name": "kakuqo" } ]
[LOG]: semlinker has been notified.
[LOG]: kakuqo has been notified.
[LOG]: remove, { "name": "kakuqo" }
[LOG]: notify all the observers, [ { "name": "semlinker" } ]
[LOG]: semlinker has been notified.
通過觀察以上的輸出結果,當觀察者被移除以後,後續的通知就接收不到了。觀察者模式支援簡單的廣播通訊,能夠自動通知所有已經訂閱過的物件。但如果一個被觀察者物件有很多的觀察者的話,將所有的觀察者都通知到會花費很多時間。 所以在實際專案中使用的話,大家需要注意以上的問題。
五、參考資源
- MDN - MutationObserver
- MDN - MutationRecord
- JavaScript 標準參考教程 - MutationObserver
- mutationobserver-api-guide
- javascript.info-mutation-observer