从问题入手,实现一个滚动加载 实现原理 懒加载的实现很简单,例如图片需要懒加载的时候,在初始加载时并不直接加载图片,而是用一个其它的图片或者占位符占位,当其它优先级更高的内容加载完成后,再使用真实的图片替换占位图。具体呢就是在初始加载时并不知道图片的src
或者src
指向一个占位图片,而真实图片的路径则是放在data-src
这样的自定义属性中,当需要加载时,使用data-src
替换src
即可。
在React中,如果我们需要懒加载一个组件,实现原理也类似:当不需要加载的时候渲染占位符,当需要加载的时候再去加载真正的加载。滚动加载就是懒加载的一种特殊情况,其触发方式是滚动,只有当组件在可视区域中时,才开始加载。
实现滚动加载并不简单,我们需要考虑以下几个问题:
如何找到组件所处的滚动容器。
如何判定组件是否可见。
如何使用“更好”组件占位符。
处理性能瓶颈。
懒加载使用最为广泛的实现是react-lazy-load ,使用起来也很方便:<LazyLoad><MyComponent /></LazyLoad>
,使用提供的LazyLoad
组件包裹需要懒加载的组件即可,并且它提供了多个属性,例如height
可以设置当内容未加载时的高度,offset
则可以指定组件开始加载时需要偏移的距离(单位:px
),除此之外,我们还可以指定懒加载是否使用防抖和节流来提升性能,具体的使用见react-lazy-load 。
虽然react-lazy-load
是一个使用广泛的懒加载解决方案,但是在最近的项目中,我却不得不放弃使用它。因为react-lazy-load
会更改原有的DOM结构!!! ,所以如果要使用react-lazy-load
,我必须更改原本的样式,这将会是一个浩大的工程。
所以,接下来将会实现一个不需要额外DOM结构的滚动加载组件ScrollLoad
组件。
问题1:如果找到组件所在的可滚动元素 这个问题其实是:如何找到组件所在的最近的可滚动的父元素 。
但是为什么需要找到那个可滚动的父元素呢?因为需要在该父元素上绑定scroll eventListener
,当父元素在滚动时,就可以根据监听函数实时获取滚动的距离,并依此决定组件显示与否。
那怎么样才能找到最近可滚动父元素 呢?为了定位父元素,我们需要定位当前元素,然后再向上遍历,去查找overflow
显式设置为auto|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 function getScrollParent (element ) { const style = (elem, prop ) => { if (getComputedStyle !== undefined ) { return getComputedStyle(elem, null ).getPropertyValue(prop); } return elem.style[prop]; }; const overflow = node => style(node, 'overflow' ) + style(node, 'overflow-x' ) + style(node, 'overflow-y' ); if (!(element instanceof HTMLElement)) { return window ; } let parent = element; while (parent) { if (parent === document .body || parent === document .documentElement) { break ; } if (!parent.parentNode) { break ; } if (/(scroll|auto|inherit)/ .test(overflow(parent))) { return parent; } parent = parent.parentNode; } return window ; }
实现参考了react-lazy-load
的实现,通过当前节点向上查找直到找到第一个可滚动的父元素或遍历到顶层元素,并且每进行一次遍历,会通过正则检查当前元素的样式的overflow overflow-x overflow-y
,如果为scroll|auto
,则返回该父元素。
那么怎么得到当前元素呢?可以通过ReactDOM.findDOMNode(component)
访问真实的DOM节点,如果有多个子节点的话,默认返回第一个。因为ScrollLoad
组件并没有添加额外的DOM结构,所以通过findDOMNode(this)
拿到的节点就是目标节点,也就是占位节点(因为就算组件初始状态下可见,那么也要等应用挂载后才去判断,所以首次渲染的是占位组件)。
问题2:如何判定组件是否可见 如果只考虑上下滚动的话,一个很简单的判断公式是:offsetTop < seenHeight + scrollTop
,就是:组件相对于可滚动父元素的偏移 < 可滚动父元素的可视高度 + 可滚动父元素的滚动距离 。上面的三个计算量中offsetTop
和seenHeight
都是固定不变的,所以一个组件是否可见取决于父元素当前滚动的距离。seenHeight
很好计算:parent.clientHeight
即可,scrollTop
也很简单:parent.scrollTop
,offsetTop
计算稍微复杂。
如何计算offsetTop
?如果最近可滚动父元素是直接父元素的话,直接通过elem.offsetTop
就可以得到,如果包含多层嵌套,那么offsetTop
就需要每一层元素相对于父元素的offsetTop
相加,直到父元素等于目标父元素。简单的实现如下:1 2 3 4 5 6 7 8 9 10 let getNodeOffsetTop = (node, parent ) => { let current = node; let offsetTop = 0 ; while (current !== parent) { offsetTop += current.offsetTop; current = current.parentElement; } return offsetTop; }
最后是判断组件是否可见的实现:1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 let checkVisible = (node, parent ) => { if (!node || !parent) { this .setState({ visible : true }); return null ; } return () => { const { visible } = this .state; if (visible) { this .parent.removeEventListener('scroll' , this .scrollHandler); return ; } let seenHeight = parent.clientHeight; let scrollHeight = parent.scrollTop; let currentNode = findDOMNode(this ); let offsetTop = this .getNodeOffsetTop(currentNode, parent); if (offsetTop <= seenHeight + scrollHeight) { this .setState({ visible : true }); } }; }
问题3:如何使用“更好”组件占位符 理想情况下,一个好的占位组件应该是和真实组件一样大的size
,这样的话初始情况和加载完成的情况下滚动条的长度都是一样长的,且在组件由不可见到可见这个过程中页面并不会因为组件前后的size
而出现抖动。
而实际的情况是,我们并不能很好的拿到目标组件的样式并作用到占位组件上,因为占位组件总是先渲染。所以折中的做法是让ScrollLoad
的使用者去提供占位组件,这样就把如何提供一个好的占位组件交给了使用者。
还有一种办法是,既然一个好的(只是我认为的)占位组件的size
是等于目标组件的,那么我直接把用于布局的样式从目标组件上拿过来不就行了!所以无论是目标组件渲染后的真实DOM上的className id style...
全部拿过来,如果高度是内容撑开的话,我们就拿渲染完成的高度直接设置到占位组件上。经过尝试,这种方法的确可行,但是需要付出的代价是需要花费时间在获取目标组件的样式和样式整理上,并且代码的可读性将一定程度的降低。
所以,关于占位组件,我暂时没有好的方法。
问题4:性能瓶颈 在前面介绍react-lazy-load
的时候提到过我们可以决定是否使用节流或防抖来解决性能问题。所以,在ScrollLoad
上,也是用了节流 来控制scroll
触发的频率。代码如下:1 2 this .scrollHandler = throttle(this .checkVisible(dom, parent), 100 );parent.addEventListener('scroll' , this .scrollHandler, { passive : true });
这里使用了lodash/throttle
来实现节流,这样默认情况下,scroll
事件只会每隔100ms
触发一次。
上面的代码在使用addEventListener
绑定监听函数时还是用到了该函数的第三个参数:{ passive: true }
,passive
的意思是消极的、被动的
,如果不指定,在新版的Chrome
中会有性能提示:[Violation] Added non-passive event listener to a scroll-blocking <some> event. Consider marking event handler as 'passive' to make the page more responsive.
其实在监听滚动事件是,我们可以通过event.preventDefault()
来阻止浏览器的默认滚动行为,可当滚动触发时,浏览器并不知道我们的监听函数中是否阻止了默认行为,所以浏览器会等待,直到监听函数执行完,此时浏览器才会选择滚动与否。而执行监听函数往往需要时间,性能就会受到影响。
而设置{ passive: true }
,可以在执行监听函数之前就告诉浏览器,并没有阻止默认滚动,因此在滚动触发时,浏览器就不会等待,直接滚动。显然ScrollLoad
是需要执行浏览器滚动的,因此设置{ passive: true }
可以提升性能。
在checkVisible
函数中,有一段代码:1 2 3 4 const { visible } = this .state;if (visible) { this .parent.removeEventListener('scroll' , this .scrollHandler); }
这段代码很好理解,除了在组件卸载之前需要移除listener
之外,一旦当某个组件可见,那么此时就没必要再监听滚动了,所以需要移除监听函数。
上面的代码还有一个问题,如果当某两次触发监听函数组件的状态刚好从visible
为false
切换到true
,此时移除了监听函数,但当次函数还会再次执行,所以在移除监听函数后,直接返回,可以避免执行下面不必要的逻辑。因此,这段代码应该是:1 2 3 4 5 const { visible } = this .state;if (visible) { this .parent.removeEventListener('scroll' , this .scrollHandler); return ; }
结尾 到此为止,一个基本可用的ScrollLoad
组件就实现了,其实对于不同的使用场景,ScrollLoad
的实现也可以略有不同,例如如果当前需要scrollload
的组件是一些列表项组件,每个组件的样式外观都是一致的。这样的话,我们在写lazyload
组件的时候,就可以把所有组件使用一个LazyLoad
组件包裹起来,然后绑定一个监听函数,使用可显示的组件个数作为组件状态,每次滚动时监听函数根据已经滚动的高度去计算可见组件的个数,最后在渲染的时候遍历this.props.children
,选择渲染组件或是占位组件即可。
附 LazyLoad
实现代码: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 import React from 'react' ;import { findDOMNode } from 'react-dom' ;import throttle from 'lodash/throttle' ;import { Spin } from '@xx/xx-ui' ;import ReactPlaceHolder from 'react-placeholder' ;function getScrollParent (element ) { const style = (elem, prop ) => { if (getComputedStyle !== undefined ) { return getComputedStyle(elem, null ).getPropertyValue(prop); } return elem.style[prop]; }; const overflow = node => style(node, 'overflow' ) + style(node, 'overflow-x' ) + style(node, 'overflow-y' ); if (!(element instanceof HTMLElement)) { return window ; } let parent = element; while (parent) { if (parent === document .body || parent === document .documentElement) { break ; } if (!parent.parentNode) { break ; } if (/(scroll|auto|inherit)/ .test(overflow(parent))) { return parent; } parent = parent.parentNode; } return window ; } let EmptyCompBox = ({ ...props } ) => ( <div {...props}> <Spin size="large" className="lazyload-center-spin" /> </div> ); class ScrollLoad extends React.Component { state = { visible: false, }; componentDidMount() { let dom = findDOMNode(this); / / 取得当前节点 let parent = getScrollParent(dom); this.parent = parent; let visible = this.checkVisible(dom, parent); / / 初始化检查是否可见 visible(); this.scrollHandler = throttle(this.checkVisible(dom, parent), 100); parent.addEventListener('scroll', this.scrollHandler, { passive: true }); } componentWillUnmount() { this.parent.removeEventListener('scroll', this.scrollHandler); } getNodeOffsetTop = (node, parent) => { let current = node; let offsetTop = 0; while (current !== parent) { offsetTop += current.offsetTop; current = current.parentElement; } return offsetTop; }; checkVisible = (node, parent) => { if (!node || !parent) { this.setState({ visible: true }); return null; } let seenHeight = parent.clientHeight; let scrollHeight = parent.scrollTop; return () => { const { visible } = this.state; if (visible) { this.parent.removeEventListener('scroll', this.scrollHandler); return; / / 直接返回不执行当次eventListener } let currentNode = findDOMNode(this); / / 获取最新的dom结构 let offsetTop = this.getNodeOffsetTop(currentNode, parent); / / 1. 当偏移高度小于可见高度 / / 2. 初始不可见的时候,当可视高度+滚动高度大于了偏移高度 if (offsetTop <= seenHeight + scrollHeight) { this.setState({ visible: true }); } }; }; render() { const { visible } = this.state; const { id, className, style } = this.props; return ( <ReactPlaceHolder ready={visible} customPlaceholder={<EmptyCompBox id={id} className={className} style={style} / >} > {this .props.children} </ReactPlaceHolder> ); } } export default ScrollLoad;