使用MutationObserver监听页面所有DOM

700 阅读5分钟

1. 什么是MutationObserver?

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

MutationObserver是一个异步操作,属于微任务,在Vue源码中实现nextTick的调度机制使用到了这个。为什么是异步操作就不用说了吧。

  • 构造函数

    MutationObserver()

    创建并返回一个新的 MutationObserver 它会在指定的 DOM 发生变化时被调用。

      // 创建一个观察器实例并传入回调函数
      const observer = new MutationObserver(callback);
    
  • 方法

    disconnect()

    阻止 MutationObserver实例继续接收的通知,直到再次调用其 observe() 方法,该观察者对象包含的回调函数都不会再被调用。

    // 可停止观察
    observer.disconnect();
    

    observe()

    配置 MutationObserver 在 DOM 更改匹配给定选项时,通过其回调函数开始接收通知。

    // 以上述配置开始观察目标节点
    observer.observe(targetNode, config)
    

    takeRecords()

    从 MutationObserver 的通知队列中删除所有待处理的通知,并将它们返回到MutationRecord对象的新Array中。

    const mutations = observer.takeRecords();
    

2. MutationObserver一般用来做什么?

(1) 防止第三方注入js文件

参考:!script、iframe注入型防运营商劫持

(2) 防止删除前端生成的水印

export const observerDOM = (targetNode, vm) => {
  // const targetNode = document.querySelector(target);

// 观察器的配置(需要观察什么变动)
  const config = {attributes: true, childList: true, subtree: true};

// 当观察到变动时执行的回调函数
  const callback = function (mutationsList, observer) {
    // Use traditional 'for loops' for IE 11
    console.log("mutationsList", mutationsList)
    for (let mutation of mutationsList) {
      if (mutation.type === 'childList' && mutation.removedNodes.length > 0) {
        if (mutation.removedNodes[0].id === "1.23452384164.123412415" ||
          mutation.removedNodes[0].id === "1.23452384164.123412422" ||
          mutation.removedNodes[0].id === "1.23452384164.123412322"
        ) {
          console.log("mutation.removedNodes[0].id", mutation.removedNodes[0].id)
          vm.$baseMessage("请不要尝试删除水印", 'error');
          location.reload();
          // console.log('A child node has been added or removed.');
        }
      } else if (mutation.type === 'attributes') {
        if (mutation.target.id === "1.23452384164.123412415" ||
          mutation.target.id === "1.23452384164.123412422" ||
          mutation.target.id === "1.23452384164.123412322") {
          vm.$baseMessage("请不要尝试删除水印", 'error');
          location.reload();
        }
      }
    }
  };

  // 创建一个观察器实例并传入回调函数
  const observer = new MutationObserver(callback);

// 以上述配置开始观察目标节点
  observer.observe(targetNode, config)
}

// 函数调用
observerDOM(this.$refs.tableContainer, this)

(3) 用来处理页面的敏感数据

主要我们说一下如何使用这个API来实现前端处理敏感数据,因为我们有个项目是为第三方系统处理敏感数据的,需要导入我们自己的工具js来实现前端处理敏感数据。经过调研发现这个API应该可以处理。只要我监听页面的每个DOM元素,获取到DOM元素的值就能实现这个功能了!想法很饱满,现实很骨感。

最先我们使用innerText来获取DOM元素的值,获取到的值发现既包含文本节点的值又包含了子DOM节点的值,所以一直替换失败。所以在写代码前先搞清楚几个概念。

  1. DOM的最小组成单位叫做节点(node),一个文档的树形结构(DOM树),就是由各种不同类型的节点组成。

  2. 对于HTML文档,节点主要有以下六种类型:Document节点、DocumentType节点、Element节点、Attribute节点、Text节点和DocumentFragment节点。

  3. 六种类型分别对应的nodeType

    类型nodeNamenodeType
    DOCUMENT_NODE#document9
    ELEMENT_NODE大写的HTML元素名1
    ATTRIBUTE_NODE等同于Attr.name2
    TEXT_NODE#text3
    DOCUMENT_FRAGMENT_NODE#document-fragment11
    DOCUMENT_TYPE_NODE等同于DocumentType.name10

经过重新温习基础的知识,仔细想一下,我们只是获取文本节点的值用来进行处理。所以对应就是target.firstChild.nodeValue。

处理的伪代码如下:

const filterTags = ['BODY', 'SCRIPT', 'HTML', 'META', 'TITLE', 'HEAD', 'SCRIPT', 'TABLE', 'IMG', 'H1', 'LINK', 'NOSCRIPT', 'SVG', 'VIDEO'];
const valueTags = ['INPUT', 'TEXTAREA'];



