首先说一下场景:单品页详情模块有一个折叠展示的功能,如果内容超出指定高度只展示一部分并显示“查看全部”的按钮。因为详情部分除了文字、表格之外,还会有图片等异步加载的资源会影响内容整体的高度,所以需要获取内容部分的实时高度。

如果要实现上面的功能,有以下方式:

  1. 变通一下,因为详情模块不会在首屏展示,可以修改成滚动到该模块的时候再获取高度。这纯粹是个懒办法,如果有相似功能的模块在首屏展示,这种方法不适用。
  2. 给异步加载的资源添加onload事件,加载完成后更新容器的高度。这种方式只有在添加了onload事件的资源加载完成后才会更新高度,如果有些异步资源没有绑定事件或者通过js改变了元素高度,最后获取的高度可能不准确。
  3. 使用setInterval定时获取容器高度。这种方式和上一种比较的话,实现方式简单,但是setInterval会占用内存,有一定的性能问题,而且在容器高度固定之后还会一直被执行,也不是理想的实现方式。
  4. 监听容器高度的动态变化。

这篇文章要讲的是第3种方式。

resize

提到高度变化,我们第一个想到的应该是resize事件,在window对象上添加resize事件,可以监听浏览器窗口变化引起的高度变化。

1
2
3
4
window.addEventListener('resize', function () => {
// resize事件回调
console.log('window resize')
})

由于resize事件可以以较高的速率触发, 因此resize事件的回调不应该执行计算开销很大的操作 (如 DOM 修改),最好使用requestAnimationFrame、setTimeout进行处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var resizeTimeout;
function resizeThrottle() {
if (!resizeTimeout) {
resizeTimeout = setTimeout(function () {
resizeTimeout = null;
// resize事件真正的回调
console.log("window resize");
}, 20);
}
}
if (window.attachEvent) {
// IE10 及以下
window.attachEvent("onresize", resizeThrottle);
} else {
window.addEventListener("resize", resizeThrottle);
}

需要注意的是,普通dom对象是没有resize事件的,只有defaultView(即window)对象有(IE浏览器普通dom也可以使用resize),在普通dom上添加resize事件是不会触发的。

使用iframe模拟

在上节我们知道,只有window对象有resize事件。按照这个思路,我们可以用隐藏的 iframe 模拟 window 撑满要监听的容器,当容器尺寸变化时,iframe 尺寸也会改变,这样就可以通过监听iframe尺寸变化达到监听容器尺寸变化的目的。如下图:

iframe模拟

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
<!-- 要监听的容器position必须为relative -->
<div
id="main"
style="
position: relative;
width: 100px;
height: 100px;
background-color: lightblue;
"
></div>

<script type="text/javascript">
var observeResize = function (element, handler) {
// 创建iframe,定义样式,使iframe和要监听的容器大小一致
var frame = document.createElement("iframe");
var frameStyle =
"\
position:absolute;\
left:0;\
top:-100%;\
width: 100%;\
height: 100%;\
opacity:0;\
visibility:hidden;\
pointer-events:none;\
";
frame.style.cssText = frameStyle;
frame.onload = function () {
// iframe创建完成后,通过contentWindow获取到iframe的window对象并添加resize事件
// 因为iframe和要监听的容器大小是一样的,iframe的尺寸变化其实也就是监听容器的尺寸变化
frame.contentWindow.onresize = function () {
// 或者使用frame.contentDocument.defaultView.onresize
handler(element.clientWidth, element.clientHeight);
};
};
element.appendChild(frame);
};

var element = document.getElementById("main");
observeResize(element, function (width, height) {
console.log("width: " + width, " height: " + height);
});

// 改变容器的高度,控制台输出:width: 100; height: 400
setTimeout(function () {
element.style.height = "400px";
}, 2000);
</script>

