JS监控DOM大小的变化

JS监控DOM大小的变化

由于浏览器原生不自带DOM元素的resize事件,一直想弄一个JS监控DOM元素大小变化的功能,之前想过很多方法都各有优缺点,但是最近发现了一个很不错的方法,虽然以前有设想过但一直没有时间去实现,知道看到有人实现了和我的设想相同的一种比较优良的监听方法,这篇文章将主要讲述两种监听方法,以及优劣分析

DOM大小的变化的诱因

DOM大小的决定因素

在寻找如何监听DOM大小变化的解决办法之前,这里先列举DOM元素宽高的数值的情况:

  1. 自己设置的固定宽高,如样式设置style="width:100px;height:100px"
  2. 由上层元素决定,如样式设置style="width:50%;height:50%"
  3. 有下层元素决定,内容撑大

宽高各自独立,可以混合出现

DOM大小改变的原因

知道了决定的DOM大小的因素的3种情况,可以很快的列出大部分导致DOM大小变化的情况:

  1. 元素修改自己的宽高样式

    元素修改自己的宽高属性,是最简单最直接也是最常见的DOM大小变化的情况

  2. 当元素大小由上层元素(包括窗口大小)决定,上层元素大小发生变动时

  3. 当袁术大小由下层元素决定,下层元素大小发生变动时

上面三种是最常见也是最容易想到的,仔细思考还存在其他情况

  1. 元素的class的内容改变

    当元素的class的内容的宽高变化时,元素是不能监听到class内容变化的,虽然这个操作很不好,但是在切换页面皮肤时确实可能存在这样的问题

  2. 过渡/动画

    当元素设置了过渡/动画效果时,元素的宽高是可能在每一帧的出现变化的

这些因素也是最难以监听和获取的可以叫"隐式变化",之前3个比较简单的变化由于可以在节点树上和style上明显看出变化,可以简单叫"显式变化"

显式变化可以通过MutationObserver监听来达到事件流驱动节约性能的效果,但是隐式变化也是同样需要检测出来的,下面将列举两个检测办法,都各自有优缺点

解决办法

循环比对

最最简单直接有效的方法当然是每一帧去计算和比对需要监听的DOM的大小是否发生改变

这个方法很简单,也能很快的思考出来,但是这个方法很多文章或者人都认为有很大的性能消耗,其实这个方法的性能消耗在现在的计算机性能下是很小的,相较于每次DOM大小变化导致的重绘消耗的性能,占比很小

覆盖情况

100%,所有元素都可以这样监听

性能分析

展示性能分析前,先先说下测试展示用的机器的配置,CPU是interl i7-4790k,GPU为GTX960

前6秒为禁止状态,后6秒为改动大小的状态

100节点监听

JS监控DOM大小的变化

在前6秒里,可以看到以js的执行为主(毕竟没有重绘的需求),一直在执行dom大小的变化检测,但占据的性能并不是很多,大约1%。

JS监控DOM大小的变化

在后4秒里,由于出现了dom的大小变化,页面还是执行渲染,在这段时间JS(这里JS只执行循环对比)的消耗比例很低,大约2.5%(在正常监听的时候会有一些操作,比例是会明显增大的),反而是渲染会占据近乎2倍的消耗

JS监控DOM大小的变化

其他数量的节点的测试数据
节点数 静态总时间 静态JS耗时 动态总时间 动态JS耗时
100 5990ms 84.3ms(1.40%) 4020ms 111.1ms(2.76%)
500 5928ms 204.2ms(3.44%) 4760ms 209.7ms(4.41%)
1000 6007ms 342.9ms(5.71%) 5125ms 342ms(6.67%)
2000 6580ms 654ms(9.94%) 7955ms 1094.9ms(13.76%)
5000 7667ms 1880.6ms(24.53%) 6277ms 823.3ms(13.11%)

根据上面的表可以看到静态和动态的时间大体是跟着监听的DOM节点的数量成正比的,动态消耗的时间比静态略多可能是由于对多一层调用的堆栈导致,在5000节点的时候,动态渲染消耗的时间反而少了,是由于5000节点同时改变大小,消耗了大量的资源进行重绘,这时候的帧数也开始明显降低

JS监控DOM大小的变化

