誰動了個人 DOM?

在某些場景下,咱們但願能監視 DOM 樹的變更,而後作一些相關的操做。好比監聽元素被插入 DOM 或從 DOM 樹中移除,而後添加相應的動畫效果。或者在富文本編輯器中輸入特殊的符號,如 #@ 符號時自動高亮後面的內容等。要實現這些功能,咱們就能夠考慮使用 MutationObserver API,接下來阿寶哥將帶你們一塊兒來探索 MutationObserver API 所提供的強大能力。javascript

閱讀完本文,你將瞭解如下內容:css

  • MutationObserver 是什麼;
  • MutationObserver API 的基本使用及 MutationRecord 對象;
  • MutationObserver API 常見的使用場景;
  • 什麼是觀察者設計模式及如何使用 TS 實現觀察者設計模式。

1、MutationObserver 是什麼

MutationObserver 接口提供了監視對 DOM 樹所作更改的能力。它被設計爲舊的 Mutation Events 功能的替代品,該功能是 DOM3 Events 規範的一部分。html

利用 MutationObserver API 咱們能夠監視 DOM 的變化。DOM 的任何變化,好比節點的增長、減小、屬性的變更、文本內容的變更,經過這個 API 咱們均可以獲得通知。前端

MutationObserver 有如下特色:vue

  • 它等待全部腳本任務執行完成後,纔會運行,它是異步觸發的。即會等待當前全部 DOM 操做都結束才觸發,這樣設計是爲了應對 DOM 頻繁變更的問題。
  • 它把 DOM 變更記錄封裝成一個數組進行統一處理,而不是一條一條進行處理。
  • 它既能夠觀察 DOM 的全部類型變更,也能夠指定只觀察某一類變更。

2、MutationObserver API 簡介

在介紹 MutationObserver API 以前,咱們先來了解一下它的兼容性:java

(圖片來源:https://caniuse.com/#search=M...node

從上圖可知,目前主流的 Web 瀏覽器基本都支持 MutationObserver API,而對於 IE 瀏覽器只有 IE 11 才支持。在項目中,如須要使用 MutationObserver API,首先咱們須要建立 MutationObserver 對象,所以接下來咱們來介紹 MutationObserver 構造函數。git

DOM 規範中的 MutationObserver 構造函數,用於建立並返回一個新的觀察器,它會在觸發指定 DOM 事件時,調用指定的回調函數。MutationObserver 對 DOM 的觀察不會當即啓動,而必須先調用 observe() 方法來指定所要觀察的 DOM 節點以及要響應哪些更改。github

2.1 構造函數

MutationObserver 構造函數的語法爲:web

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:變更前的值。這個屬性只對 attributecharacterData 變更有效,若是發生 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 這篇文章中使用的在線示例統一提取出來,作了一下彙總與分類:

一、 MutationObserver Example - childListhttps://codepen.io/impressive...

二、MutationObserver Example - childList with subtreehttps://codepen.io/impressive...

三、MutationObserver Example - Attributeshttps://codepen.io/impressive...

四、MutationObserver Example - Attribute Filterhttps://codepen.io/impressive...

五、MutationObserver Example - attributeFilter with subtreehttps://codepen.io/impressive...

六、MutationObserver Example - characterDatahttps://codepen.io/impressive...

七、MutationObserver Example - characterData with subtreehttps://codepen.io/impressive...

八、MutationObserver Example - Recording an Old Attribute Valuehttps://codepen.io/impressive...

九、MutationObserver Example - Recording old characterDatahttps://codepen.io/impressive...

十、MutationObserver Example - Multiple Changes for a Single Observerhttps://codepen.io/impressive...

十一、MutationObserver Example - Moving a Node Treehttps://codepen.io/impressive...

3、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、觀察者設計模式

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.

經過觀察以上的輸出結果,當觀察者被移除之後,後續的通知就接收不到了。觀察者模式支持簡單的廣播通訊,可以自動通知全部已經訂閱過的對象。但若是一個被觀察者對象有不少的觀察者的話,將全部的觀察者都通知到會花費不少時間。 因此在實際項目中使用的話,你們須要注意以上的問題。

5、參考資源

6、推薦閱讀

相關文章
相關標籤/搜索