聊聊JS DOM变化的监听检测与应用

这篇文章发布于 2019年08月30日,星期五,01:04,归类于 JS API。 阅读 60307 次, 今日 10 次 15 条评论

 

DOM变化与检测

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这个生命周期。

眼见为实,您可以狠狠地点击这里:HTML5自定义元素与rows属性直接控制几行打点demo

点击按钮会执行JS,让自定义元素的rows属性值为3

document.querySelector('x-ell').rows = '3';

然后可以看到自动变成了3行打点效果,如下Gif截屏所示:

gif截屏 2行打点变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>

然后我们点击下图所示的按钮:

点击按钮设置rows为3

会执行下面的JavaScript代码:

document.querySelector('x-ell').rows = '3';

然后就会魔术一般自动变成下面这样:

显示为3行打点

此时我们再点击后面一个按钮:

文字内容变少按钮

可以让<x-ell>元素内的文本变得很少:

document.querySelector('x-ell').innerText = '只有一行啦!';

结果如下图所示:

只有一行效果

您可以狠狠地点击这里:MutationObserver与多行打点demo

多行自动打点的核心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变动,必须同时指定childListattributescharacterData中的一种或多种。

例如,本文打点的例子中,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 Observer兼容性

//zxx: Chrome浏览器还支持ResizeObserver,浏览器缩放时候的观察与变化检测。

三、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事件,意味着属性发生变化的时候重绘。

您可以狠狠地点击这里:Mutation Events与多行打点demo

下面这个GIF是IE9浏览器下的录屏效果:

IE9下多行打点示意

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,如下兼容性图所示:

Mutation events兼容性

但是在这些浏览器下,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

此方法非本文重点内容,不展开。

六、碎碎念和参考文档

周一晚上到周四晚上,爆肝了4个晚上完成,平均22:00~1:30,中间看动漫刷微博用掉一个小时,所以每天大概花费两个半小时,总共文章花费10个小时时间,累积将近1万字。

字数统计示意

不出意外,下个月我的第二本书籍《CSS选择器世界》就要出版了,写书的时间哪里来的,就是每天下班回家坚持写一点,就算平均每天就一个小时,不断累积,不断重复,结果半年下来一本书就写好了,就是这么简单。

创作就是这么简单,不需要突然某一天鸡血,只要每天都有一点产出,持续不间断,久而久之,就有不错的成果出现了。起点上写小说的那些大神也是如此,偶尔暴走一天写1万字没锤子用,每天不间断3000字才是王者。

酒香也怕巷子深,如果你觉得这篇文章不错,欢迎分享到群啊,朋友圈,看得人多了,我更新也更有动力了。

参考文档

(本篇完)

分享到:


发表评论(目前15 条评论)

  1. HarryLit说道:

    大佬说得很仔细,学习 mark 一下

  2. mattuy说道:

    您好,请问多行打点如何处理行首标点、跨行数字拆分这些情况?浏览器的排版优化会导致计算的位置不对。我处理了大部分情况,但偶尔还是会出现问题,请问有什么解决思路或者相关资料吗?

  3. 山传说道:

    设置width为100%;怎么检测宽度的变化呢?

    • 张 鑫旭说道:

      浏览器之后应该会支持resize监听。你这个可以写个定时器,成本开销不高的。

    • 摸鱼中...说道:

      1. 给需要检测的元素添加一个 iframe / object 子元素,嵌入一个空的页面,另外这个子元素的样式可以设为绝对定位、低层级、透明、宽/高 100%;
      2. 通过 iframeElement.contentDocument.defaultView 或者 iframeElement. contentWindow 获取嵌入页面的 window ,给该 window 添加 resize 事件监听器,即可监听高度(只设置高度100%)或者宽度(只设置宽度100%)的变化。

      如果普通元素也有 resize 事件就不用这么麻烦了。

    • jinci说道:

      ResizeObserver

  4. thedb说道:

    佩服鑫哥的坚持
    +1

  5. 林颖树说道:

    方案二的换行可能存在问题啊
    图如下:
    https://image.zhangxinxu.com/image/blog/201909/a-comm-img.png

    [ 我是来找bug的,不要打我 ]

  6. Context说道:

    沙发

  7. xiaosong说道:

    真是佩服鑫哥的坚持

  8. 王晓晖说道:

    请问张老师,最近在做一个技术调研,就是使用 react 来监听某一个div中(contenteditable=”true”),用户输入或者删除div中的文字内容,新增的文字显示为绿色,删掉的文字显示为红色,是否可以使用您文中提到的方法来做监控,并且实时调用接口保存HTML到后台呢?
    感谢回复!