mini react-window(一) 实现固定高度虚拟滚动

1,723 阅读7分钟

我们在平常的开发中不可避免的会有很多列表渲染逻辑,在 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 />
);
// 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
// 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,让浏览器就可以提前知道哪些元素的属性将会改变,把元素提升到一个新层,提升性能,同时避免了重排重绘。

实现固定渲染虚拟滚动

  1. 创建自己实现组件的目录
// src/react-window/index.js

export {default as FixedSizeList} from './FixedSizeList'
// 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 值可如此计算
});
function createListComponent({
  getEstimatedTotalSize, // 估算内容高度
  getItemSize, // 每一项的高度
  getItemOffset, // 每一项的 top 值
}) {
....

contentStyle.height = getEstimatedTotalSize(this.props)

itemStyle.height = getItemSize(this.props)
itemStyle.top = getItemOffset(this.props, i)

实现效果如下,符合我们的预期:

  1. 实现可视区域内渲染 我们上面是直接对所有的列表进行了渲染,其实在可是区域外的数据,我们是不关心的,如果有数据更新也不应该进行渲染,因为我们看不到。所以我们要对渲染的截取索引进行处理。
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]
}

const FixedSizeList = createListComponent({
  ...
  // 开始索引我们需要向下取整,即使 item 滚动到一半,我们也要渲染
  getStartIndexForOffset: ({ itemSize }, offset) =>
    Math.floor(offset / itemSize),
  // 结束索引的计算为 开始索引 + 中间能展示的索引个数
  getEndIndexForOffset: ({ height, itemSize }, startIndex) =>
    startIndex + Math.ceil(height / itemSize) - 1 // 结束索引闭区间,所以 -1 (即算到了第八个,但是第八个其实是不展示的)
});

实现效果如下,可以看到我们只渲染了可是区域内能展示的数量

我们实现的滚动效果如下:

可以看到滚动不是很流畅,会有白屏,这就是为什么官方库会默认多两个元素的原因,预先渲染,避免白屏,我们继续优化;

// 定义需要预渲染的个数
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 的值改善体验效果,但是现有的基本就够用了。

本小节我们实现了固定高度虚拟列表,代码不是很多,感兴趣的小伙伴可以自己动手实现自己的虚拟滚动库,下一小节我们继续实现其他场景下的滚动列表,如有问题欢迎留言讨论。