qrcode

加入前端交流群,阿里、腾讯和京东大佬都在的群里

你不知道的 MutationObserver

创建了一个 “重学TypeScript” 的微信群,想加群的小伙伴,加我微信 “semlinker”,备注 “1” 。阿里、京东、腾讯的大佬都在群里等你哟。

semlinker/awesome-typescript 1.8K

在某些场景下,我们希望能监视 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 构造函数的语法为:

1
const observer = new MutationObserver(callback);

相关的参数说明如下:

  • callback:一个回调函数,每当被指定的节点或子树有发生 DOM 变动时会被调用。该回调函数包含两个参数:一个是描述所有被触发改动的 MutationRecord 对象数组,另一个是调用该函数的 MutationObserver 对象。

使用示例

1
2
3
4
5
const observer = new MutationObserver(function (mutations, observer) {
mutations.forEach(function(mutation) {
console.log(mutation);
});
});

2.2 方法

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

  • observe(target[, options]):该方法用来启动监听,它接受两个参数。第一个参数,用于指定所要观察的 DOM 节点。第二个参数,是一个配置对象,用于指定所要观察的特定变动。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    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 使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<!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 - childListhttps://codepen.io/impressivewebs/pen/aXVVjg

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

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

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

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

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

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

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

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

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

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

三、MutationObserver 使用场景

3.1 语法高亮

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

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

1
2
3
4
5
6
7
8
9
10
11
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>
`

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
<!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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
<!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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 只包含部分代码
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/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 接口
1
2
3
interface Observer {
notify: Function;
}
4.3.2 创建 ConcreteObserver 观察者实现类
1
2
3
4
5
6
7
class ConcreteObserver implements Observer{
constructor(private name: string) {}

notify() {
console.log(`${this.name} has been notified.`);
}
}
4.3.3 创建 Subject 类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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 使用示例
1
2
3
4
5
6
7
8
9
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();

以上代码成功运行后,控制台会输出以下结果:

1
2
3
4
5
6
7
8
[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.

通过观察以上的输出结果,当观察者被移除以后,后续的通知就接收不到了。观察者模式支持简单的广播通信,能够自动通知所有已经订阅过的对象。但如果一个被观察者对象有很多的观察者的话,将所有的观察者都通知到会花费很多时间。 所以在实际项目中使用的话,大家需要注意以上的问题。

五、参考资源


欢迎小伙伴们订阅全栈修仙之路,及时阅读 TypeScript、Node/Deno、Angular 技术栈最新文章。

qrcode