react 虚拟列表之 FixedSizeList 封装


1. 长列表渲染

  • 如果有海量数据在浏览器里一次性渲染会有以下问题
    • 计算时间过长,用户需要长时间等待,体验差
    • CPU 处理时间过长,滑动过程中可能卡顿
    • GPU 负载过高,渲染不过来会出现闪动
    • 内存占用过多,严重会引起浏览器卡死和崩溃
  • 优化方法
    • 下拉底部加载更多实现懒加载,此方法随着内容越来越多,会引起大量的重排和重绘,依赖可能会卡顿
    • 虚拟列表 其实我们的屏幕可视区域是有限的,能看到的数据也是有限的,所以可以在用户滚动时,只渲染可视区域内的内容即可,不可见区域用空白占位填充, 这样的话页面中的 DOM 元素少,CPU、GPU 和内存负载小

2.长列表组件

npm i react-window --save

3. 固定高度列表实战

3.1 src\index.js

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 />)

3.2 fixed-size-list.js

src\fixed-size-list.js

import { FixedSizeList } from "react-window"
import "./fixed-size-list.css"
const Row = ({ index, style }) => (
  <div className={index % 2 ? "ListItemOdd" : "ListItemEven"} style={style}>
    Row{index}
  </div>
)
function App() {
  return (
    <FixedSizeList
      className="List"
      height={200}
      width={200}
      itemSize={50}
      itemCount={1000}
    >
      {Row}
    </FixedSizeList>
  )
}
export default App

3.3 fixed-size-list.css

src\fixed-size-list.css

.List {
  border: 1px solid gray;
}

.ListItemEven,
.ListItemOdd {
  display: flex;
  align-items: center;
  justify-content: center;
}
.ListItemOdd {
  background-color: lightcoral;
}
.ListItemEven {
  background-color: lightblue;
}

4.FixedSizeList实现

4. 1 全部渲染

首先实现传入的数据页面全部渲染

原理

4.1 .1fixed-size-list.js

src\fixed-size-list.js

import { FixedSizeList } from "./react-window"
import "./fixed-size-list.css"
const Row = ({ index, style }) => (
  <div className={index % 2 ? "ListItemOdd" : "ListItemEven"} style={style}>
    Row{index}
  </div>
)
function App() {
  return (
    <FixedSizeList
      className="List"
      height={200}
      width={200}
      itemSize={50}
      itemCount={1000}
    >
      {Row}
    </FixedSizeList>
  )
}
export default App

4.1.2 react-window\index.js

src\react-window\index.js

export { default as FixedSizeList } from "./FixedSizeList"

4.1.3 FixedSizeList.js

src\react-window\FixedSizeList.js

import createListComponent from "./createListComponent"
const FixedSizeList = createListComponent({
  getItemSize: ({ itemSize }) => itemSize, //每个条目的高度
  getEstimatedTotalSize: ({ itemSize, itemCount }) => itemSize * itemCount, //获取预计的总高度
  getItemOffset: ({ itemSize }, index) => itemSize * index, //获取每个条目的偏移量
})
export default FixedSizeList

4.1.4 createListComponent.js

src\react-window\createListComponent.js

import React from "react"
export default function createListComponent({
  getEstimatedTotalSize, //获取预计的总高度
  getItemSize, //每个条目的高度
  getItemOffset, //获取每个条目的偏移量
}) {
  return class extends React.Component {
    render() {
      const { width, height, itemCount, children: ComponentType } = this.props
      const containerStyle = {
        position: "relative",
        width,
        height,
        overflow: "auto",
        willChange: "transform",
      }
      const contentStyle = {
        height: getEstimatedTotalSize(this.props),
        width: "100%",
      }
      const items = []
      if (itemCount > 0) {
        for (let index = 0; index < itemCount; index++) {
          items.push(
            <ComponentType
              key={index}
              index={index}
              style={this._getItemStyle(index)}
            />
          )
        }
      }
      return (
        <div style={containerStyle}>
          <div style={contentStyle}>{items}</div>
        </div>
      )
    }
    //获取每个item的样式
    _getItemStyle = (index) => {
      const style = {
        position: "absolute",
        width: "100%",
        height: getItemSize(this.props),
        top: getItemOffset(this.props, index),
      }
      return style
    }
  }
}

4.2. 渲染首屏

4.2.1 FixedSizeList.js

src\react-window\FixedSizeList.js

import createListComponent from './createListComponent';
const FixedSizeList = createListComponent({
    getItemSize: ({ itemSize }) => itemSize,//每个条目的高度
    getEstimatedTotalSize: ({ itemSize, itemCount }) => itemSize * itemCount, //获取预计的总高度
    getItemOffset: ({ itemSize }, index) => itemSize * index, //获取每个条目的偏移量
+   getStartIndexForOffset: ({ itemSize }, offset) => Math.floor(offset / itemSize),//获取起始索引
+   getStopIndexForStartIndex: ({ height, itemSize }, startIndex) => {//获取结束索引
+       const numVisibleItems = Math.ceil(height / itemSize);
+       return startIndex + numVisibleItems - 1;
    }
});
export default FixedSizeList;

4.2.2 createListComponent.js

src\react-window\createListComponent.js