这个图可以看到资源几乎被完全利用了,而且大部分是渲染消耗的

小结

这个方法可以覆盖全部的变动情况,而且可以监听所有的DOM对象,但是在监听的节点数量很多的情况下(500+)的时候,消耗的性能就很多了,在这之前的消耗还是在可接受的范围内(<5%)的,而且一般实际上是没有这么多需要监听的节点的~~~,一般都在100节点以内,性能消耗是在1%内的,是一个完全满足需求的方案,没必要为了一个几乎不会遇到的需求而放弃这个简单有效的方案

接下来介绍一下,一个基于事件实现的监听方案

DOM滚动事件驱动方案

这个方案是通过监听DOM元素的scroll事件来进行的,在大家可以在这篇文章了解下详情,这里只简单介绍下原理

scroll事件发生的原理

当一个元素的scrollLeftscrollTop属性发生改变的时候,这个元素就会触发scroll事件,scroll事件是不会冒泡的

父元素大小变化时内部元素的对scrollLeft和scrollTop的影响

当父元素大小变化时,内部的元素(内容)在一定条件下会影响scrollLeftscrollTop的值,从而触发scroll函数,下面都以高度为例(宽度其实相同)

父元素变大

在一般情况下父元素变大是不会影响内容的

JS监控DOM大小的变化

但是当父元素变大的幅度超过了剩余的内容,那么内容会跟着底部向下移动,导致scrollTop缩小

JS监控DOM大小的变化

这样我们就能监听到scroll事件啦!

JS监控DOM大小的变化

父元素变小

放大了的原理理解了话,那么放小来看下

JS监控DOM大小的变化

如果是父元素变小,如果内容的大小不变,那么scrollTop永远都不会发生变化,为了让其发生变化,这里需要让子元素能跟着父元素变化,而且必须变化幅度大于父元素,才能促使父元素由于超过展示范围,去改变scrollTop

JS监控DOM大小的变化

当内容的高度是父元素的100%以上时,由于速度比父元素缩小的块,导致父元素必须修改scrollTop来达到允许的最大的scrollTop,通过这个原理我们就可以监听到父元素的缩小啦!!!

在代码中最好使用200%及其以上,因为在浏览器中DOM的宽高都是整数的,如果是用200%一下会导致收缩不明显,而会漏掉一部分

JS监控DOM大小的变化

放大放小混合

上面两种方法都只能实现一直情况,但是只要混合一下就能实现同时监听放大缩小了,一方变动的同时需要恢复scrollTop来让另一方也能跟上

JS监控DOM大小的变化

性能分析

这个相较于循环比对方法要复杂的多,但是节约了DOM没有变动时(大部分情况)的计算性能,使用事件驱动是必然性能优化很多的

小结

这个方法虽然很好的解决了循环比对的性能问题,但是会污染使用者的DOM树结构(添加了额外的东西),而且必须要求DOM能够在下面插入节点,文本(TextNode)、图片(ImageElement)等等这些元素就么法了,同时需要父元素是一个相对元素,不然就不能模拟捕捉父元素的宽高,限制条件还是很多的

封装源码

循环监听

class DomResizeWatcher{
    constructor(){
        this.datas = new Map()

        this._init()
    }

    _init(){
        this._check()
    }

    _check(){
        Array.from(this.datas.values()).forEach(data => {
            data.check()
        })

        requestAnimationFrame(this._check.bind(this))
    }

    addResizeEventListener(dom, fun){
        let data = this.datas.get(dom)
        if(!data){
            data = new DomResizeWatcherData(dom)
            this.datas.set(dom, data)
        }
        data.addResizeEventListener(fun)
    }

    removeResizeEventListener(dom, fun){
        let data = this.datas.get(dom)
        if(!data) return

        data.removeResizeEventListener(fun)

        if(data.getFunCount() > 0) return

        this.datas.delete(dom)
    }
}

class DomResizeWatcherData{
    constructor(dom){
        this.dom = dom
        this.funs = new Set()
        this.size = this._getDomSize()
    }

    _getDomSize(){
        let height = this.dom.clientHeight
        let width = this.dom.clientWidth

        return {
            height,
            width
        }
    }

    _trigger(){
        let dom = this.dom
        this.funs.forEach(fun => {
            fun.apply(dom)
        })
    }

