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节点监听

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

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

其他数量的节点的测试数据

|节点数|静态总时间|静态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节点同时改变大小,消耗了大量的资源进行重绘,这时候的帧数也开始明显降低

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

小结

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

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

DOM滚动事件驱动方案

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

scroll事件发生的原理

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

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

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

父元素变大

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

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

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

父元素变小

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

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

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

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

放大放小混合

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

性能分析

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

小结

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

封装源码

循环监听

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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
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事件驱动

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
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
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 立项