注意:创建iframe比创建其他dom元素(包括style 和script)多耗费数十甚至数百倍的性能,而且还会阻塞页面onload事件的触发,因此这种方式需要谨慎使用。

使用object模拟

使用object模拟的原理和iframe是一样的。
在模拟iframe思路的基础上,我们来完善下功能:

  1. IE10及以下浏览器可以使用resize事件,其他浏览器使用object模拟
  2. 允许一个dom添加多个resize事件
  3. 节流
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
<!-- 要监听的容器position必须为relative,这里可以不用必须指定,代码中会判断 -->
<div
id="main"
style="
width: 100px;
height: 100px;
background-color: lightblue;
"
></div>
<script type="text/javascript">
// 节流函数
var requestFrame = (function () {
var raf =
window.requestAnimationFrame ||
window.mozRequestAnimationFrame ||
window.webkitRequestAnimationFrame ||
function (fn) {
return window.setTimeout(fn, 20);
};
return function (fn) {
return raf(fn);
};
})();

// 清除节流
var cancelFrame = (function () {
...
})();

// resize事件回调
var handleResize = function (e) {
// 获取触发了resize事件的元素,IE 8 使用srcElement,其他的取target值
var target = e.target || e.srcElement;
if (
target &&
target.__resizeTrigger__ &&
target.__resizeTrigger__.__resizeListeners__
) {
if (target.__resizeRAF__) cancelFrame(target.__resizeRAF__);
target.__resizeRAF__ = requestFrame(function () {
// 遍历元素上已添加的事件,执行
var handlers = target.__resizeTrigger__.__resizeListeners__;
for (var i = 0; i < handlers.length; i++) {
var element = target.__resizeTrigger__;
handlers[i](element.clientWidth, element.clientHeight);
}
});
}
};

// 为指定元素添加resize事件
var addResizeListener = function (element, handler) {
if (!element.__resizeListeners__) {
element.__resizeListeners__ = [];
if (element.attachEvent) {
element.__resizeTrigger__ = element;
element.attachEvent("onresize", handleResize);
} else {
// 创建object 模拟元素大小
var object = document.createElement("object");
var objectStyle =
"\
position:absolute;\
left:0;\
top:-100%;\
width: 100%;\
height: 100%;\
opacity:0;\
visibility:hidden;\
pointer-events:none;\
";
object.style.cssText = objectStyle;
object.type = "text/html";
object.onload = function () {
// 指定__resizeTrigger__为实际要获取大小的元素
this.contentDocument.defaultView.__resizeTrigger__ = element;
// 给object对应的window对象添加resize事件
this.contentDocument.defaultView.addEventListener(
"resize",
handleResize
);
};
if (getComputedStyle(element).position == "static") {
element.style.position = "relative";
}
element.appendChild(object);
object.data = "about:blank";
}
}
element.__resizeListeners__.push(handler);
};

// 为指定元素移除resize事件
var removeResizeListener = function (element, handler) {
var handlers =
element && element.__resizeListeners__
? element.__resizeListeners__
: [];
handlers.splice(handlers.indexOf(handler), 1);
if (!handlers.length) {
if (element.attachEvent) {
element.detachEvent("onresize", handleResize);
} else {
element.__resizeTrigger__.contentDocument.defaultView.removeEventListener(
"resize",
handleResize
);
element.__resizeTrigger__ = !element.removeChild(
element.__resizeTrigger__
);
}
}
};

var element = document.getElementById("main");
addResizeListener(element, function (width, height) {
console.log("width:" + width, " height:" + height);
});
addResizeListener(element, function (width, height) {
console.log("width111:" + width, " height111:" + height);
});

// 改变容器的高度,控制台输出:width: 100; height: 400 和 width111: 100; height111: 400
setTimeout(function () {
element.style.height = "400px";
}, 2000);
</script>

监听scroll事件

要监听的容器我们是不能改变它的滚动状态的,但是我们可以和iframe或者object模拟一样,创建一个不可见的子元素,使这个子元素可以触发滚动,再通过事件冒泡,让容器捕捉到。

