参考一

引入

有教室1/2/3, 每间教室下有1000+个学生.

学生组件为:

1
2
3
function Student({student}) {
return <div>{student.name}</div>
}

如果我们直接把整个列表渲染出来, 仅仅学生列表就会生成1000+个div标签.

往往, 我们的学生组件都会是:

1
2
3
4
5
6
7
8
9
function Student({student, ...rest}) {
return (
<div>
...
<div>{student.name} ....</div>
...
</div>
)
}

这个时候的DOM数量就会变得难以想象.

我们都知道, DOM结构如果过大, 网页就会出现用户操作体验上的问题, 比如滚动, 点击等常用操作. 同时, 对react的虚拟DOM计算以及虚拟DOM反映到真实DOM的压力也会很大. 当用户点击切换教室时, 就会出现秒级的卡顿.

使用react-virtualized优化

解决以上问题的核心思想就是: 只加载可见区域的组件

  • react-virtualized将我们的滚动场景区分为viewport内的局部滚动, 和基于viewport的滚动,
  • 前者相当于在页面中开辟了一个独立的滚动区域,属于内部滚动, 这跟和iscroll的滚动很类似,
  • 而后者则把滚动作为了window滚动的一部分(对于移动端而言,这种更为常见).
  • 基于此计算出当前所需要显示的组件.

具体实现

学生组件修改为:

1
2
3
4
5
6
7
8
9
function Student({student, style, ...rest}) {
return (
<div style={style}>
...
<div>{student.name} ....</div>
...
</div>
)
}

学生列表组件:

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
import React from 'react'
import { AutoSizer } from 'react-virtualized/dist/commonjs/AutoSizer'
import { List as VList } from 'react-virtualized/dist/commonjs/List'

class StudentList extends React.Component {
constructor(props) {
super(props)
this.state = {
list: []
}
}
getList = () => {
api.getList.then(res => {
this.setState({
list: res
})
})
}
componentDidMount() {
this.getList()
}
render() {
const { list } = this.state
const renderItem = ({ index, key, style }) => {
return <Student key={key} student={list[index]} style{style} />
}
return (
<div style={{height: 1000}}>
<AutoSizer>
{({ width, height }) => (
<VList
width={width}
height={height}
overscanRowCount={10}
rowCount={list.length}
rowHeight={100}
rowRenderer={renderItem}
/>
)}
</AutoSizer>
</div>
)
}
}

(外层div样式中的高度不是必须的, 比如你的网页是flex布局, 你可以用flex: 1来让react-virtualized计算出这个高度)

这个时候, 如果每个Student的高度相同的话, 问题基本上就解决啦!

可是, 问题又来了, 有时候我们的Student会是不确定高度的, 可以有两种方法解决问题, 推荐react-virtualized的CellMeasurer组件解决方案

方法二

学生列表组件修改为:

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
import React from 'react'
import { AutoSizer } from 'react-virtualized/dist/commonjs/AutoSizer'
import { List as VList } from 'react-virtualized/dist/commonjs/List'
import { CellMeasurerCache, CellMeasurer } from 'react-virtualized/dist/commonjs/CellMeasurer'

class StudentList extends React.Component {
constructor(props) {
super(props)
this.state = {
list: []
}
}
measureCache = new CellMeasurerCache({
fixedWidth: true,
minHeight: 58
})
getList = () => {
api.getList.then(res => {
this.setState({
list: res
})
})
}
componentDidMount() {
this.getList()
}
render() {
const { list } = this.state
const renderItem = ({ index, key, parent, style }) => {
return (
<CellMeasurer cache={this.measureCache} columnIndex={0} key={key} parent={parent} rowIndex={index}>
<Student key={key} student={list[index]} />
</CellMeasurer>
)
}
return (
<div style={{height: 1000}}>
<AutoSizer>
{({ width, height }) => (
<VList
ref={ref => this.VList = ref}
width={width}
height={height}
overscanRowCount={10}
rowCount={list.length}
rowHeight={this.getRowHeight}
rowRenderer={renderItem}
deferredMeasurementCache={this.measureCache}
rowHeight={this.measureCache.rowHeight}
/>
)}
</AutoSizer>
</div>
)
}
}