const options = {
  childList: true,
  subtree: true,
  attribute: true,
  characterData: true,
  attributeFilter: ['class', 'style']
};
const observer = new MutationObserver(mutations => {

  for(let mutation of mutations) {

    if (!filterTags.includes(mutation.target.nodeName)) {
      if (mutation.target instanceof HTMLElement) {
        if (valueTags.includes(mutation.target.nodeName)) {
           // 处理input,textarea中的值
           // 处理逻辑
        } else {
          if (mutation.target.firstChild.nodeValue) {
            // 这里根据DOM的节点来获取值
            // 得到值来处理
          } else {
            if (Array.form(mutation.target.children).length > 0) {
              flatDomNodes(mutation.target.firstChild.nodeValue, Array.form(mutation.target.children))
            }
          }
        }
      }
    }
  }

});

const domElem = document.documentElement
observer.observe(domElem, options);

function flatDomNodes(parentText, arr = []) {
  if (!Array.isArray(arr)) return;
  for (let i = 0; i < arr.length; i++) {
    if (arr[i].firstChild.nodeValue && (parentText !== arr[i].firstChild.nodeValue)) {
      // 处理值的逻辑 arr[i].firstChild.nodeValue
    } else if (valueTags.includes(arr[i].nodeName)) {
      // 处理值的逻辑 arr[i].value
    }
    if (Array.form(arr[i].children).length > 0) {
      flatDomNodes(arr[i].firstChild.nodeValue, Array.form(arr[i].children))
    }
  }
}

经过测试发现我们的第一版本的代码经常会导致页面卡死,通过排查发现我们这段递归的代码导致,因为页面的嵌套太深了,遇到几百条的表格那种直接卡死,甚至有时候浏览器就直接崩溃了。还有就是在递归中添加了setTimeout定时器。直呼好家伙,这不卡死才怪。

这时候突然想到之前看到的一个API,document.createNodeIterator !MDN NodeIterator 接口表示一个遍历 DOM 子树中节点列表的成员的迭代器。节点将按照文档顺序返回。

const nodeIterator = document.createNodeIterator(root[, whatToShow[, filter]]);

仔细想一下这不正是我们递归想要实现的地方么?所以把我们的代码改一下,去掉递归。

const filterTags = ['BODY', 'SCRIPT', 'HTML', 'META', 'TITLE', 'HEAD', 'SCRIPT', 'TABLE', 'IMG', 'H1', 'LINK', 'NOSCRIPT', 'SVG', 'VIDEO'];
const valueTags = ['INPUT', 'TEXTAREA'];



const options = {
  childList: true,
  subtree: true,
  attribute: true,
  characterData: true,
  attributeFilter: ['class', 'style']
};
const observer = new MutationObserver(mutations => {

  for(let mutation of mutations) {

    if (!filterTags.includes(mutation.target.nodeName)) {
      if (mutation.target instanceof HTMLElement) {
        if (valueTags.includes(mutation.target.nodeName)) {
           // 处理input,textarea中的值
           // 处理逻辑
        } else {
             getChildren(mutation.target);
          }
        }
      }
    }
  }

});

const domElem = document.documentElement
observer.observe(domElem, options);

// function flatDomNodes(parentText, arr = []) {
  // if (!Array.isArray(arr)) return;
  // for (let i = 0; i < arr.length; i++) {
    // if (arr[i].firstChild.nodeValue && (parentText !== arr[i].firstChild.nodeValue)) // {
      // 处理值的逻辑 arr[i].firstChild.nodeValue
   //  } else if (valueTags.includes(arr[i].nodeName)) {
      // 处理值的逻辑 arr[i].value
    // }
     // if (Array.form(arr[i].children).length > 0) {
      // flatDomNodes(arr[i].firstChild.nodeValue, Array.form(arr[i].children))
    // }
  // }
// }

function getChildren(parent) {
    let t = document.createNodeIterator(parent, NodeFilter.SHOW_ALL),{
        acceptNode(node) {
            // 处理的逻辑
        }
    });
    let currNode = null;
    while((currNode = t.nextNode()) !== null) {
        // 处理逻辑
    }
}

在处理过程中可能会遇到执行顺序的问题,一定要注意代码的执行时机。还有一个比较坑的地方:js相同的正则多次调用test()返回的值却不同的问题。

这是因为正则regg属性,设置的全局匹配。RegExp有一个lastIndex属性,来保存索引开始位置。

我们的解决方案:每次匹配之前将lastIndex的值设置为0。