【JS】695- 是谁动了我的 DOM?

前端自习课

共 8258字,需浏览 17分钟

 · 2020-08-25

在某些场景下,我们希望能监视 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=MutationObserver)

从上图可知,目前主流的 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 = {
      childListtrue// 监视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 使用示例


<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 {border1px dashed grey; width400pxheight300px;}
    
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, {
        subtreetrue// 监视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/impressivewebs/pen/aXVVjg

2、MutationObserver Example - childList with subtree:https://codepen.io/impressivewebs/pen/PVgyLa

3、MutationObserver Example - Attributes:https://codepen.io/impressivewebs/pen/XOzaWv

4、MutationObserver Example - Attribute Filter:https://codepen.io/impressivewebs/pen/pGGdVr

5、MutationObserver Example - attributeFilter with subtree:https://codepen.io/impressivewebs/pen/ywYaYv

6、MutationObserver Example - characterData:https://codepen.io/impressivewebs/pen/pGdpvq

7、MutationObserver Example - characterData with subtree:https://codepen.io/impressivewebs/pen/bZVpMZ

8、MutationObserver Example - Recording an Old Attribute Value:https://codepen.io/impressivewebs/pen/wNNjrP

9、MutationObserver Example - Recording old characterData:https://codepen.io/impressivewebs/pen/aXrzex

10、MutationObserver Example - Multiple Changes for a Single Observer:https://codepen.io/impressivewebs/pen/OqJMeG

11、MutationObserver Example - Moving a Node Tree:https://codepen.io/impressivewebs/pen/GeRWPX

三、MutationObserver 使用场景

3.1 语法高亮

相信大家对语法高亮都不会陌生,平时在阅读各类技术文章时,都会遇到它。接下来,阿宝哥将跟大家介绍如何使用 MutationObserver API 和 Prism.js 这个库实现 JavaScript 和 CSS 语法高亮。

在看具体的实现代码前,我们先来看一下以下 HTML 代码段未语法高亮和语法高亮的区别:

let htmlSnippet = `下面是一个JavaScript代码段:
    
        let greeting = "大家好,我是阿宝哥"; 
    

    
另一个CSS代码段:

       

         
            #code-container { border: 1px dashed grey; padding: 5px; } 
         
    

`

通过观察上图,我们可以很直观地发现,有进行语法高亮的代码块阅读起来更加清晰易懂。下面我们来看一下实现语法高亮的功能代码:


<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 {
        border1px dashed grey;
        padding5px;
        width550px;
        height200px;
      }
    
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, { childListtruesubtreetrue });
      // 动态插入带有代码段的内容
      codeContainer.innerHTML = `下面是一个JavaScript代码段:
         let greeting = "大家好,我是阿宝哥"; 

        
另一个CSS代码段:

        

          
             #code-container { border: 1px dashed grey; padding: 5px; } 
          
        

        `;
    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


<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 < 1return;
    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, {
    childListtrue,
    subtreetrue,
    attributestrue,
    attributeOldValuetrue,
    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/semlinker/a149763bf033d7f2dff2d32d60c27865

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
推荐阅读
你不知道的 Web Workers (上)

你不知道的 Web Workers (上)

你不知道的 Blob

你不知道的 Blob

你不知道的 WeakMap

你不知道的 WeakMap

聚焦全栈,专注分享 TypeScript、Web API、Deno 等技术干货。

浏览 43
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报