虚拟列表优化长列表的原理

  • 用数组保存所有列表元素的位置,只渲染可视区内的列表元素,当可视区滚动时,根据滚动的offset大小以及所有列表元素的位置,计算在可视区应该渲染哪些元素。

具体实现步骤如下所示:

  1. 首先确定长列表所在父元素的大小,父元素的大小决定了可视区的宽和高
  2. 确定长列表每一个列表元素的宽和高,同时初始的条件下计算好长列表每一个元素相对于父元素的位置,并用一个数组来保存所有列表元素的位置信息
  3. 首次渲染时,只展示相对于父元素可视区内的子列表元素,在滚动时,根据父元素的滚动的offset重新计算应该在可视区内的子列表元素。这样保证了无论如何滚动,真实渲染出的dom节点只有可视区内的列表元素。
  4. 假设可视区内能展示5个子列表元素,及时长列表总共有1000个元素,但是每时每刻,真实渲染出来的dom节点只有5个。
  5. 补充说明,这种情况下,父元素一般使用position:relative,子元素的定位一般使用:position:absolute或sticky

参考二

react-virtualized简介

  • react-virtualized是一个以高效渲染大型列表和表格数据的响应式组件
  • react-virtualized是一个实现虚拟列表较为优秀的组件库,react-virtualized提供了一些基础组件用于实现虚拟列表,虚拟网格,虚拟表格等等,它们都可以减小不必要的dom渲染。此外还提供了几个高阶组件,可以实现动态子元素高度,以及自动填充可视区等等。

react-virtualized的基础组件包含:

  • Grid:用于优化构建任意网状的结构,传入一个二维的数组,渲染出类似棋盘的结构。
  • List:List是基于Grid来实现的,但是是一个维的列表,而不是网状。
  • Table:Table也是基于Grid来实现,表格具有固定的头部,并且可以在垂直方向上滚动
  • Masonry:同样可以在水平方向,也可以在垂直方向滚动,不同于Grid的是可以自定义每个元素的大小,或者子元素的大小也可以是动态变化的
  • Collection:类似于瀑布流的形式,同样可以水平和垂直方向滚动。

值得注意的是这些基础组件都是继承于React中的PureComponent,因此当state变化的时候,只会做一个浅比较来确定重新渲染与否。

除了这几个基础组件外,react-virtualized还提供了几个高阶组件,比如ArrowKeyStepper、AutoSizer、CellMeasurer、InfiniteLoader等,本文具体介绍常用的AutoSizer、CellMeasurer和InfiniteLoader。

  • AutoSizer:用于一个子元素的情况,通过AutoSizer包含的子元素会根据父元素Resize的变化,自动调节该子元素的可视区的宽度和高度,同时调节的还有该子元素可视区真实渲染的dom元素的数目。
  • CellMeasurer:这个高阶组件可以动态的改变子元素的高度,适用于提前不知道长列表中每一个子元素高度的情况。
  • InfiniteLoader:这个高阶组件用于Table或者List的无限滚动,适用于滚动时异步请求等情况

react-virtualized基础组件的使用

Grid

  • 所有基础组件基本上都是基于Grid构成的,
  • 一个简单的Grid的例子如下:
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
import { Grid } from 'react-virtualized';

const list = [
['Jony yu', 'Software Engineer', 'Shenzhen', 'CHINA', 'GUANGZHOU'],
['Jony yu', 'Software Engineer', 'Shenzhen', 'CHINA', 'GUANGZHOU'],
['Jony yu', 'Software Engineer', 'Shenzhen', 'CHINA', 'GUANGZHOU'],
['Jony yu', 'Software Engineer', 'Shenzhen', 'CHINA', 'GUANGZHOU'],
['Jony yu', 'Software Engineer', 'Shenzhen', 'CHINA', 'GUANGZHOU'],
['Jony yu', 'Software Engineer', 'Shenzhen', 'CHINA', 'GUANGZHOU']
];