滚动事件可以被触发的条件是:当子元素大于其父级元素,且父级元素允许其滚动。当元素高度改变时,scrollTop或scrollLeft默认保持原状,如果scrollTop或scrollLeft不能保持原状必须变化时,就会触发scroll事件。我们可以利用这一点,通过修改scroolTop或scrollLeft的值,来触发滚动。下面我们来看下容器变大或变小时滚动的情况。

容器变大

容器变大

上图中虚线表示子元素高度(均为110px),实线表示父元素高度。

  1. 当父元素高度为80px时,滚动条滚动到页面最底部,scrollTop为30px;
  2. 当父元素高度由80px变为90px时,可滚动区域变小为20px,如果scrollTop保持30px不变,30 + 90 会大于页面高度110px,因此scrollTop必须为20px才能满足条件,此时滚动被触发;
  3. 当父元素高度变为70px时,可滚动区域变为40px,scrollTop保持20px不变,此时滚动不会被触发。

因此,当滚动到底部时,如果父元素不断变大,scrollTop的值就会不断缩小,从而触发滚动事件。

容器变小

从上面第3条我们可以知道,父元素变小时,不会触发滚动事件。但是我们也知道,当可滚动区域的高度小于scrollTop的值的时候,scrollTop会变为可滚动区域的值,这时候滚动被触发。我们可以利用父元素来压缩可滚动区域的大小:

容器变小

上图中虚线表示子元素,实线表示父元素,子元素高度始终是父元素高度的200%

  1. 当父元素高度为60px时,子元素高度为120px,滚动条滚动到最底部,scrollTop为60px;
  2. 当父元素高度由60px变为50px时,子元素高度为100px,此时可滚动区域变小为50px,scrollTop由60px变为50px,滚动被触发。

因此,当滚动到底部时,如果子元素随着父元素不断缩小且缩小的幅度大于父元素,scrollTop的值就会不断缩小,从而触发滚动事件。

有了对容器变大和变小时触发滚动的了解后,我们就可以通过触发模拟元素的滚动来触发监听容器的滚动了。我们创建一个和要监听的容器等大的模拟元素,再添加两个子元素分别监听容器变大和变小的情况,如下:

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
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
<!-- 要监听的容器position必须为relative,这里可以不用必须指定,代码中会判断 -->
<div
id="main"
style="width: 100px; height: 100px; background-color: lightblue"
></div>
<script type="text/javascript">
var stylesCreated = false;
// 节流函数
var requestFrame = (function () {
...
})();

// 清除节流
var cancelFrame = (function () {
...
})();

// 处理滚动事件
var handleScroll = function (target) {
if (target.__resizeRAF__) cancelFrame(target.__resizeRAF__); // 取消之前的回调
target.__resizeRAF__ = requestFrame(function () {
if (target.attachEvent) {
for (var i = 0; i < target.__resizeListeners__.length; i++) {
var handlers = target.__resizeListeners__;
handlers[i](target.clientWidth, target.clientHeight);
}
} else {
// 设置新的函数
if (checkTriggers(target)) {
// 如果改变了大小
target.__resizeLast__.width = target.offsetWidth; // 更新宽度
target.__resizeLast__.height = target.offsetHeight; // 更新高度
target.__resizeListeners__.forEach(function (fn) {
fn(target.clientWidth, target.clientHeight);
});
}
}
});
};

