我们在平常的开发中不可避免的会有很多列表渲染逻辑,在 pc
端可以使用分页进行渲染数限制,在移动端可以使用下拉加载更多。但是对于大量的列表渲染,特别像有实时数据需要更新的场景(股票价格),会导致页面有很多计算和重绘,内存占用也会变多,这就需要我们对长列表处理进行优化。
长列表渲染
- 海量数据渲染会有如下问题
- 计算时间过长,用户等待时间长,体验差
CPU
处理时间过长,滑动过程可能卡顿GPU
负载过高,渲染不过来会闪动- 内存占用过多,严重会引起浏览器卡死和崩溃
- 优化
- 下拉底部加载更多,实现赖加载,但是如果内容越来越多会引起大量重排和重绘
- 虚拟列表,可视区域有限,看到的数据有限,在用户滚动时,指渲染可是区域内的内容即可,
dom
少,渲染少
在 github
上也有很多针对 react
的虚拟滚动的库,我们这里对 react-window 的使用和实现,进行一下简单的学习分享,了解不同虚拟滚动场景下的使用方式和 react
的优秀封装,希望对你有帮助。
固定高度场景
这种场景中我们已知每一项的渲染高度,可以根据渲染个数计算出整体高度,我们只需要对可是区域内的渲染进行渲染计算即可。
由上图可知,我们定义可以区域的高度为 200px
,每一项高度是 50px
,那么我们只需要把所有的列表进行截取,只渲染中间的内容即可,上下超出的部分不参与绘制,可以提升性能。
使用事例
我们使用 create-react-app
创建项目,修改代码如下:
// src/index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import FixedSizeList from './fixed-size-list'
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<FixedSizeList />
);
代码语言:javascript复制// src/fixed-size-list
// 固定高度列表
import {FixedSizeList} from 'react-window'
import './fixed.css'
// 渲染的每一行的 item 项
function Row({index, style}) {
return <div className={index % 2 ? 'odd': 'even'} style={style}>
Row {index}
</div>
}
function App() {
// 可视区的宽高 200,每一项高度 50,列表总数 1000
return <FixedSizeList className='list' height={200} width={200} itemSize={50} itemCount={1000}>
{Row}
</FixedSizeList>
}
export default App
代码语言:css复制// src/fixed.css
.list {
border: 1px solid gray;
}
.odd, .even {
display: flex;
align-items: center;
justify-content: center;
}
.odd {
background-color: pink;
}
.even {
background-color: antiquewhite;
}
我们使用官方库效果如下:
我们可以看到可视区内展示
4
项,但是dom
结构中展示了6
项,这是因为列表在上下滑动的时候做了一个缓冲,避免滚动的时候有个白屏的效果,类似缓存。那这里元素的定位为什么使用定位形式又使用
will-change
呢?这是使用了will-change
,让浏览器就可以提前知道哪些元素的属性将会改变,把元素提升到一个新层,提升性能,同时避免了重排重绘。
实现固定渲染虚拟滚动
- 创建自己实现组件的目录
// src/react-window/index.js
export {default as FixedSizeList} from './FixedSizeList'
代码语言:javascript复制// src/react-window/FixedSizeList.js
import createListComponet from './createListComponent'
// 传入组件的配置参数,返回一个组件
const FixedSizeList = createLstComponent({})
export default FixedSizeList
实现 FixedSizeList
组件时我们要注意我们没有直接写, react-window
提供了固定高度非固定高的等几种虚拟滚动场景,但是对于包裹元素来说基本都是一致的,只是具体的场景元素处理有不同,所以我们仿照官方库,先提供一个父组件,其他的具体场景的实现都是基于该父组件实现的,这种形式也就是我们说的高阶组件,就是这里的 createListComponent
。
// src/react-window/createListComponent.js
import React from 'react'
function createListComponet({}) {
return class extends React.Component {
render() {
// 这个类组件是返回的页面具体使用的那个组件,所以可以直接通过属性获取值
const { width, height, itemCount, children: ComponentType } = this.props;
// 我们根据上面的 dom 结构可以写出如下布局
const containerStyle = {
position: "relative",
width,
height,
overflow: "auto",
willChange: "transform",
};
const contentStyle = {
width: '100%',
height: ??? // 这里高度待定
}
const items = []
// 如果有列表长度,进行每一项的处理,样式待定
if (itemCount > 0) {
// 这里我们现渲染所有的数据,稍后做截取处理
for(let i = 0; i < itemCount; i ) {
items.push(<ComponentType index={i} style={this.getItemStyle(i)} key={i} />)
}
}
return <div style={containerStyle}>
<div style={contentStyle}>{items}</div>
</div>
}
// 每一项的样式
getItemStyle =(i) => {
const style = {
position: "absolute",
width: "100%",
height: ???,
top: ???,
};
return style;
}
}
}
上面的代码相信大家可以理解,我们对公共的样式结构进行了书写,同时对所有数据进行了渲染,这里有两处是空着的:
- 内容高度和每一项元素样式
因为我们这里实现的固定高度场景,所以可知内容高度可以直接计算,但是其他的非固定高度场景不能够复用,所以这里我们使用传入的方式;同时每一项的样式的高度和 top
值也是需要具体场景单独计算。还记得 createLstComponent
方法可以接受参数,我们进行参数处理:
const FixedSizeList = createListComponent({
getEstimatedTotalSize: ({ itemSize, itemCount }) => itemSize * itemCount, // 预计内容高度,固定高度直接相乘 就好
getItemSize: ({ itemSize }) => itemSize, // 固定高度直接使用
getItemOffset: ({ itemSize }, index) => itemSize * index // 因为元素是定位的,同时高度固定,所以 top 值可如此计算
});
代码语言:javascript复制function createListComponent({
getEstimatedTotalSize, // 估算内容高度
getItemSize, // 每一项的高度
getItemOffset, // 每一项的 top 值
}) {
....
contentStyle.height = getEstimatedTotalSize(this.props)
itemStyle.height = getItemSize(this.props)
itemStyle.top = getItemOffset(this.props, i)
实现效果如下,符合我们的预期:
- 实现可视区域内渲染 我们上面是直接对所有的列表进行了渲染,其实在可是区域外的数据,我们是不关心的,如果有数据更新也不应该进行渲染,因为我们看不到。所以我们要对渲染的截取索引进行处理。
render() {
...
if (itemCount > 0) {
// 需要计算得出截取的索引
const [startIndex, endIndex] = this.getRangeToRender()
for (let i = startIndex; i <= endIndex; i ) {
items.push(
<ComponentType index={i} style={this.getItemStyle(i)} key={i} />
);
}
}
...
}
state = {
scrollOffset: 0, // 向上卷去的高度,就是我们说的滚动距离,scrollTop,默认 0
}
getRangeToRender = () => {
const {scrollOffset} = this.state
const {itemCount} = this.props
// 索引的计算处理同样因为场景不同外部传入
// 根据卷去高度计算开始索引
const startIndex = getStartIndexForOffset(this.props, scrollOffset)
// 根据开始索引计算 结束索引
const endIndex = getEndIndexForOffset(this.props, startIndex)
return [startIndex, endIndex]
}
代码语言:javascript复制const FixedSizeList = createListComponent({
...
// 开始索引我们需要向下取整,即使 item 滚动到一半,我们也要渲染
getStartIndexForOffset: ({ itemSize }, offset) =>
Math.floor(offset / itemSize),
// 结束索引的计算为 开始索引 中间能展示的索引个数
getEndIndexForOffset: ({ height, itemSize }, startIndex) =>
startIndex Math.ceil(height / itemSize) - 1 // 结束索引闭区间,所以 -1 (即算到了第八个,但是第八个其实是不展示的)
});
实现效果如下,可以看到我们只渲染了可是区域内能展示的数量
我们实现的滚动效果如下:
可以看到滚动不是很流畅,会有白屏,这就是为什么官方库会默认多两个元素的原因,预先渲染,避免白屏,我们继续优化;
代码语言:javascript复制// 定义需要预渲染的个数
static defaultProps = {
overscanCount: 2, // 性能好可以多设置
}
getRangeToRender = () => {
const {scrollOffset} = this.state
const {overscanCount, itemCount} = this.props
const startIndex = getStartIndexForOffset(this.props, scrollOffset)
const endIndex = getEndIndexForOffset(this.props, startIndex)
// 向下滚动要取最大值,向上滚动时要取最小值,需要跟索引临界值对比
return [Math.max(0, startIndex - overscanCount), Math.min(itemCount - 1, endIndex overscanCount)]
}
实现效果如下,可以看到滚动起来还是很流畅的,但是快速滚动还是有显示白屏的概率,可以增加 overscanCount
的值改善体验效果,但是现有的基本就够用了。
本小节我们实现了固定高度虚拟列表,代码不是很多,感兴趣的小伙伴可以自己动手实现自己的虚拟滚动库,下一小节我们继续实现其他场景下的滚动列表,如有问题欢迎留言讨论。