来,实现一个“滚动加载”

从问题入手,实现一个滚动加载

实现原理

懒加载的实现很简单,例如图片需要懒加载的时候,在初始加载时并不直接加载图片,而是用一个其它的图片或者占位符占位,当其它优先级更高的内容加载完成后,再使用真实的图片替换占位图。具体呢就是在初始加载时并不知道图片的src或者src指向一个占位图片,而真实图片的路径则是放在data-src这样的自定义属性中,当需要加载时,使用data-src替换src即可。

在React中,如果我们需要懒加载一个组件,实现原理也类似:当不需要加载的时候渲染占位符,当需要加载的时候再去加载真正的加载。滚动加载就是懒加载的一种特殊情况,其触发方式是滚动,只有当组件在可视区域中时,才开始加载。

实现滚动加载并不简单,我们需要考虑以下几个问题:

  1. 如何找到组件所处的滚动容器。
  2. 如何判定组件是否可见。
  3. 如何使用“更好”组件占位符。
  4. 处理性能瓶颈。

ScrollLoad实现

懒加载使用最为广泛的实现是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) {
// 当前节点是body或者document
if (parent === document.body || parent === document.documentElement) {
break;
}
// 当期元素无父节点
if (!parent.parentNode) {
break;
}
// 判断节点是否含有overflow等属性的值
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,就是:组件相对于可滚动父元素的偏移 < 可滚动父元素的可视高度 + 可滚动父元素的滚动距离。上面的三个计算量中offsetTopseenHeight都是固定不变的,所以一个组件是否可见取决于父元素当前滚动的距离。seenHeight很好计算:parent.clientHeight即可,scrollTop也很简单:parent.scrollTopoffsetTop计算稍微复杂。

如何计算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; // 直接返回不执行当次eventListener
}
let seenHeight = parent.clientHeight;
let scrollHeight = parent.scrollTop;
let currentNode = findDOMNode(this); // 获取最新的dom结构
let offsetTop = this.getNodeOffsetTop(currentNode, parent);
// 1. 当偏移高度小于可见高度
// 2. 初始不可见的时候,当可视高度+滚动高度大于了偏移高度
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之外,一旦当某个组件可见,那么此时就没必要再监听滚动了,所以需要移除监听函数。

上面的代码还有一个问题,如果当某两次触发监听函数组件的状态刚好从visiblefalse切换到true,此时移除了监听函数,但当次函数还会再次执行,所以在移除监听函数后,直接返回,可以避免执行下面不必要的逻辑。因此,这段代码应该是:

1
2
3
4
5
const { visible } = this.state;
if (visible) {
this.parent.removeEventListener('scroll', this.scrollHandler);
return; // 直接返回不执行当次eventListener
}

结尾

到此为止,一个基本可用的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) {
// 当前节点是body或者document
if (parent === document.body || parent === document.documentElement) {
break;
}
// 当期元素无父节点
if (!parent.parentNode) {
break;
}
// 判断节点是否含有overflow等属性的值
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;

分享到 评论