JS DOM变化的监听检测与应用
参考文档: https://www.zhangxinxu.com/wordpress/2019/08/js-dom-mutation-observer/
JS DOM变化的检测从上往下,从今往古有下面3-5种方法。
自定义元素声明周期与DOM变化检测
当我们使用ES6的基础创建自定义元素(Custom Elements)的时候,是可以使用其内置的生命周期,对DOM变化进行实时更新的。
我们直接看例子来说明吧。
例如,我们希望自定义一个<x-ell>
元素,直接根据rows
属性值显示多少行打点。
例如2行点点点:
<x-ell rows="2">
3行点点点:
<x-ell rows="3">
此时,相关JS如下(生命周期相关方法红色高亮):
class HTMLEllElement extends HTMLElement {
// 指定观察的属性,这样attributeChangedCallback才会起作用
static get observedAttributes() { return ['rows']; }
constructor() {
super();
}
// 下面4个方法为常用生命周期
connectedCallback() {
console.log('自定义元素加入页面');
// 执行渲染更新
this._updateRendering();
}
disconnectedCallback() {
// 本例子该生命周期未使用,占位示意
console.log('自定义元素从页面移除');
}
adoptedCallback() {
// 本例子该生命周期未使用,占位示意
console.log('自定义元素转移到新页面');
}
attributeChangedCallback(name, oldValue, newValue) {
console.log('自定义元素属性发生变化');
this._rows = newValue;
// 执行渲染更新
this._updateRendering();
}
// 设置直接get/set rows属性的方法
get rows() {
return this._rows;
}
set rows(v) {
this.setAttribute('rows', v);
}
_updateRendering() {
// 根据变化的属性,改变组件的UI
// ...
}
}
// 定义x-ell标签元素为多行打点元素
customElements.define('x-ell', HTMLEllElement);
其中几个生命周期方法分别是:
connectedCallback
每次自定义元素连接到文档中的时候会触发。每次移动节点时也会发生,并且可能在元素的内容完全解析之前发生。注意,元素如果和文档失去连接也可能触发connectedCallback,所以最好先使用Node.isConnected(IE不支持)确认下。disconnectedCallback
每次自定义元素和文档连接中断的时候触发。adoptedCallback
每次自定义元素移动到新的文档时候触发。attributeChangedCallback
每次自定义元素的属性增删改的时候会触发,不过需要先在在静态get observedAttributes方法中指定要注意更改的属性。例如上面的案例就是在observedAttributes静态方法中返回了['rows']
,于是当rows属性发生变化时候会触发attributeChangedCallback
这个生命周期。
css代码:
x-ell {
display: block;
width: 200px;
}
html代码:
<x-ell rows="2">对于现代浏览器,例如webkit内核的浏...组合如下。</x-ell>
<p><button onClick="document.querySelector('x-ell').rows = '3';">点击设置rows为3</button></p>
js代码:
class HTMLEllElement extends HTMLElement {
// 指定观察的属性,这样attributeChangedCallback才会起作用
static get observedAttributes() { return ['rows']; }
constructor() {
// constructor中首先第一件事情就是调用 super
// super指代了整个prototype或者__proto__指向的对象
// 这一步免不了的
super();
// 创建shadow元素,实际上,从本例要实现的效果讲,
// 直接元素上设置也可以,就是HTML丑了点,CSS要放在外部
// 且目前火狐并不支持shadow dom可以不用
var shadow = this.attachShadow({
// open外部可访问(通过element.shadowRoot),closed则不能
mode: 'open'
});
// 文本内容移动到shadow dom元素中
var div = document.createElement('div');
div.innerHTML = this.innerHTML;
this.innerHTML = '';
var style = document.createElement('style');
shadow.appendChild(style);
shadow.appendChild(div);
}
// 下面4个方法为常用生命周期
connectedCallback() {
console.log('自定义元素加入页面');
// 执行渲染更新
this._updateRendering();
}
disconnectedCallback() {
// 本例子该生命周期未使用,占位示意
console.log('自定义元素从页面移除');
}
adoptedCallback() {
// 本例子该生命周期未使用,占位示意
console.log('自定义元素转移到新页面');
}
attributeChangedCallback(name, oldValue, newValue) {
console.log('自定义元素属性发生变化');
this._rows = newValue;
// 执行渲染更新
this._updateRendering();
}
get rows() {
return this._rows;
}
set rows(v) {
this.setAttribute('rows', v);
}
_updateRendering() {
// 根据变化的属性,改变组件的UI
var shadow = this.shadowRoot;
var childNodes = shadow.childNodes;
var rows = this._rows;
for (var i = 0; i < childNodes.length; i++) {
if (childNodes[i].nodeName === 'STYLE') {
childNodes[i].textContent = `div {
display: -webkit-box;
-webkit-line-clamp: ${rows};
-webkit-box-orient: vertical;
overflow: hidden;
}`;
}
}
}
}
// 定义x-ell标签元素为多行打点元素
customElements.define('x-ell', HTMLEllElement);
点击按钮会执行JS,让自定义元素的rows
属性值为3
:
document.querySelector('x-ell').rows = '3';
MutationObserver与DOM变化检测
然而Custom Elements自定义元素IE并不支持,如果我们想要兼容到IE11浏览器的话,需要求助其他方法,则可以试一试MutationObserver。
Mutation Observer是在DOM level 4中定义的新API,可以监听DOM的变化,单词mutation是“突变”的意思,observer是“观察者”的意思,连起来就是“突变观察者”的意思。
该API执行逻辑是先观察,再执行,是一个异步的过程。
这样讲没什么感觉,我们还是从例子说起吧,还是点点点的例子,下面看看我是如何使用MutationObserver实现IE11也兼容的多行打点的效果的。
下面IE11浏览器下最终实现效果,默认HTML如下:
<x-ell rows="2">对于现代浏览器...组合如下。</x-ell>
可以让<x-ell>
元素内的文本变得很少:
document.querySelector('x-ell').innerText = '只有一行啦!';
代码:
CSS代码:
x-ell {
display: block;
width: 200px;
word-break: break-all;
}
x-ell > x-dot::before {
content: '...';
position: absolute;
}
x-ell > x-hidden {
position: absolute;
clip: rect(0 0 0 0);
font-size: 0;
}
HTML代码:
<x-ell rows="2">对于现代浏览器...组合如下。</x-ell>
<p>
<button onClick="document.querySelector('x-ell').rows = '3';">点击设置rows为3</button>
<button onClick="document.querySelector('x-ell').innerText = '只有一行啦!';">文字内容变少</button>
</p>
JS代码:
/**
@description 本着演示目的,我们只考虑x-ell内部都是纯文本的情况
@author zhangxinxu(.com) from https://www.zhangxinxu.com/wordpress/?p=8925
@licence MIT,保留原作者和出处
*/
[].slice.call(document.querySelectorAll('x-ell')).forEach(function (ell) {
ell.render = function () {
var rows = this.rows;
// 宽度,这里就clientWidth代替,padding之类就先不考虑
var width = this.clientWidth;
// 这里我们借助canvas做边界判断
var canvas = document.createElement('canvas');
var context = canvas.getContext('2d');
// 需要字号和字体
var computedStyle = window.getComputedStyle(this);
context.font = computedStyle.fontSize + ' ' + computedStyle.fontFamily;
// 所有文本内容
var text = this.textContent || this.innerText;
// 字符分隔为数组
var arrText = text.split('');
var textInit = '';
// 当前第几行
var line = 1;
// 中断点
var breakIndex = -1;
// 点点点的宽度
var widthDots = context.measureText('...').width;
for (var n = 0; n < arrText.length; n++) {
var testLine = textInit + arrText[n];
var metrics = context.measureText(testLine);
var testWidth = metrics.width;
if (testWidth > width) {
if (line >= rows) {
// 超出了
var lastTextWidth = context.measureText(arrText[n - 1]).width;
if (lastTextWidth >= widthDots) {
breakIndex = n - 1;
} else {
breakIndex = n - 2;
}
break;
} else {
textInit = arrText[n];
line++;
}
} else {
textInit = testLine;
}
}
if (breakIndex != -1) {
this.innerHTML = arrText.slice(0, breakIndex).join('') + '<x-dot aria-hidden="true"></x-dot><x-hidden>' + arrText.slice(breakIndex - arrText.length).join('') + '</x-hidden>';
}
};
// 重新定义rows属性
Object.defineProperty(ell, 'rows', {
writeable: true,
enumerable: true,
get: function () {
return this.getAttribute('rows');
},
set: function (rows) {
this.setAttribute('rows', rows);
}
});
// 打点显示
ell.render();
// 开始观察ell元素
var observer = new MutationObserver(function (mutationsList, mutationObserver) {
// mutationsList参数是个MutationRecord对象数组,描述了每个发生的变化
// mutationObserver参数就是调用了回调的MutationObserver
mutationsList.forEach(function (mutation) {
var target = mutation.target;
if (!target || !target.render) {
return;
}
// 变化的类型
switch(mutation.type) {
case 'characterData':
// 文本内容变化
target.render();
break;
case 'attributes':
// rows属性值发生了变化
target.render();
break;
}
});
});
// 开始观察ell元素并制定观察内容
observer.observe(ell, {
attributes: true,
subtree: true,
characterData: true,
attributeFilter: ['rows']
});
});
多行自动打点的核心JS代码
核心JavaScript代码如下:
/**
@description 本着演示目的,我们只考虑x-ell内部都是纯文本的情况
@author zhangxinxu(.com) from https://www.zhangxinxu.com/wordpress/?p=8925
@licence MIT,保留原作者和出处
*/
[].slice.call(document.querySelectorAll('x-ell')).forEach(function (ell) {
ell.render = function () {
var rows = this.rows;
// 基于rows打点实现……
// 这里代码不是本文重点,略!有兴趣可以参阅demo页面
};
// 重新定义rows属性
Object.defineProperty(ell, 'rows', {
writeable: true,
enumerable: true,
get: function () {
return this.getAttribute('rows');
},
set: function (rows) {
this.setAttribute('rows', rows);
}
});
// 打点显示
ell.render();
// 构造观察ell元素的实例
var observer = new MutationObserver(function (mutationsList) {
// mutationsList参数是个MutationRecord对象数组,描述了每个发生的变化
mutationsList.forEach(function (mutation) {
var target = mutation.target;
if (!target || !target.render) {
return;
}
// 变化的类型
switch(mutation.type) {
case 'characterData':
// 文本内容变化
target.render();
break;
case 'attributes':
// rows属性值发生了变化
target.render();
break;
}
});
});
// 开始观察ell元素并制定观察内容
observer.observe(ell, {
attributes: true,
subtree: true,
characterData: true,
attributeFilter: ['rows']
});
});
是不是有点看不懂,不知道说的啥跟啥,稍安勿躁,我们去粗取精一下,其实没什么内容。
MutationObserver检测DOM变化的套路都是固定的,所以实际开发的时候往往都是Ctrl + C然后Ctrl +V然后改改参数值就可以了。
其实就两部分组成,如下:
// part 1
var observer = new MutationObserver(callback);
// part 2
observer.observe(node, options);
定义一个观察实例,实例方法中有个callback
回调参数,然后开始观察指定node
节点的变化,观察的内容由options
参数决定。
由于callback
是最后执行,所以我们先了解node, options
这两个参数,再来看看callback
中的参数。
API参数细节深入
observer.observe(node, options)
node指观察的节点,例如本例中是观察<x-ell>
元素。
options是一个MutationObserverInit对象,属性值、类型以及描述参见下表:
属性 | 类型 | 描述 |
---|---|---|
childList | Boolean | 观察子节点的变动 |
attributes | Boolean | 观察属性的变动 |
characterData | Boolean | 观察节点内容或节点文本的变动 |
subtree | Boolean | 观察所有后代节点的变动 |
attributeOldValue | Boolean | 当观察到attributes变动时,是否记录变动前的属性值 |
characterDataOldValue | Boolean | 当观察到characterData变动时,是否记录变动前的属性值 |
attributeFilter | Array | 指定需要观察的特定属性(比如[‘src’, ‘rows’]),不在此数组中的属性变化时将被忽略 |
需要注意的是,不能单独观察subtree
变动,必须同时指定childList
、attributes
和characterData
中的一种或多种。
例如,本文打点的例子中,options
设置的值是:
{
attributes: true,
subtree: true,
characterData: true,
attributeFilter: ['rows']
}
表示观察所有子节点的属性变化以及子节点字符数据的变化,其中,观察的属性只观察'rows'
,其他属性变化则忽略。
new MutationObserver(callback)
callback是一个回调函数,其支持两个参数(见下面代码红色高亮):
new MutationObserver(function (mutationsList, mutationObserver) {})
其中,mutationsList
是一个MutationRecord对象数组,mutationObserver
就是返回的MutationObserver实例本身,可以用来清空MutationRecord或者中断观察,不过实际开发用到的不多。
MutationRecord对象
mutationsList
是一个MutationRecord对象数组,包含所有观察的数据。
我们可以使用forEach
遍历mutationsList
,例如:
mutationsList.forEach(function(mutation) {
// 此时mutation就是一个MutationRecord对象
});
上面循环中的mutation
就是一个MutationRecord对象,其包含下表所示的属性以及描述内容:
属性 | 类型 | 描述 |
---|---|---|
type | String | 根据变动类型的不同,值可能为attributes,characterData或者childList |
target | Node | 发生变动的DOM节点,可能是删除节点的父元素 |
addedNodes | NodeList | 被添加的节点,如果没有则是null |
removedNodes | NodeList | 被删除的节点,如果没有则是null |
previousSibling | Node | 被添加或被删除的节点的前一个兄弟节点,如果没有则是null |
nextSibling | Node | 被添加或被删除的节点的后一个兄弟节点,如果没有则是null |
attributeName | String | 发生变更的属性的名称,如果没有则是null |
attributeNamespace | String | 发生变更的属性的命名空间,在SVG元素操作时比较有用,如果没有则是null |
oldValue | String | 如果type为attributes,则返回该属性变化之前的属性值;如果type为characterData,则返回该节点变化之前的文本数据;如果type为childList,则返回null |
在本文的打点案例中,callback是下面这样处理的(实际开发case语句可以合在一起,这里为了演示清晰分开了):
new MutationObserver(function (mutationsList) {
// 遍历出所有的MutationRecord对象
mutationsList.forEach(function (mutation) {
switch (mutation.type) {
case 'characterData':
// 文本内容变化,触发重渲染...
target.render();
break;
case 'attributes':
// rows属性值发生了变化,触发重渲染...
render();
break;
}
});
});
是不是就很好理解了。
MutationObserver实例的其他方法
var observer = new MutationObserver(callback)
上面的observer就是一个实例对象,支持下面3个方法:
observer.observe(node, options)
上面已经介绍过了,这里略。
observer.takeRecords()
没有参数。返回观察者回调函数检测到但尚未处理的所有匹配的DOM更改的列表,使变化队列为空。最常见的使用情况是在断开观察者连接之前立即获取所有挂起的变化记录,以便在停止观察者时处理任何挂起的变化。
observer.disconnect()
没有参数。停止对DOM变化的观察,直到重新调用observe()
方法。
MutationObserver模式的优点
相比下一节要介绍的Mutation events,MutationObserver性能要更高。Mutation Events是同步执行的,它的每次调用,都需要从事件队列中取出事件,执行,然后事件队列中移除,期间需要移动队列元素。如果事件触发的较为频繁的话,每一次都需要执行上面的这些步骤,那么浏览器会被拖慢。而MutationObserver所有监听操作以及相应处理都是在其他脚本执行完成之后异步执行的,并且是所以变动触发之后,将变得记录在数组中,统一进行回调的,也就是说,当你使用observer监听多个DOM变化时,并且这若干个DOM发生了变化,那么observer会将变化记录到变化数组中,等待一起都结束了,然后一次性的从变化数组中执行其对应的回调函数。
因此,如果你的浏览器不需要兼容IE9,IE10浏览器,推荐使用MutationObserver实现DOM变化的检测。
Mutation events与DOM变化检测
如果你的项目需要兼容IE9,IE10浏览器,同时想要实现对DOM变化的检测,则可以试试Mutation events。
Mutation events语法上相对简单易懂很多。
你就认为是和'click'
, 'mouseover'
一样的DOM事件用就好了。
支持的事件列表如下:
DOMAttrModified
Chrome/Safari不支持DOMAttributeNameChanged
DOMCharacterDataModified
DOMElementNameChanged
DOMNodeInserted
DOMNodeInsertedIntoDocument
IE不支持DOMNodeRemoved
DOMNodeRemovedFromDocument
IE不支持DOMSubtreeModified
具体描述见下表(IE不支持的两个我们忽略,这个就算没兼容性问题和很少用到):
事件名称 | 事件描述 |
---|---|
DOMAttrModified | DOM属性发生修改 |
DOMAttributeNameChanged | DOM属性名发生变化 |
DOMCharacterDataModified | DOM文本数据发生修改 |
DOMElementNameChanged | DOM元素名发生变化 |
DOMNodeInserted | DOM节点插入 |
DOMNodeRemoved | DOM节点删除 |
DOMSubtreeModified | DOM子元素修改 |
使用例子:
element.addEventListener("DOMNodeInserted", function (event) {
// event.target就是依次插入的DOM节点
}, false);
如果我们使用Mutation events实现兼容IE9浏览器的多行打点效果该怎么实现呢?
代码可就简单多了,就下面这几行就好了:
/**
@description 本着演示目的,我们只考虑x-ell内部都是纯文本的情况
@author zhangxinxu(.com) from https://www.zhangxinxu.com/wordpress/?p=8925
@licence MIT,保留原作者和出处
*/
[].slice.call(document.querySelectorAll('x-ell')).forEach(function (ell) {
ell.render = function () {
var rows = this.rows;
// 基于rows打点实现……
// 这里代码不是本文重点,略!有兴趣可以参阅demo页面
};
// 重新定义rows属性
Object.defineProperty(ell, 'rows', { /* ... */ });
// 打点显示
ell.render();
// 开始观察ell元素
ell.addEventListener('DOMCharacterDataModified', function () {
this.render();
});
ell.addEventListener('DOMAttrModified', function () {
this.render();
});
});
上面代码大家一看就知道怎么回事了,绑定DOMCharacterDataModified事件,意味着如果<x-ell>
元素内部文字变化了,执行一次重绘;绑定了DOMAttrModified事件,意味着属性发生变化的时候重绘。
完整代码:
css代码:
x-ell {
display: block;
width: 200px;
word-break: break-all;
}
x-ell > x-dot::before {
content: '...';
position: absolute;
}
x-ell > x-hidden {
position: absolute;
clip: rect(0 0 0 0);
font-size: 0;
}
html代码:
<x-ell rows="2">对于现代浏览器...组合如下。</x-ell>
<p>
<button onClick="document.querySelector('x-ell').rows = '3';">点击设置rows为3</button>
<button onClick="document.querySelector('x-ell').innerText = '只有一行啦!';">文字内容变少</button>
</p>
js代码:
/**
@description 本着演示目的,我们只考虑x-ell内部都是纯文本的情况
@author zhangxinxu(.com) from https://www.zhangxinxu.com/wordpress/?p=8925
@licence MIT,保留原作者和出处
*/
[].slice.call(document.querySelectorAll('x-ell')).forEach(function (ell) {
ell.render = function () {
var rows = this.rows;
// 宽度,这里就clientWidth代替,padding之类就先不考虑
var width = this.clientWidth;
// 这里我们借助canvas做边界判断
var canvas = document.createElement('canvas');
var context = canvas.getContext('2d');
// 需要字号和字体
var computedStyle = window.getComputedStyle(this);
context.font = computedStyle.fontSize + ' ' + computedStyle.fontFamily;
// 所有文本内容
var text = this.textContent || this.innerText;
// 字符分隔为数组
var arrText = text.split('');
var textInit = '';
// 当前第几行
var line = 1;
// 中断点
var breakIndex = -1;
// 点点点的宽度
var widthDots = context.measureText('...').width;
for (var n = 0; n < arrText.length; n++) {
var testLine = textInit + arrText[n];
var metrics = context.measureText(testLine);
var testWidth = metrics.width;
if (testWidth > width) {
if (line >= rows) {
// 超出了
var lastTextWidth = context.measureText(arrText[n - 1]).width;
if (lastTextWidth >= widthDots) {
breakIndex = n - 1;
} else {
breakIndex = n - 2;
}
break;
} else {
textInit = arrText[n];
line++;
}
} else {
textInit = testLine;
}
}
if (breakIndex != -1) {
this.innerHTML = arrText.slice(0, breakIndex).join('') + '<x-dot aria-hidden="true"></x-dot><x-hidden>' + arrText.slice(breakIndex - arrText.length).join('') + '</x-hidden>';
}
};
// 重新定义rows属性
Object.defineProperty(ell, 'rows', {
writeable: true,
enumerable: true,
get: function () {
return this.getAttribute('rows');
},
set: function (rows) {
this.setAttribute('rows', rows);
// Chrome/Safari不支持DOMAttrModified
// 可以直接检测属性变化
if (typeof document.webkitHidden != 'undefined') {
this.render();
}
}
});
// 打点显示
ell.render();
// 开始观察ell元素
ell.addEventListener('DOMCharacterDataModified', function () {
this.render();
});
ell.addEventListener('DOMAttrModified', function () {
this.render();
});
});
Mutation events更真实的应用
实际上,Mutation events更多的是绑定在document.body这种级别的容器元素上,要来检测下面子元素的更新或删除,而不是像上面这个例子这样,直接绑定在目标元素上。
例如想要知道页面上是不是新增了一个textarea元素,会有类似下面代码:
document.addEventListener('DOMNodeInserted', function(event) {
var target = event.target;
if (target.nodeName.toLowerCase() === 'textarea') {
// 如果是textarea元素...
}
});
看上去平淡无奇,但实际上却是个很烧的东西,例如页面是append一个巨大的表单,其DOM节点元素有500个之多,那’DOMNodeInserted’对应的事件也会执行500次,每次执行event.target
就是一个节点元素,关键是每次执行都是同步的,一旦我们的DOM处理逻辑比较复杂,那整个页面的性能就会有巨大的隐患。
这就是为什么Mutation events规范后来被舍弃的原因,虽然其API用起来真的是顺手。
和MutationObserver细节的差异
拿DOMNodeRemoved删除具体,在Mutation events中,当观察到删除行为发生的时候,DOM元素还在文档流中,但是在Mutation Observer中,DOM元素已经不在文档流中了,这个DOM元素对象本身还在,但是,类似dom.parentElement
这样的执行就会失败,因为不在文档流中,无法进行DOM查询,但是Mutation events中却可以。
Chrome/Safari不支持DOMAttrModified的处理
Chrome/Safari不支持DOMAttrModified,如下兼容性图所示:
但是在这些浏览器下,demo页面功能也完全正常,这是怎么回事呢?
这就引出最后一个DOM变化检测方法,Object.defineProperty,可以方便对自定义属性的变化进行检测。
Object.defineProperty与属性变化检测
如果我们只想检测某几个DOM属性的变化,而不需关心DOM节点的增删改,则Object.defineProperty可以说是非常好的方法。
例如这里,我们想要实现'rows'
属性值发现变化的时候自动重新确认,则可以像下面这样处理:
// 重新定义rows属性
Object.defineProperty(ell, 'rows', {
writeable: true,
enumerable: true,
get: function () {
return this.getAttribute('rows');
},
set: function (rows) {
this.setAttribute('rows', rows);
// rows变化了,重渲染
this.render();
}
});
我们重新给ell
这个DOM对象自定义一个'rows'
属性。其中,当我们ell.rows
取值的时候会执行get方法,当ell.rows = 'xxx'
赋值的时候会执行set方法。
于是,我们只要在set方法中埋一个执行重渲染的方法,那么每次rows属性赋值的时候都会自动触发重渲染,实现了DOM元素属性的检测功能。
Object.defineProperty语法如下:
Object.defineProperty(obj, prop, descriptor)
至于各个参数是什么含义,我这里不展开啊,因为内容比较多,网上文章也很多,不了解的人可以看看MDN文档上的介绍。
CSS3 animation动画与检测
这个方法主要借助animationend
回调判断DOM元素的变化,此方法可以识别添加,属性变化,甚至DOM删除都可以(配合合适的选择器即可)。
这篇文章最后有介绍,大家可以先了解了解,里面方案并不是很完美,以后有机会我再展开介绍。
然后借助CSS3动画检测DOM变化还有专门的Github项目,大家可以了解下:https://github.com/muicss/sentineljs
此方法非本文重点内容,不展开。