当你想监听DOM时,不尝试用一下 MutationObserver 吗?

904 阅读9分钟

亲爱的观众老婆们大家好,欢迎大家欢迎来到一可的昆特牌馆小白的地盘(看在我为你宣传的份上,跪求别告我侵权台词啊T.T)。作为某浏览器的小前端,最近接到一个需求:某境外视频网站在我们浏览器打开后是白屏,希望帮忙定位原因并提出解决方案。

经过Fiddler各种抓包替换文件后,定位出问题是自家浏览器严格模式上的bug。然而浏览器发版太久,依靠商务推动对方修改代码也不是一时半刻就能解决问题,于是皮球又踢回给可怜的小前端。既然浏览器是自家的,那可不可以通过在对方页面中使用注入功能解决这问题呢?

示例代码大致如下:

<!doctype html>
 <html lang="en">
 <head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
 </head>
 <body>
    <script src="1.js"></script>
    <script src="2.js"></script>
    <script src="error.js"></script>
 </body>
 </html>

根据这样的情况,可以想到的解决方案是在页面替换error.js为我们上传到CDN的ok.js,这样页面就没有问题啦。然而注入不是银弹,客户端提供的注入功能是有限制的,只能在<head>中注入JS文件,无法直接修改DOM内容,这就意味着我们无法直接修改src指向,只通过在<head>文件中注入JS,真的能完成这个需求吗?至此似乎陷入了僵局。然而,我们一起回顾一下浏览器的渲染过程,在不添加deferasync属性的前提下,浏览器碰到<script>标签是会停下来等待JS文件下载下来,执行完毕后再继续解析渲染其他内容。如果我们有能力监测到DOM节点的添加并能对其进行干预,之前的需求就有解决方案了。幸运的是,我们拥有这样的能力,它就是MutationObserver

MutationObserver 简介

MutationObserver给开发者们提供了一种能在某个范围内的DOM树发生变化时作出适当反应的能力.该API设计用来替换掉在DOM3事件规范中引入的Mutation事件.

其实MDNMutationObserver的介绍是十分清晰的,但其中有些细节可能不太好理解。初看之下感觉这个API其实挺复杂的,但别急,先记住两个东东就好。首先是通过一个构造函数MutationObserver()创建一个观察实例,构造函数接受一个函数作为唯一参数,即const observer = MutationObserver(function(){})。其次要一个配置对象MutationObserverInit,即const option = {},别担心是什么高大上的东西,其实就是一个配置。之后调用实例,传入参数观察对象:observer.observe(targetElement, option)就好了。看吧看吧,是不是挺简单的?

不过,对于前端来说,不谈兼容性就使用API,那是耍流氓,因此我们需要瞧瞧MutationObserver在浏览器的的兼容性如何:

看到的基本都是原谅绿的,尤其移动端,可以放心使用。要注意的是版本比较旧浏览器是需要前缀的,即WebKitMutationObserverMozMutationObserver。由于自家浏览器是支持这个API的,我就不添加前缀了。不然可以参考MDN的的例子:

var observer = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver;

这样可以确保兼容性。

MutationObserverInit 对象简析

原本应该是先介绍构造函数及其传入的函数的,但想了想,发现先介绍这个配置对象会比较好理解。如之前所说,MutationObserver是可以监测DOM变化的,然而大家想一下,DOM变化其实有挺多类型的,比如改下文本,改下属性等等。如果这个API是监测全部变化的话,是不是有点浪费浏览器资源?监测范围可控,才是最佳实践,因而有MutationObserverInit这个配置对象。

MutationRecord可配置的挺多,初看还真会懵逼,但好好归纳之后,发现其实是十分清晰的。我们从主到次过一次。首先是监测的类型,分三类,分别是:

childList,代表观察目标节点子节点的变化;
attributes,代表观察目标节点属性的变化;
characterData,代表观察目标节点是文本节点、注释节点时的变化。

值得一提的是characterData,感觉用得不多,必须将观察的目标节点指定为文本、注释等节点时才会生效(补充一下:业务可能用到不多,但可能会有极为精彩的hack!如Vue源码中的用法。)。

举个栗子:

const p = document.querySelector('p');
const childListBtn = document.querySelector('.childList');
const characterDataBtn = document.querySelector('.characterData');
childListBtn.addEventListener('click', function() {
  p.innerHTML = 'childList';
})
characterDataBtn.addEventListener('click', function() {
  p.firstChild.data = 'characterDataBtn';
})
//...观察相关代码

如果你观察的目标节点是p标签,它的文本发生变化时,触发的是childList类型的变化,即触发的是子节点变化。只有将观察的目标节点设置为p.firstChild,且改变了p.firstChild里面的属性时,才会触发characterData类型的变化。希望大家注意这个细节。

分完类型之后,剩下的几个选项就是增强上面三个类型了:

