首先说一下场景:单品页详情模块有一个折叠展示的功能,如果内容超出指定高度只展示一部分并显示“查看全部”的按钮。因为详情部分除了文字、表格之外,还会有图片等异步加载的资源会影响内容整体的高度,所以需要获取内容部分的实时高度。
如果要实现上面的功能,有以下方式:
变通一下,因为详情模块不会在首屏展示,可以修改成滚动到该模块的时候再获取高度。这纯粹是个懒办法,如果有相似功能的模块在首屏展示,这种方法不适用。
给异步加载的资源添加onload事件,加载完成后更新容器的高度。这种方式只有在添加了onload事件的资源加载完成后才会更新高度,如果有些异步资源没有绑定事件或者通过js改变了元素高度,最后获取的高度可能不准确。
使用setInterval定时获取容器高度。这种方式和上一种比较的话,实现方式简单,但是setInterval会占用内存,有一定的性能问题,而且在容器高度固定之后还会一直被执行,也不是理想的实现方式。
监听容器高度的动态变化。
这篇文章要讲的是第3种方式。
resize 提到高度变化,我们第一个想到的应该是resize事件,在window对象上添加resize事件,可以监听浏览器窗口变化引起的高度变化。
1 2 3 4 window .addEventListener('resize' , function ( ) => { 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 ; console .log("window resize" ); }, 20 ); } } if (window .attachEvent) { 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尺寸变化达到监听容器尺寸变化的目的。如下图:
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 <div id="main" style=" position: relative; width: 100px; height: 100px; background-color: lightblue; " ></div > <script type ="text/javascript" > var observeResize = function (element, handler ) { 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 ( ) { frame.contentWindow.onresize = function ( ) { handler(element.clientWidth, element.clientHeight); }; }; element.appendChild(frame); }; var element = document .getElementById("main" ); observeResize(element, function (width, height ) { console .log("width: " + width, " height: " + height); }); setTimeout (function ( ) { element.style.height = "400px" ; }, 2000); </script >
注意:创建iframe比创建其他dom元素(包括style 和script)多耗费数十甚至数百倍的性能,而且还会阻塞页面onload事件的触发,因此这种方式需要谨慎使用。
使用object模拟 使用object模拟的原理和iframe是一样的。 在模拟iframe思路的基础上,我们来完善下功能:
IE10及以下浏览器可以使用resize事件,其他浏览器使用object模拟
允许一个dom添加多个resize事件
节流
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 <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 ( ) { ... })(); var handleResize = function (e ) { 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); } }); } }; var addResizeListener = function (element, handler ) { if (!element.__resizeListeners__) { element.__resizeListeners__ = []; if (element.attachEvent) { element.__resizeTrigger__ = element; element.attachEvent("onresize" , handleResize); } else { 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 ( ) { this .contentDocument.defaultView.__resizeTrigger__ = element; 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); }; 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); }); setTimeout (function ( ) { element.style.height = "400px" ; }, 2000); </script >
要监听的容器我们是不能改变它的滚动状态的,但是我们可以和iframe或者object模拟一样,创建一个不可见的子元素,使这个子元素可以触发滚动,再通过事件冒泡,让容器捕捉到。
滚动事件可以被触发的条件是:当子元素大于其父级元素,且父级元素允许其滚动。当元素高度改变时,scrollTop或scrollLeft默认保持原状,如果scrollTop或scrollLeft不能保持原状必须变化时,就会触发scroll事件。我们可以利用这一点,通过修改scroolTop或scrollLeft的值,来触发滚动。下面我们来看下容器变大或变小时滚动的情况。
容器变大
上图中虚线表示子元素高度(均为110px),实线表示父元素高度。
当父元素高度为80px时,滚动条滚动到页面最底部,scrollTop为30px;
当父元素高度由80px变为90px时,可滚动区域变小为20px,如果scrollTop保持30px不变,30 + 90 会大于页面高度110px,因此scrollTop必须为20px才能满足条件,此时滚动被触发;
当父元素高度变为70px时,可滚动区域变为40px,scrollTop保持20px不变,此时滚动不会被触发。
因此,当滚动到底部时,如果父元素不断变大,scrollTop的值就会不断缩小,从而触发滚动事件。
容器变小 从上面第3条我们可以知道,父元素变小时,不会触发滚动事件。但是我们也知道,当可滚动区域的高度小于scrollTop的值的时候,scrollTop会变为可滚动区域的值,这时候滚动被触发。我们可以利用父元素来压缩可滚动区域的大小:
上图中虚线表示子元素,实线表示父元素,子元素高度始终是父元素高度的200%
当父元素高度为60px时,子元素高度为120px,滚动条滚动到最底部,scrollTop为60px;
当父元素高度由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 <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 ]; 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 ) { 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; expandChild.style.width = expand.offsetWidth + 1 + "px" ; expandChild.style.height = expand.offsetHeight + 1 + "px" ; expand.scrollLeft = expand.scrollWidth; expand.scrollTop = expand.scrollHeight; }; var addResizeListener = function (element, handler ) { if (!element.__resizeListeners__) { element.__resizeListeners__ = []; 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); element.addEventListener( "scroll" , function ( ) { handleScroll(element); }, true ); } } element.__resizeListeners__.push(handler); }; 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都不兼容了)。当然,我们做项目的时候可以使用现成的开源代码:
element-resize-event : 使用的是object模拟
element-resize-detector :使用object和scroll两种策略,默认object
在使用object和scroll模拟的时候,对flex压缩和css动画造成的高度变化的情况进行了测试,本文代码中scroll的方式对动画造成的高度变化无效(需补充animation的适配),但element-resize-detector中scroll是正常的。