// 添加模拟元素的样式
var createStyles = function () {
if (stylesCreated) return;
var css =
'\
.resize-triggers {\
visibility: hidden;\
opacity: 0;\
}\
.resize-triggers, .resize-triggers > div, .contract-trigger:before {\
content: " ";\
display: block;\
position: absolute;\
top: 0;\
left: 0;\
height: 100%;\
width: 100%;\
overflow: hidden;\
}\
.resize-triggers > div {\
background: #eee;\
overflow: auto;\
}\
.contract-trigger:before {\
width: 200%;\
height: 200%;\
}';

var head = document.head || document.getElementsByTagName("head")[0]; // 后者兼容IE9以下
var style = document.createElement("style");

style.type = "text/css";
if (style.styleSheet) {
style.styleSheet.cssText = css;
} else {
style.appendChild(document.createTextNode(css));
}

head.appendChild(style); // 添加新的样式
stylesCreated = true;
};

// 检查是否发生滚动
var checkTriggers = function (element) {
// 宽度或高度不一致就返回true
return element.offsetWidth !== element.__resizeLast__.width ||
element.offsetHeight !== element.__resizeLast__.height;
};

// 重置模拟元素的滚动位置
var resetTrigger = function (element) {
if (!element || !element.__resizeTrigger__) return;
var trigger = element.__resizeTrigger__;
var expand = trigger.firstElementChild; // 用来监听变大
var contract = trigger.lastElementChild; // 用来监听变小
var expandChild = expand.firstElementChild;

contract.scrollLeft = contract.scrollWidth;
contract.scrollTop = contract.scrollHeight;
// 监听变大时,需要设置子元素比父元素大1像素,使容器可以滚动
expandChild.style.width = expand.offsetWidth + 1 + "px";
expandChild.style.height = expand.offsetHeight + 1 + "px";
expand.scrollLeft = expand.scrollWidth;
expand.scrollTop = expand.scrollHeight;
};

// 为指定元素添加resize事件
var addResizeListener = function (element, handler) {
if (!element.__resizeListeners__) {
element.__resizeListeners__ = [];
// IE10及以下可以直接绑定onresize事件
if (element.attachEvent) {
element.attachEvent("onresize", function () {
handleScroll(element);
});
} else {
createStyles();
var resizeTrigger = document.createElement("div");
resizeTrigger.className = "resize-triggers";
resizeTrigger.innerHTML =
'<div class="expand-trigger"><div></div></div><div class="contract-trigger"></div>';
if (getComputedStyle(element).position == "static") {
element.style.position = "relative";
}
element.appendChild(resizeTrigger);
element.__resizeTrigger__ = resizeTrigger;
element.__resizeLast__ = {};
resetTrigger(element);
// 重要:addEventListener第三个参数设置为true,允许滚动事件冒泡
element.addEventListener(
"scroll",
function () {
handleScroll(element);
},
true
);
}
}
element.__resizeListeners__.push(handler);
};

// 为指定元素移除resize事件
var removeResizeListener = function (element, handler) {
var handlers =
element && element.__resizeListeners__
? element.__resizeListeners__
: [];
handlers.splice(handlers.indexOf(handler), 1);
if (!handlers.length) {
if (element.detachEvent) {
element.detachEvent("onresize", handleResize);
} else {
element.removeEventListener(
"resize",
handleResize
);
element.__resizeTrigger__ = !element.removeChild(
element.__resizeTrigger__
);
}
}
};

var element = document.getElementById("main");
addResizeListener(element, function (width, height) {
console.log("width:" + width, " height:" + height);
});
addResizeListener(element, function (width, height) {
console.log("width111:" + width, " height111:" + height);
});

setTimeout(function () {
element.style.height = "400px";
}, 2000);
</script>

补充

以上介绍的四种方式均可兼容到IE8(因为我只测试到了IE8,大部分网站连IE9都不兼容了)。当然,我们做项目的时候可以使用现成的开源代码:

  1. element-resize-event: 使用的是object模拟
  2. element-resize-detector:使用object和scroll两种策略,默认object

在使用object和scroll模拟的时候,对flex压缩和css动画造成的高度变化的情况进行了测试,本文代码中scroll的方式对动画造成的高度变化无效(需补充animation的适配),但element-resize-detector中scroll是正常的。