function cellRenderer ({ columnIndex, key, rowIndex, style }) {
return (
<div
key={key}
style={style}
>
{list[rowIndex][columnIndex]}
</div>
)
}
render(
<Grid
cellRenderer={cellRenderer}

columnCount={list[0].length}
rowCount={list.length}

columnWidth={100}
rowHeight={80}

height={300}
width={300}
/>,
rootEl
);
  • 渲染网格也是只渲染可视区的dom节点,
  • 有个有趣的现象是滚动条的大小,这里Grid做了一个细节优化,只有滚动的时候才会显示滚动条,停止滚动后会隐藏滚动条。

List

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
import { List } from 'react-virtualized';
import loremIpsum from "lorem-ipsum"

const rowCount = 1000;
const list = Array(rowCount).fill().map(()=>{
return loremIpsum({
count: 1,
units: 'sentences',
sentenceLowerBound: 3,
sentenceUpperBound: 3
}
})
function rowRenderer ({
key,
index,
isScrolling,
isVisible,
style
}) {
return (
<div
key={key}
style={style}
>
{list[index]}
</div>
)
}
export default class TestList extends Component{
render(){
return <div style={{height:"300px",width:"200px"}}>
<List
width={300}
height={300}
rowCount={list.length}
rowHeight={20}
rowRenderer={rowRenderer}
/>
</div>
}
}

List的使用方法也是极简,指定列表总条数rowCount,每一条的高度rowHeight以及每次渲染的函数rowRenderer,就可以构建一个渲染列表。

react-virtualized高阶组件的使用

AutoSizer

  • 首先来看使用不使用AutoSizer的缺点,List只能指定固定的大小,如果其所在的父元素的大小resize了,那么List是不会主动填满父元素的可视区的:
  • List是无法自动填充父元素的。因此我们这里需要使用AutoSizer。AutoSizer的使用也很简单,我们只需要在List的基础上:
  • 增加了AutoSizer可以动态的适应父元素宽度和高度的变化。

CellMeasurer

  • 上面代码也有问题,子元素太长,换行后改变了子元素的高度后无法子适应,也就是说仅仅通过基础的组件List是不支持子元素的高度动态改变的场景
  • 为了解决上述的子元素可以动态变化的问题,我们可以利用高阶组件CellMeasurer:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { List,AutoSizer,CellMeasurer, CellMeasurerCache} from 'react-virtualized';

const cache = new CellMeasurerCache({ defaultHeight: 30,fixedWidth: true});

function cellRenderer ({ index, key, parent, style }) {
console.log(index)

return (
<CellMeasurer
cache={cache}
columnIndex={0}
key={key}
parent={parent}
rowIndex={index}
>
<div
style={style}
>
{list[index]}
</div>
</CellMeasurer>
);
}

对于需要渲染的List,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class TestList extends Component{
render(){
return <div>
<AutoSizer>
{({ height, width }) => (
<List
height={height}
rowCount={list.length}
rowHeight={cache.rowHeight}
deferredMeasurementCache={cache}
rowRenderer={cellRenderer}
width={width}
/>
)}
</AutoSizer>
</div>
}
}

子列表元素的高度可以动态变化,通过CellMeasurer可以实现子元素的动态高度。

InfiniteLoader

最后我们来考虑这种无限滚动的场景,很多情况下我们可能需要分页加载,就是常见的在可视区内无限滚动的场景。react-virtualized提供了一个高阶组件InfiniteLoader用于实现无限滚动。

InfiniteLoader的使用很简单,只要按着文档来即可,就是分页的去在家下一页,滚动分页所调用的函数为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function loadMoreRows ({ startIndex, stopIndex }) {
return new Promise(function(resolve,reject){
resolve()
}).then(function(){
//模拟ajax请求
let temList = Array(10).fill(1).map(()=>{
return loremIpsum({
count: 1,
units: 'sentences',
sentenceLowerBound:3,
sentenceUpperBound:3
})
})
list = list.concat(temList)
})
}

看起来跟基础组件List一样,其实唯一的区别就是会在滚动的时候自动执行loadMoreRows函数去更新list

参考三

主要功能可以从两方面概括:

  • 布局
    • Collection
    • Grid
    • List
    • Masonry(堆砌)
    • Table
  • 可组装功能
    • Arrow Key Stepper,单元格的方向键导航
    • AutoSizer,自动管理列表/表格的宽度和高度
    • CellMeasurer,自动计算单元格的高度,适用于动态高度的数据行
    • ColumnSizer,设定列宽的宽高管理
    • InfiniteLoader,支持分页预读取的无限滚动
    • MultiGrid, 类似冻结行列的组合Grid
    • ScrollSync,用于同步多个Grid之间的滚动的工具组件
    • WindowScroller,用于把滚动事件绑定在列表之上的容器(div或window)

这里以搜索界面的代码为例,这是一个包含了分页数据、外部容器滚动、动态高度列表。在这个例子里,我们用到了以下这些类(按嵌套的层级关系列出):

  • InfiniteLoader,用于分页取结果
    • WindowScroller,用于将滚动事件绑定在外部容器上
      • List,列表数据的呈现
        • CellMeasurer,计算单元格高度
  • CellMeasurerCache,用于计算和缓存不同的单元格的高度
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
// 列表的渲染方法:renderSearchResult()
renderSearchResult() {
const { recordPerPage, onFetchMore, refLayoutMain } = this.props;
const remoteRowCount = this.state.result.total;
const cache = this.cellMeasurerCache;

if (!refLayoutMain) {
return null;
}

return (
<InfiniteLoader
isRowLoaded={this.isResultRowLoaded}
loadMoreRows={onFetchMore}
rowCount={remoteRowCount}
threshold={recordPerPage - PAGE_PREFETCH_THRESHOLD}
>
{({ onRowsRendered, registerChild }) => {
// logger.debug('render window scroller with ref:', refLayoutMain);
const listMinHeight = 40;
const defaultPanelWidth = 896;
const mockOnRowsRendered = (...args) => {
const ret = onRowsRendered(...args);
// logger.debug('onRowsRendered:', ...args, 'return value:', ret);
return ret;
};

return (<WindowScroller
scrollElement={refLayoutMain}
>
{({ height, isScrolling, onChildScroll, scrollTop }) => {
// logger.debug(
// 'Window scroller render with height:', height, 'container:', refLayoutMain);
return (
<List
ref={(node) => { this.refResultList = node; registerChild(node); }}
onRowsRendered={mockOnRowsRendered}
className="setting-main-custom-card search-result-scroller"
autoHeight
height={height || listMinHeight}
width={this.panelWidth || defaultPanelWidth}
isScrolling={isScrolling}
onScroll={onChildScroll}
scrollTop={scrollTop}
deferredMeasurementCache={cache}
rowHeight={cache.rowHeight}
rowCount={remoteRowCount}
rowRenderer={this.renderListCell}

/>
);
}}
</WindowScroller>);
}}

</InfiniteLoader>);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 单元格的渲染方法:renderListCell()
renderListCell = ({ index, key, parent, style }) => {
// logger.debug('render list cell:', index, key, style);
const resultList = this.state.result.list;
const data = (index === resultList.length && this.props.searchResult.has_next) ?
loadingResultData.list[0] : resultList[index]; // loading or record
const cache = this.cellMeasurerCache;

return (
<CellMeasurer
cache={cache}
columnIndex={0}
key={key}
parent={parent}
rowIndex={index}
>
<SearchResultItemRenderer data={data} style={style} />
</CellMeasurer>
);
}