import React from 'react';
export default function createListComponent({
    getEstimatedTotalSize,//获取预计的总高度
    getItemSize,//每个条目的高度
    getItemOffset,//获取每个条目的偏移量
+   getStartIndexForOffset,
+   getStopIndexForStartIndex
}) {
    return class extends React.Component {
+       state = { scrollOffset: 0 }
        render() {
            const { width, height, itemCount, children: ComponentType } = this.props;
            const containerStyle = { position: 'relative', width, height, overflow: 'auto', willChange: 'transform' };
            const contentStyle = { height: getEstimatedTotalSize(this.props), width: '100%' };
            const items = [];
            if (itemCount > 0) {
+               const [startIndex, stopIndex] = this._getRangeToRender();
+               for (let index = startIndex; index <= stopIndex; index++) {
                    items.push(
                        <ComponentType key={index} index={index} style={this._getItemStyle(index)} />
                    );
                }
            }
            return (
                <div style={containerStyle}>
                    <div style={contentStyle}>
                        {items}
                    </div>
                </div>
            )
        }
        _getItemStyle = (index) => {
            const style = {
                position: 'absolute',
                width: '100%',
                height: getItemSize(this.props),
                top: getItemOffset(this.props, index)
            };
            return style;
        }
+       _getRangeToRender = () => {
+           const { scrollOffset } = this.state;
+           const startIndex = getStartIndexForOffset(this.props, scrollOffset);
+           const stopIndex = getStopIndexForStartIndex(this.props, startIndex);
+           return [startIndex, stopIndex];
+       }
    }
}

4.3. 监听滚动

4.3.1 createListComponent.js

src\react-window\createListComponent.js

import React from 'react';
export default function createListComponent({
    getEstimatedTotalSize,//获取预计的总高度
    getItemSize,//每个条目的高度
    getItemOffset,//获取每个条目的偏移量
    getStartIndexForOffset,
    getStopIndexForStartIndex
}) {
    return class extends React.Component {
        state = { scrollOffset: 0 }
        render() {
            const { width, height, itemCount, children: ComponentType } = this.props;
            const containerStyle = { position: 'relative', width, height, overflow: 'auto', willChange: 'transform' };
            const contentStyle = { height: getEstimatedTotalSize(this.props), width: '100%' };
            const items = [];
            if (itemCount > 0) {
                const [startIndex, stopIndex] = this._getRangeToRender();
                for (let index = startIndex; index <= stopIndex; index++) {
                    items.push(
                        <ComponentType key={index} index={index} style={this._getItemStyle(index)} />
                    );
                }
            }
            return (
+               <div style={containerStyle} onScroll={this.onScroll}>
                    <div style={contentStyle}>
                        {items}
                    </div>
                </div>
            )
        }
+       onScroll = event => {
+           const { scrollTop } = event.currentTarget;
+           this.setState({ scrollOffset: scrollTop });
+       }
        _getItemStyle = (index) => {
            const style = {
                position: 'absolute',
                width: '100%',
                height: getItemSize(this.props),
                top: getItemOffset(this.props, index)
            };
            return style;
        }
        _getRangeToRender = () => {
            const { scrollOffset } = this.state;
            const startIndex = getStartIndexForOffset(this.props, scrollOffset);
            const stopIndex = getStopIndexForStartIndex(this.props, startIndex);
            return [startIndex, stopIndex]
        }
    }
}

4.4. overscan (增加缓存区域)

  • 过扫描实质上是切断图片的边缘,以确保所有重要的东西显示在屏幕上 img

4.4.1 createListComponent.js

src\react-window\createListComponent.js

import React from 'react';
export default function createListComponent({
    getEstimatedTotalSize,//获取预计的总高度
    getItemSize,//每个条目的高度
    getItemOffset,//获取每个条目的偏移量
    getStartIndexForOffset,
    getStopIndexForStartIndex
}) {
    return class extends React.Component {
+       static defaultProps = {
+           overscanCount: 2
+       }
        state = { scrollOffset: 0 }
        render() {
            const { width, height, itemCount, children: ComponentType } = this.props;
            const containerStyle = { position: 'relative', width, height, overflow: 'auto', willChange: 'transform' };
            const contentStyle = { height: getEstimatedTotalSize(this.props), width: '100%' };
            const items = [];
            if (itemCount > 0) {
                const [startIndex, stopIndex] = this._getRangeToRender();
                for (let index = startIndex; index <= stopIndex; index++) {
                    items.push(
                        <ComponentType key={index} index={index} style={this._getItemStyle(index)} />
                    );
                }
            }
            return (
                <div style={containerStyle} onScroll={this.onScroll}>
                    <div style={contentStyle}>
                        {items}
                    </div>
                </div>
            )
        }
        onScroll = event => {
            const { scrollTop } = event.currentTarget;
            this.setState({ scrollOffset: scrollTop });
        }
        _getItemStyle = (index) => {
            const style = {
                position: 'absolute',
                width: '100%',
                height: getItemSize(this.props),
                top: getItemOffset(this.props, index)
            };
            return style;
        }
        _getRangeToRender = () => {
            const { scrollOffset } = this.state;
+           const { itemCount, overscanCount } = this.props;
            const startIndex = getStartIndexForOffset(this.props, scrollOffset);
            const stopIndex = getStopIndexForStartIndex(this.props, startIndex);
            return [
+               Math.max(0, startIndex - overscanCount),
+               Math.max(0, Math.min(itemCount - 1, stopIndex + overscanCount)),
                startIndex, stopIndex]
        }
    }
}

文章作者: 高红翔
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 高红翔 !
  目录