subtree,代表不但观察子节点,还会观察目标节点所有的子孙节点,三种类型均可增强;
attributeOldValue,代表是否需要将发生变化的属性节点之前的属性值记录下来,这是用于增强attributes的;
attributeFilter,唯一一个值不是布尔值而是数组的选项,如果设置了该值,则只有该数组中包含的属性名发生变化时才会被观察到,也是增强attributes的;
characterDataOldValue,代表是否需要将发生变化的characterData节点之前的文本内容记录下来,这是用于增强attributes的。

值得注意的是,无论是三个大类还是增强的选项,默认值都是undefined,而增强选项必须在对应的选项设置为true的情况下才可使用,不然浏览器会报错。

MutationObserverInit这个配置对象就介绍完了,之后我们来看看MutationObserver的构造函数及其关键的回调函数。

构造函数 MutationObserver

其实我说MutationObserver的构造函数重要,是骗你的。重要的是构造函数是理解它接受的那个参数,即回调函数。回调函数执行时会被传入两个参数。先说最好理解的第二个参数,传入的是new出来的那个实例。第一个参数则是一个数组,由数组中的每一项都是一个对象:MutationRecord,接下来我们将好好看一看这个对象。

MutationRecord对象包含很多属性,主要是用于展示DOM节点发生了什么变化:

这里我偷个懒,直接上了MDN文档上的说明。文档写的很详细,但这里说一些需要注意的细节。addedNodesremovedNodes的类型是 NodeList,也就意味着它不是一个真正的数组,使用数组方法时需要谨慎。另外,更重要的是理解为什么回调函数的第一个参数是数组。按照逻辑,某一个节点产生变化,一个MutationRecord对象应该是足够描述它的变化的。我认为,这句话在MutationObserver中既对也不对,接下来的理解只是我个人的见解,还请dalao们指教。

MutationObserver所做到的,并不是在一个函数执行完后,比较DOM前后的变化,监测到变化后给回调函数传入相关的变化,而是记录DOM的变化“记录”。这看起来很绕,但请看如下例子:

<p>1111</p>
<button>click</button>

<script>
    function obFn(changeArr) {
      console.log(changeArr.length)
    }

    const observer = new MutationObserver(obFn);

    const option = {
      childList: true,
      subtree: true,
    };
    const p = document.querySelector('p');
    observer.observe(p, option);

    const btn = document.querySelector('button');
    btn.addEventListener('click', function() {
      p.innerHTML = '<span>1</span><span>2</span><span>3</span>';
    })
</script>

当点击按钮后,控制台会打印出1。但当btn的事件绑定改为:

btn.addEventListener('click', function() {
  const a = document.createElement('a');
  a.innerHTML = '<span>123</span>';
  const a1 = a.cloneNode();
  const a2 = a.cloneNode();
  p.appendChild(a);
  p.appendChild(a1);
  p.appendChild(a2);
})

点击按钮,控制台打印出3!第一例中,对<p>的操作记录只有一次:p.innerHTML = '<span>1</span><span>2</span><span>3</span>';,因而变化数组的长度是1,变化对象MutationRecordaddedNodes的长度是3 。第二例中,对<p>的操作记录有3次,因而变化数组的长度是3,数组每项的变化对象MutationRecordaddedNodes的长度是1 。

通过这样的对比,能理解我为何说MutationObserver是记录DOM的变化“记录”,因而回调函数的第一个参数是一个数组,而不是一个只记录了前后变化的对象!

最后,MutationObserver的构造函数生成的实例有三个方法:observe,接受两个参数,第一个是观察DOM节点,第二个是MutationObserverInit配置对象;disconnect,停止观察DOM节点;takeRecords,清空观察者对象的记录队列,并返回里面的内容。

小结

写了半天,感觉好像偏题了,最后附上解决问题写的代码:

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
    <script>
    function observerFn(changeArr, observer) {
      changeArr.forEach((obj) => {
        const addNodes = [...obj.addedNodes];
        addNodes.forEach((node) => {
          if (node.nodeName === 'SCRIPT' && /error/.test(node.src)) {
            node.src = 'ok.js';
            observer.disconnect();
          }
        })
      })
    }

    const observer = new MutationObserver(observerFn);
    const option = {
      childList: true,
      subtree: true,
    }
    observer.observe(document.documentElement, option);
    </script>
</head>
<body>
    <script src="1.js"></script>
    <script src="2.js"></script>
    <script src="error.js"></script>
</body>
</html>

上述代码稍微修改一下,其实能阻止某些劫持的。只要不是白名单内的JS文件,全部改变它的src,并上传到自己的服务器,这样可以记录下来到底是什么劫持了我们的网页,也算是个拓展了思路。除此之外,MutationObserver可以记录DOM变化,这个能力实在是太强大了,还有数之不尽的应用场景等待着我们研究与开发。

以上就是我对MutationObserver的一些归纳,希望能帮到大家。不当之处还请不吝赐教!