    getFunCount(){
        return this.funs.size
    }

    addResizeEventListener(fun){
        this.funs.add(fun)
    }

    removeResizeEventListener(fun){
        this.funs.delete(fun)
    }

    check(){
        let size = this._getDomSize()
        if(size.height !== this.size.height 
            || size.width !== this.size.width){
            this._trigger()
            this.size = size
        }
    }
}

scroll事件驱动

class FrameCounter{
    constructor(){
        this.time = 0
        this._run = false
    }

    _runFun(){
        if(this._run){
            this.time++
            requestAnimationFrame(this._runFun.bind(this))
        }
    }

    start(){
        this._run = true
        this._runFun()
    }

    stop(){
        this._run = false
    }
}

class DOMResizeWatcher{
    constructor(){
        this.datas = new Map()
        this.timer = new FrameCounter()
        this.timer.start()
    }

    addResizeEventListener(dom, fun){
        let data = this.datas.get(dom)
        if(!data){
            data = new DOMResizeWatcherData(dom, this.timer)
            this.datas.set(dom, data)
        }
        data.addResizeEventListener(fun)
    }

    removeResizeEventListener(dom, fun){
        let data = this.datas.get(dom)
        if(!data) return
        
        if(fun){
            data.removeResizeEventListener(fun)

            if(data.getFunCount() > 0) return
        }

        data.destory()
        this.datas.delete(dom)
    }
}

class DOMResizeWatcherData{

    constructor(dom, timer){
        this.dom = dom
        this.funs = new Set()
        this.trigged = false
        this.timer = timer

        this.insideBigEl = null
        this.insideSmallEl = null

        this._init()
    }

    _init(){
        //监听变大的DOM
        let insideBig = document.createElement("div")
        insideBig.style = "position: absolute;top:0;left: 0;bottom: 0;right: 0;overflow: hidden;visibility: hidden;z-index:-1"
        insideBig.innerHTML = `<div style="width:${DOMResizeWatcherData.bigNumber}px;height:${DOMResizeWatcherData.bigNumber}px"></div>`

        //监听变小的DOM
        let insideSmall = document.createElement("div")
        insideSmall.style = "position: absolute;top:0;left: 0;bottom: 0;right: 0;overflow: hidden;visibility: hidden;z-index:-1"
        insideSmall.innerHTML = `<div style="width:300%;height:300%"></div>`

        this.insideBigEl = insideBig
        this.insideSmallEl = insideSmall

        try{
            this.dom.appendChild(insideBig)
            this.dom.appendChild(insideSmall)
        } catch(e) {
            throw new Error("DOMElement can't appendChild! try another way!")
        }
        
        insideSmall.scrollTop = DOMResizeWatcherData.bigNumber
        insideSmall.scrollLeft = DOMResizeWatcherData.bigNumber
        insideBig.scrollTop = DOMResizeWatcherData.bigNumber
        insideBig.scrollLeft = DOMResizeWatcherData.bigNumber

        insideBig.addEventListener("scroll", _ => {
            insideSmall.scrollTop = DOMResizeWatcherData.bigNumber
            insideSmall.scrollLeft = DOMResizeWatcherData.bigNumber
            this._trigger()
        })
        insideSmall.addEventListener("scroll", _ => {
            insideBig.scrollTop = DOMResizeWatcherData.bigNumber
            insideBig.scrollLeft = DOMResizeWatcherData.bigNumber
            this._trigger()
        })
    }

    _trigger(){
        if(this.triggertime === this.timer.time) return
        this.triggertime = this.timer.time
        
        let dom = this.dom
        this.funs.forEach(fun => {
            fun.apply(dom)
        })
    }

    getFunCount(){
        return this.funs.size
    }

    addResizeEventListener(fun){
        this.funs.add(fun)
    }

    removeResizeEventListener(fun){
        this.funs.delete(fun)
    }

    destory(){
        try{
            this.dom.removeChild(this.insideBigEl)
            this.dom.removeChild(this.insideSmallEl)
        } catch(e){}
    }
}
DOMResizeWatcherData.bigNumber = 9999999

参考资料

scrolling官方规范

巧妙监测元素尺寸变化

END

2018-10-23 完成

2018-10-17 立项